🐢

优惠券秒杀

13756 字
69 分钟
优惠券秒杀

初识#

  • 优惠卷秒杀是高并发场景下的经典实战案例
  • 每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购

数据表

  • 特价优惠卷不仅有抢购的开始结束时间还有库存
  • 超卖、一人一单、多个命令的原子性如何保证、分布式缓存、秒杀性能保证

全局唯一Id#

Redis#

当用户成功抢购优惠卷往订单表中添加订单数据了,涉及到数据库存储数据就必须考虑订单数据的主键(订单ID)如何设定。

  • 采用数据库自增ID就存在ID规律性明显和受单表数据量限制。
  • 如果我们的id具有太明显的规则,用户猜测信息,比如商城在一天时间内,卖出了多少单
  • ,数据量过大,分库分表之后,它们是同一张表,所以他们的id是不能一样的, 我们需要保证id的唯一性。

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具

image.webp
image.webp

ID的组成部分 :

  • 符号位:1bit,最高位,永远为0,代表正数

  • 时间戳 :31bit,以秒为单位,可以使用69年

  • 序列号:32bit,秒内的自增序列,支持每秒产生2^32个不同ID

@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}

Redis单线程执行,StringRedisTemplate的increment方法,底层是基于Redis的INCR命令,这是绝对原子性的操作,即便在高并发下也能保证每次拿到的数字不一样

缺点

  • 强依赖 Redis 服务,每次生成 ID 都要有一次网络开销
  • RedisIdWorker 本质上就是“借助 Redis 维护序列号的雪花算法变种
  • 换来了无需配置机器 ID (WorkerId) 和不用担心时钟回拨的便利性

雪花算法#

分布式 ID 生成算法。不依赖 Redis,纯本地计算

优点:

  • 完全本地生成,不依赖外部中间件(如 Redis/DB),性能炸裂(单机几百万/秒)。
  • 整体呈递增趋势,对数据库索引友好。

缺点:

  • 时钟回拨问题:强依赖机器的时钟。如果服务器时间发生倒退(比如通过 NTP 同步时间回退了),可能会生成重复的 ID 或者导致发号阻塞。

号段模式#

为了解决纯依赖数据库自增太慢的问题(每次都要访问 DB)

原理:

  • 数据库不生成单个 ID,而是维护一个“当前最大 ID”。

  • 应用服务器每次向数据库请求,不是要 1 个 ID,而是要一个批次(号段),比如 [1000, 2000]。

  • 拿到号段后,放在应用服务器的本地内存(类似 AtomicLong)中。

  • 接下来这 1000 个请求,直接从本地内存发号,不需要访问数据库。等号段用完,再去数据库拉取下一个 1000 个。

优点:

  • 极大地降低了数据库压力(并发性能提升千倍)。
  • 即使数据库宕机短时间,应用依然可以依靠本地剩余的号段撑一会(高可用)。

缺点:

  • 架构稍显复杂。

  • 服务重启会导致内存中未用完的号段丢失(造成 ID 空洞,不连续,但这在业务上通常可以接受)。

添加优惠券#

Controller#

  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等
  • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
  • 因此可以看出两张表中的数据是一对一关系
  • 特价代金券由于优惠力度大,限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段
@RestController
@RequestMapping("/voucher")
public class VoucherController {
@Resource
private IVoucherService voucherService;
//新增普通券
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
//新增秒杀券
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
//查询店铺的优惠券列表
@GetMapping("/list/{shopId}")
public Result queryVoucherOfShop(@PathVariable("shopId") Long shopId) {
return voucherService.queryVoucherOfShop(shopId);
}
}

Service#

@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Override
public Result queryVoucherOfShop(Long shopId) {
// 查询优惠券信息
List<Voucher> vouchers = getBaseMapper().queryVoucherOfShop(shopId);
// 返回结果
return Result.ok(vouchers);
}
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
//补充tb_voucher表中没有的属性
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
}

秒杀下单#

image.webp
image.webp

image.webp
image.webp

  • 在不考虑并发的情况下,当用户下单,应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件。
  • 时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,
  • 创建订单,然后返回订单id,如果有一个条件不满足则直接结束
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
//全局唯一id引入
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
//拿到优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判断优惠券在抢购时间之前
if(voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
//判断秒杀是否结束
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if(voucher.getStock()<1){
return Result.fail("库存不足");
}
//存在减库存
boolean success= seckillVoucherService.update()
.setSql("stock=stock -1")
.eq("voucher_id",voucherId)//where条件
.update();
if(!success){
Result.fail("库存不足");
}
//创建订单
VoucherOrder voucherOrder =new VoucherOder();
//订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//用户ID
Long Userid = UserHolder.getUser().getId();
voucherOrder.setUserId(Userid);
//代金券ID
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//返回订单id
return Result.ok(orderId);
}

超卖问题#

初识#

  • 先查询库存充足再执行扣减库存的操作。这是两步操作,不具备原子性
  • 在高并发场景下,库存就剩一张特价优惠卷了
  • 多个线程同时判断了库存充足(这个时候还没有线程来得及扣减库存),就会导致多个线程都具备了扣减库存的资格,出现库存为负数的情况,这也就是所谓的超卖问题。超卖问题是典型的多线程安全问题

image.webp
image.webp

  • 维度悲观锁乐观锁
    基本信念冲突一定会发生冲突极大概率不会发生
    工作机制强行加锁,排队执行不加锁,先执行,失败再重试(CAS)
    底层实现synchronized、ReentrantLock、FOR UPDATECAS 指令、版本号机制(Version)
    CPU 开销线程阻塞/唤醒涉及内核态切换,开销大不阻塞线程,但自旋重试会消耗 CPU
    适用场景写多读少,并发冲突概率极高的场景读多写少,并发冲突概率低的场景
    在秒杀中的应用“一人一单”防重时,用分布式锁拦截,本质偏悲观扣减库存防超卖时,用 WHERE stock > 0 拦截,属于乐观锁思想
  • 使用悲观锁会发现虽然解决了超卖问题,性能较差,吞吐量暴跌,常用于插入数据

  • 乐观锁,严格意义上它不是锁而是一种锁的思想。常用于更新数据

版本号#

一个版本号每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功

在操作过程中,版本号只比原来大1 ,操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过

image.webp
image.webp

CAS#

image.webp
image.webp

Java 并发包(JUC)中底层操作类 Unsafe 的核心源码(具体是 getAndAddInt 方法的核心逻辑),也就是像 AtomicInteger 这种原子类能够实现无锁并发的基石。

乐观锁中最核心的自旋+CAS机制

  • 利用cas进行无锁化机制加锁

  • var5 是操作前读取的内存值,while中的var1+var2 是预估值

  • 如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

dowhile进行重试(自旋)线程不挂起、不阻塞,而是在 CPU 上不断重试。

  • 优点:不用进入阻塞,不用发生线程上下文切换,低并发下非常快

  • 缺点:高并发下大量线程同时失败、同时重试,会疯狂消耗 CPU

int var5; // 定义一个变量,用于存放当前从内存中读到的值
do {
//getIntVolatile 保证了可见性,每次都从主内存直接读取,而不是从线程的工作内存读
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;

原子性

  • 回答:CAS 的原子性是由 CPU 硬件指令保证的,比较 + 修改”是 CPU 层面的一条原子操作
  • CAS 并非真正的“无锁”,只是在软件层面无锁,在硬件层面依赖了极其底层的轻量级锁机制来保证原子性。

CAS 的经典缺陷:ABA 问题

现象:线程 1 读到数据 A。此时线程 2 把数据改成 B,然后又改回了 A。线程 1 执行 CAS 操作时,发现内存里的值依然是 A,于是欢快地执行成功了。

危害:对于数字累加无所谓,但如果是链表节点或者账户余额(扣款又退款),可能会引发严重的逻辑错误。

企业级解决方案:引入版本号(Version)或时间戳。不仅比较值,还要比较版本号。Java 中的 AtomicStampedReference 就是通过增加版本号邮戳来解决 ABA 问题的。在数据库中,通常表现为 UPDATE table SET val=新值, version=version+1 WHERE id=1 AND version=旧版本号。

AtomicLong#

image.webp
image.webp

AtomicLong(传统CAS)

所有线程竞争一个 value

  • 是一个只有单行道的收费站。平时车少(低并发),车来了直接交钱走人(CAS 成功),效率极高。
  • 但如果到了节假日(高并发),100 辆车同时挤在这个收费站前,每次只能进一辆。剩下的 99 辆车只能在原地不停地打转、踩油门(CPU 疯狂自旋),白白燃烧汽油(消耗 CPU 资源),导致系统整体性能暴跌。
  • “大量线程并发更新一个原子性的时候,问题就是自旋,会导致并发性问题”。怎么解决单行道拥堵?
  • 多开几个收费站。这就是 LongAdder 的核心思想:空间换时间,将单一的 CAS 竞争热点分散开来(分段 CAS)。

LongAdder#

不让所有线程都抢同一个变量,而是把一个热点变量拆成多个小变量,让不同线程去更新不同位置。

  • 少量量线程更新 base
  • 高并发时分散到多个 Cell

1.低并发场景—— 退化为传统 CAS

  • LongAdder 内部有一个基础变量叫 base。
  • 当并发量不高时,所有线程都直接尝试通过 CAS 去修改这个 base 的值。
  • 和 AtomicLong 一模一样,不需要额外的内存开销

2.高并发场景—— 触发“分段 CAS”

  • 当某个线程尝试 CAS 修改 base 失败了
  • 改变策略,初始化一个 Cell[] 数组(默认大小是 2 的 N 次方,最多不超过 CPU 核数)。你可以把 Cell 理解为新增的收费站窗口。

3.智能分流与自动迁移

哈希路由:新来的线程不再死磕 base

  • 通过自己线程内部的一个 Hash 值(Thread.getProbe()),去和 Cell 数组长度进行取模,计算出自己应该去哪个 Cell 窗口排队交钱。
  • 比如线程 A 去 Cell[0],线程 B 去 Cell[1]。竞争热点被分散

Hash冲突

  • 线程 A 和线程 B 运气不好,Hash 冲突了,都分配到了 Cell[1]。
  • 线程 A 抢先一步 CAS 成功了,线程 B 失败了。
  • 如果按照老套路:线程 B 会在 Cell[1] 这里死循环自旋。

自动迁移实现自旋减负

  • 线程 B 一看 Cell[1] CAS 失败,它不会死等,而是重新生成一个 Hash 值,换一个 Cell(比如跑到 Cell[2])再去尝试 CAS
  • 达到一定次数后,当前线程会去进行自动迁移,不去盲目等待)。这彻底终结了无意义的 CPU 空转

汇总

  • 调用 longAdder.sum() 获取最终结果时,它会把 base 的值,加上所有 Cell 数组里的值,汇总加起来返回给你。(总收入 = 主收费站收入 + 所有新增窗口收入)。

伪共享

问:既然是数组,Cell[0] 和 Cell[1] 在内存中是紧挨着的,这会不会产生问题?

答:会触发 CPU 缓存行的“伪共享”问题,导致性能反而下降!

原理解析:

  • CPU 从内存读取数据时,不是一个字节一个字节读的,而是一块一块读的(这叫 Cache Line 缓存行,通常是 64 字节)。
  • 由于 Cell 对象很小,Cell[0] 和 Cell[1] 极大概率会被装进同一个 CPU 缓存行里。

致命打击:

  • 如果 CPU1 修改了 Cell[0],根据底层的缓存一致性协议(如 MESI),整个缓存行都会失效。
  • CPU2 想修改 Cell[1],发现缓存失效了,只能被迫去主内存重新慢速读取。这就导致了明明是操作不同的变量,却互相影响,分段失去了意义。

问题解决

  • LongAdder 中 Java 8 Cell 类的源码,类头上加了一个注解@sun.misc.Contended。
  • 这个注解的作用叫缓存行对齐(Padding)”。它强行在 Cell 对象的前后塞入无用的空字节(比如补齐 64 字节),强行把 Cell[0] 和 Cell[1] 挤到不同的缓存行中去。
  • 这样,各个线程修改各自的 Cell,物理上绝对隔离,彻底释放了多核 CPU 的并发性能!
对比项AtomicLongLongAdder
核心思想单点 CAS分段 CAS
内部结构一个 valuebase + Cell[]
低并发表现很好也很好
高并发表现大量线程竞争同一个 value,CAS 失败多线程分散到多个 Cell,竞争降低
内存开销小更大
取值成本直接读一个 value需要累加 base 和所有 Cell
结果一致性单个值,适合精确原子操作sum() 非强一致快照
适合场景并发不高、需要精确原子值高并发统计计数

set tock = stock - 1 where voucher_id = #{voucherId} and stock > 0

boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId)
.gt("stock",0); //where id = ? and stock > 0
.update()

一人一单#

基础#

image.webp
image.webp

  • 一个用户只能下一个单,而不是让一个用户下多个单,即实现一人一单
  • 获取用户id通过套餐id和userId查询订单数量>0则用户已购买
  • 扣减库存要求stock>0防止超卖,创建订单
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
//全局唯一id引入
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
//拿到优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//判断优惠券在抢购时间之前
if(voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
//判断秒杀是否结束
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//判断库存是否充足
if(voucher.getStock()<1){
return Result.fail("库存不足");
}
// 一人一单
Long Userid = UserHolder.getUser().getId();
// 查询订单
int count = query().eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
//判断是否存在
if(count>0){
//用户已经购买过了
return Result.fail("用户已经购买了一次");
}
//存在减库存
boolean success= seckillVoucherService.update()
.setSql("stock=stock -1")
.eq("voucher_id",voucherId)//where条件
.gt("stock",0)
.update();
if(!success){
Result.fail("库存不足");
}
//创建订单
VoucherOrder voucherOrder =new VoucherOrder();
//订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//用户ID
voucherOrder.setUserId(Userid);
//代金券ID
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//返回订单id
return Result.ok(orderId);
}
  • 这里是判断订单存不存在于数据库中,因此原本基于版本号或者stock库存的乐观锁思想在这里就无法沿用了,因此为了确保线程安全,我们将要采用悲观锁synchronized锁。
  • 乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作

方法级锁#

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}

锁的究竟是谁?

  • 在非静态方法上加 synchronized,锁住的是 this 对象。
  • 在 Spring Boot 中,Service 类默认是单例(Singleton)的。
  • 这就意味着整个 JVM 进程中,只有一个该 Service 对象实例。

粒度如何?

  • 相当于给整个方法建了一堵墙,开了一扇只能容纳一人的门。

  • 假设用户 A 和用户 B 同时来抢购不同的优惠券,或者即使是抢同一张券,理论上他们之间是不应该相互影响的。因为锁住的是整个 this 对象,用户 A 进去了,用户 B 就必须在门外干等。

  • 如果同时有 1000 个不同用户发起请求,系统硬生生把高并发变成了“排队单线程”执行,系统的吞吐量(TPS)会暴跌至谷底

用户级锁#

@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}

锁的究竟是谁? 锁住的是当前请求用户的 ID 字符串对象

为什么要用 .toString().intern()?

只写 userId.toString():

  • 因为每次 toString() 都会在 JVM 堆内存中 new 一个全新的 String 对象。
  • 两个请求的 userId 都是 10086,它们也是两个不同的对象,拥有两把不同的锁,锁会失效。
  • synchronized 锁的是对象地址

加上 .intern():

  • 去 JVM 的字符串常量池中寻找。如果池子里已经有了 “10086”,就返回池子里的引用;
  • 如果没有,就放进去再返回。只要是同一个用户 ID,拿到的永远是同一个内存地址的 String 对象

优化的意义

  • 锁的粒度从“全局锁”缩小到了“用户锁”。现在,用户 A 和用户 B 并发请求时,拿的是不同的锁,互不干扰,完全并发执行。
  • 只有当“用户 A 的第一次请求”和“用户 A 的第二次请求”同时到达时,才会发生阻塞,完美实现了“防重/一人一单”的需求,同时极大保全了系统性能 事务注解

事务注解#

由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效(异步秒杀JVM)

方法上的@Transactional注解为了保证查询和扣减操作原子性设计的,梳理一下该方法的执行流程。

事务是通过 AOP(动态代理)实现的。

  • 开启数据库事务。

  • 执行你的业务代码(进入 synchronized 块)。

  • 业务代码执行完毕(退出 synchronized 块,释放锁)。

  • 异常其他线程进入

  • 提交数据库事务。

Bug 假设用户 A 并发发起了两次请求(线程 1 和 线程 2):

  • 线程 1 获取了锁,判断没买过,扣减库存,创建订单,然后释放了锁。但是此时,事务还没有提交到数据库!

  • 线程 2 瞬间抢到了刚刚释放的锁,进去执行 count 查询。由于线程 1 的事务还没提交,线程 2 查到的 count 依然是 0

  • 线程 2 也顺利走完了流程。一人一单防重失败

如何修复(更好的 JVM 级实现方案)?

  • 必须保证:锁的范围要大于事务的范围。 必须先提交事务,再释放锁。
  • 正确的做法是将锁加在调用该方法的外层,或者在 Service 中获取当前类的代理对象来调用
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
return this.createVoucherOrder(voucherId);
}
  • 由于我们使用的是方法调用方法(锁的),而在相同类里方法调用方法使用的是this关键字,this代表当前类的对象(不是Spring的代理对象)

  • Spring 没机会在外面加开启事务,提交事务,回滚事务

  • 现在的this调用是非代理对象不拥有事务功能(Spring事务失效的可能性之一),因此事务管理将会失效

  • 事务生效是因为Spring对当前类实现了动态代理,是拿到了接口动态代理对象进行的事务管理,

  • SpringAOP生成的代理实现了这个接口,代理对象去调用带@Transactional的方法

IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀是否开始
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始!");
}
//3.判断秒杀是否结束
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束!");
}
//4.判断库存是否充足
if (voucher.getStock()<1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
获取到代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
</dependency>
@EnableAspectJAutoProxy(exposeProxy = true)

潜在问题#

image.webp
image.webp

部署了多个tomcat,每个tomcat都有一个属于自己的jvm,

  • 假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,
  • 那么他们的锁对象是同一个,是可以实现互斥的,
  • 现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样
  • 但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,
  • 但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失

分布式锁#

初识#

  • 满足分布式系统或集群模式下多进程可见并且互斥的锁。
  • 分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么就能锁住线程
  • 不让线程进行,让程序串行执行,不同JvM之间加锁,实现1,2和3,4线程互斥

基于 MySQL 的分布式锁

实现原理

互斥机制:利用 MySQL 的 GET_LOCK 和 RELEASE_LOCK 函数来实现互斥锁。GET_LOCK 试图获取一个锁,如果获取成功则返回 1,否则返回 0。RELEASE_LOCK 用于释放锁。

特点

  • 高可用性:依赖于 MySQL 数据库的高可用性,通常被认为是好的。

  • 高性能:由于数据库操作的开销相对较高,其性能一般。

  • 安全性:当客户端断开连接时,锁会自动释放,这提供了一定的安全性。

基于 Redis 的分布式锁

实现原理

互斥机制:利用 Redis 的 SETNX 命令来实现互斥锁。SETNX 命令只有在键不存在时才设置键值,从而实现锁的获取。同时,可以使用 EXPIRE 命令为锁设置一个超时时间,以防止死锁。

特点

  • 高可用性:Redis 本身具有高可用性,通常被认为是好的。

  • 高性能:Redis 是内存数据库,操作速度快,性能好。

  • 安全性:通过设置锁的超时时间,可以自动释放锁,防止死锁。

基于 Zookeeper 的分布式锁

实现原理

互斥机制:Zookeeper 提供了分布式协调功能,可以利用其临时顺序节点来实现分布式锁。客户端在获取锁时创建一个临时顺序节点,只有当该节点是最小顺序节点时才认为获取锁成功。释放锁时删除该节点。

特点

  • 高可用性:Zookeeper 本身具有高可用性,通常被认为是好的。

  • 高性能:Zookeeper 的性能一般,因为它需要进行网络通信和协调多个节点。

  • 安全性:Zookeeper 的临时节点机制可以确保在客户端断开连接时自动释放锁

image.webp
image.webp

初级#

image.webp
image.webp

实现获取锁和释放锁两种基本方法。

  • 使用Redis的setnx命令,多个线程进入时同时执行setnx命令,谁set成功了这个key就获取了这把锁,返回1
  • 其他线程再执行setnx就回返回0,获取锁失败。一人一单业务上就是多个线程都同时尝试在某一个用户账号上抢购,
  • Key的组成:KEY_PREFIX = “lock:” name = “order<1001>”有参构造传入key的统一锁前缀 + 具体业务锁名称,Value为线程ID并设置过期时间
//分布式锁
public class SimpleRedisLock implements ILock {
//锁的名称
private String name;
//引入Redis
private StringRedisTemplate stringRedisTemplate;
//有参构造传入key的统一锁前缀 + 具体业务锁名称
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
//Redis中的key设计(统一的key前缀)
private static final String KEY_PREFIX ="lock:";
//锁的名称不能写死
//任何业务需要有不同的锁(name) value为线程id
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = Thread.currentThread().getId() + "";
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
//释放锁
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}

String threadId = ID_PREFIX + Thread.currentThread().getId();

唯一标识:为每个线程生成一个唯一标识符,方便在日志、调试或分布式锁等场景中区分不同的线程。

  • 获取锁:
    • 互斥:确保只能有一个线程获取锁

    • 非阻塞:尝试一次,成功返回true,失败返回false

@Override
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀是否开始
if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始!");
}
//3.判断秒杀是否结束
if(voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束!");
}
//4.判断库存是否充足
if (voucher.getStock()<1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//获取锁对象,有参构造Key
SimpleRedisLock Lock = new SimpleRedisLock("order" + userId, stringRedisTemplate);
//获取锁
boolean isLock = Lock.tryLock(1200);//设置过期时间
//判断是否获取锁成功
if(!isLock){
//获取锁失败
return Result.fail("不予许重复下单");
}
try {
//代理对象执行事务逻辑Transactional
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
Lock.unlock();
}

误删锁#

锁的时间必须超过线程执行业务的时间,才能保证线程安全,超时释放优化

image.webp
image.webp

  • 持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,
  • 这时线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中
  • 线程1执行完业务,走到了删除锁逻辑,把本应该属于线程2的锁进行删除,这就是误删别人锁的情况。
  • 造成的后果就是线程3就拿到了锁,相当于线程2和线程3一起执行业务,出现并发问题的可能性大大增加。

解决方案:

  • 在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,

  • 如果获取到成功执行了setnx命令,那么把key(用户唯一)的value(线程唯一)设置为该线程的独特标识就可以了。

  • 在获取锁时存入线程标示(可以用UUID表示),两台应用服务器上都可能有 ID 为 23 的线程定义Redis的Value值

  • 用Static Final表示每个应用实例启动时生成一次,不是每次加锁生成

  • 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致,如果一致则释放锁

image.webp
image.webp

image.webp
image.webp

//Redis中的key设计
private static final String KEY_PREFIX ="lock:";
private static final String ID_PREFIX
=UUID.randomUUID().toString();
//锁的名称不能写死
//任何业务需要有不同的锁
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = ID_PREFIX+Thread.currentThread().getId() + "";
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
//释放锁
@Override
public void unlock() {
//获取线程标示
String threadId = ID_PREFIX+Thread.currentThread().getId() + "";
//锁中的标示
String id= StringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if(threadId.equals(id)){
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

Lua脚本#

一人多单

  • 线程 A 拿到锁,随后执行 GET,确认锁值属于自己。A 的锁恰好过期;线程 B 拿到同名锁。

  • A 接着执行 DEL(Key),把 B 的锁删了

  • 比对和删除必须是一个原子性操作

  • 根源出在获取锁标识并判断是否一致动作和释放锁的动作不具备原子性,让其他线程有了可乘之机,引入Lua脚本,解决多条命令原子性问题。

  • RedisTemplate中,可以利用execute方法(Lua脚本对象及位置,UserId的Key,线程ID的Value)去执行lua脚本

image.webp
image.webp

image.webp
image.webp

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
  • Lua脚本放到src/main/resources/unlock.lua
  • redis.call(‘SET’, ‘name’, ‘Rose’)KEYS[1]为 name,ARGV[1]为 Rose
  • KEYS[1] = “lock:order”区分线程 ARGV[1] = “UUID<线程id>”区分JVM
//静态代码块,类加载时执行一次
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
//创建 Spring Redis 的 Lua 脚本对象
UNLOCK_SCRIPT = new DefaultRedisScript<>();
//Lua脚本放置的位置
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
//Collections.singletonList 创建一个只包含一个元素的 List
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}

Redission#

初识#

  • 超时释放可能造成的并发问题
  • 原因是我们给锁加上了过期时间,这就造成了可能线程还未执行完业务就把锁释放了
  • 其他线程就可以拿到锁乘虚而入,这是我们尚未解决的

基于setnx实现的分布式锁存在下面的问题:

重入问题:

  • 线程 A 已经获得锁后,又调用了一个同样需要这把锁的方法:

  • methodA() -> methodB()
  • 如果锁不可重入,A 第二次加锁会认为锁被占用,于是一直等待自己释放锁,最终造成死锁。

  • synchronized 和 ReentrantLock 都支持重入,Redisson 也支持

不可重试:

  • SetNX在最大等待时间内不断尝试,如果超时仍未成功,再返回失败
  • Redisson 的 tryLock(waitTime, leaseTime, unit) 就实现了这个能力

超时释放

  • 加锁时增加了过期时间,可以防止死锁
  • 卡顿的时间超长,锁提前失效后,其他线程可能进入业务,造成并发问题

主从一致性

  • 主机需要异步的将数据同步给从机
  • Redis 主节点写入锁后,还没有同步到从节点,主节点就宕机了。
  • 从节点升级为主节点后,可能不存在原来的锁:
  • 旧主节点:有锁,新主节点:没有锁
  • 此时其他线程可能误认为没有锁,从而同时执行同一份业务。

image.webp
image.webp

image.webp
image.webp

可重入锁#

  • 利用@Configuration注解和@Bean注解定义Redission客户端
  • redissonClient.getLock(“lock:order:” + userId);获取锁(可重入),指定锁的名称

导入Redission依赖

<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>

配置RedissionConfig客户端

@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(){
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6379")
.setPassword("");
// 创建RedissonClient对象
return Redisson.create(config);
}
}
@Resource
private RedissionClient redissonClient;
Long userId = UserHolder.getUser().getId();
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("lock:order:" + userId);
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
//tryLock(无参)实现看门狗机制
boolean isLock = lock.tryLock();
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}

原理#

  • Redisson 使用 Redis Hash 保存锁。定义个Value记录可重试次数
  • 在实现可重入性这块也借鉴了ReentrantLock的方式。

image.webp
image.webp

死锁

  • 简单的 SETNX,因为锁已经被 method1 占用了,method2 只能在原地干等
  • method2 是 method1 调用的,method2 等不到锁就不会执行完,它不执行完 method1 就无法释放锁

ReentrantLock可重入锁的实现方式

if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(current);
return true;
} else if (getExclusiveOwnerThread() == current) {
setState(getState() + 1); // 重入计数
return true;
}

Lock核心原理、

  • 底层的一个voaltile的一个state变量来记录重入的状态

  • 内部维护一个 state 计数器(int 类型)记录锁的持有次数。

  • 首次获取锁:state 从 0 通过 CAS 设置为 1,并记录当前线程为 exclusiveOwnerThread。

  • 重入获取锁:如果当前线程已是锁的持有者,直接将 state++,无需进入等待队列。

  • 释放锁:state—,当 state 归零时清空持有线程并唤醒等待队列中的线程

Hash 结构拆解为三层含义:

  • KEY(大 Key):锁的名字,比如叫 lock,锁是否存在

  • FIELD(小 Key):持有这把锁的线程标识。这把锁被哪个线程持有

  • VALUE(值):重入的次数(count)。一开始是 1,重入一次变 2,释放一次变 1,直到减为 0 时删除这把锁。

Redission为了保证查看锁是否存在和获取锁两个动作的原子性,依旧使用了Lua脚本。

  • 明确脚本调用时传入的三个参数:

  • KEYS[1]:锁的名字(例如 “myLock”)。

  • ARGV[1]:锁的过期时间(例如 30000 毫秒)。

  • ARGV[2]:当前请求的线程标识(UUID

分支1

if (redis.call('exists', KEYS[1]) == 0) then //判断锁存在,如果==0,就表示当前这把锁不存在
redis.call('hset', KEYS[1], ARGV[2], 1); //判断同一线程再次请求锁
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
  • 用 hset 创建一个 Hash,记录下当前线程 ARGV[2],并将重入次数设为 1。接着给这把锁设置过期时间 ARGV[1] 以防死锁。
  • 返回 nil 代表抢锁成功。

分支2

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
//同一线程再次请求锁**(可重入场景)将重入次数+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
  • 如果锁存在而且是自己的,那就说明是重入操作。调用 hincrby 把 value 的值加 1(比如从 1 变成 2)。
  • 因为我又加了一把锁,我要重新刷新一下锁的过期时间(续期)。返回 nil 代表重入成功。

分支3

return redis.call('pttl', KEYS[1]);

如果上面两个 if 都没进去,说明锁被别人占了。调用 pttl 命令,查看这把锁离过期还有多少毫秒。将剩余存活时间返回给 Java 客户端。

抢锁过程中,获得当前线程,通过tryAcquire进行抢锁

  • 先判断当前这把锁是否存在,如果不存在,插入一把锁,返回null
  • 判断当前这把锁是否是属于当前线程,如果是,则返回null
  • 所以如果返回是null,则代表着当前这哥们已经抢锁完毕,或者可重入完毕,
  • 如果以上两个条件都不满足,则进入到第三个条件,返回的是锁的失效时间,while( true) 再次进行tryAcquire进行抢锁

如果 Lua 脚本返回了 nil,Java 代码就知道自己拿到锁了,直接往下执行业务。

  • 那如果返回的不是 nil(而是锁的剩余时间 pttl)呢? Redisson 底层并没有直接报错放弃,而利用了返回的那个 pttl 时间:
  • Java 客户端会利用 Semaphore(信号量)或者基于 Redis 的 Pub/Sub(发布订阅机制)进行阻塞等待。
  • 等待的时间就是刚才返回的 pttl(人家还有多久释放,我就等多久,不盲目消耗 CPU)。
  • 等别人释放锁后,被唤醒,触发一个 while(true) 的循环,重新再去执行上面的 Lua 脚本抢锁。
  • 释放锁的逻辑与获取锁的逻辑十分相似,只不过倒了过来。

当执行 lock.unlock() 时,同样会发送一段 Lua 脚本:

  • 先判断当前持有锁的是不是自己。如果不是,直接抛异常(你不能释放别人的锁)。

  • 如果是自己,执行 hincrby -1(把重入次数减 1)。

  • 关键判断:减去 1 之后,如果重入次数 > 0,说明外层方法还需要这把锁,此时不能删除大 Key,仅仅是重置一下过期时间。

  • 减去 1 之后,如果重入次数 == 0,说明是最外层的方法要退出了,此时直接执行 del KEYS[1],彻底释放分布式锁,并通过发布订阅机制通知其他正在等锁的线程。

锁重试看门狗#

业务开始运行 -> 拿到 30 秒的锁 -> 过了 10 秒 -> 看门狗醒来,把锁恢复到 30 秒 -> 又过 10 秒 -> 看门狗醒来,又恢复到 30 秒… 只要你的业务没调用 unlock(),这个心跳就会永远跳动下去

Lua脚本中三个逻辑分支,前两个逻辑分支都return nll,可以正常拿到锁执行业务,但第三个逻辑分支却因为锁被别人占用了不得不进行重试环节。

  • 只要我的业务还没执行完,你就帮我不断地去 Redis 里把锁的时间延长。
  • 一旦我宕机了,你就跟着停止续期,让锁自然过期。它就是 Redisson WatchDog(看门狗)机制。

2.实现解析

从Redission的源码出发,调用lock方法时的传参,有两种情况,传参或者不传参。

  • waitTime是抢锁前最多等多久,leaseTime 是抢到后持有多久
  • 看门狗实现看是否显式指定leaseTime,显示则不启用
tryLock(waitTime, unit)
tryLock(waitTime, leaseTime, unit)
lock(leaseTime, unit)

lock.lock(10, TimeUnit.SECONDS) (带时间参数)

  • 底层逻辑:你显式传入了 leaseTime(大于 0)。Redisson 会绝对服从你的指令,将锁的过期时间设置为 10 秒。

  • 致命后果:看门狗机制将不会生效! 10 秒一到,不管你业务有没有执行完,锁直接释放。

lock.lock() (无参方法)

  • 底层逻辑:默认传入的 leaseTime 是 -1

  • 触发看门狗:Redisson 发现你没传时间,就会使用默认的看门狗时间 getLockWatchdogTimeout()(默认是 30 秒)来加锁。并且在加锁成功后,偷偷开启一个后台续命线程。

抢锁失败后的逻辑

  • 不会像纯 while(true) 那样疯狂空转消耗 CPU。

  • 利用 Redis 的 Pub/Sub(发布/订阅)机制,订阅这把锁释放的消息。

  • 利用 Semaphore(信号量)将当前线程阻塞挂起。阻塞的最大时间就是刚查出来的 ttl 时间。

  • 一旦收到别人释放锁的消息,或者等到了 ttl 时间,线程被唤醒,再进入 while(true) 循环重新去尝试抢锁。

  • (这种“订阅 + 阻塞挂起 + 唤醒重试”的机制,既保证了抢锁的及时性,又极大减轻了 CPU 和 Redis 的压力。

抢锁成功呢,看门狗机制又是如何发挥作用实现自动延期的呢。

  • 如果你调用了无参的 lock() 并且成功拿到了锁(ttlRemaining == null)
  • scheduleExpirationRenewal(threadId) 方法就会被调用,这就是看门狗的启动开关。
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;

第一步:设定定时任务(TimerTask)

  • Redisson 底层并没有用传统的线程池死循环
  • 使用 Netty 的 HashedWheelTimer(时间轮)实现了一个轻量级的延时任务 Timeout task。
  • 它会告诉你:“等一段时间后,去执行续约逻辑”。

第二步:什么时候去续约?(10秒法则)

  • 看门狗默认的超时时间(WatchdogTimeout)是 30 秒。
  • Redisson 内部硬编码了一个规则:每隔 WatchdogTimeout / 3 的时间去执行一次续约。
  • 只要锁还在,每隔 10 秒钟,看门狗就会苏醒一次。 第三步:怎么续约?(递归调用与 Lua 脚本)
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// 续期的主要方法
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
  • 当 10 秒倒计时结束,执行 renewExpiration() 内部的代码:

  • 发 Lua 脚本给 Redis:判断当前这把锁是不是还是当前线程持有。

  • 刷新 TTL:如果还是自己持有,就执行 pexpire 命令,把这把锁的存活时间重新重置回 30 秒!

  • 无限循环(递归):续期成功后,调用自己(递归),重新安排下一个 10 秒后的定时任务。

如果服务器宕机了,看门狗会无限续期导致死锁吗?

  • 不会,看门狗的 TimerTask 是运行在当前 JVM 进程内存中的延时任务。如果服务器突然断电宕机,或者 JVM 进程被杀掉,看门狗线程也会瞬间死亡。

  • 最终结果:由于没有人再去周期性地调用 renewExpiration() 发送续约命令,Redis 中的那把锁会在度过它生命中最后的 30 秒后,自然过期释放。其他服务器上的线程就能重新抢到这把锁。

  • Redisson 设计的精妙之处:用 JVM 内部的生命周期,去兜底分布式环境的容错性

MutiLock原理#

为了提高redis的可用性,我们会搭建集群或者主从

  • 写命令到主机上, 主机会将数据同步给从机,
  • 假设在主机还没有来得及把数据写入到从机去的时候
  • 此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。

image.webp
image.webp

MutiLock锁

  • 使用这把锁不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,
  • 只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,
  • 只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

img
img

原理

  • 设置了多个锁时,redission会将多个锁添加到一个集合中
  • 用while循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,
  • 假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功, 那么此时才算是加锁成功,
  • 如果在4500ms有线程加锁失败,则会再次去进行重试.

image.webp
image.webp

异步秒杀#

初识#

image.webp
image.webp

很多操作是要去操作数据库的,而且还是一个线程串行执行, 这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行,那么如何加速呢?

优化方案:

  • 将判断库存和用户是否下过单放在redis中判断
  • 为了保证原子性使用lua脚本,通过返回值判断是否能下单
  • 将能下单的任务丢到一个阻塞队列中,另一个线程来执行该队列中的任务。实现异步下单

image.webp
image.webp

秒杀资格判断#

在保存秒杀券的时候,将库存信息存一份到redis;seckill:stock<订单id> 为key 库存量为value

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到Redis中
//SECKILL_STOCK_KEY 这个变量定义在RedisConstans中
//private static final String SECKILL_STOCK_KEY ="seckill:stock:"
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}

Lua脚本#

  • 编写lua脚本,参数需要3个value,优惠券ID 用户ID和订单ID,脚本业务判断库存是否充足,用户是否下单,不满足就返回1或者2。
  • 返回0代表能顺利下单,此时扣除redis优惠券库存-1,保存用户ID到redis(set类型) seckill:order<订单id> 用户为key value为ID。
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId 创建seckill:order:
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

去下单时,是通过lua表达式去原子执行判断逻辑,如果判断我出来不为0 ,则要么是库存不足,要么是重复下单,返回错误信息,如果是0,则把下单的逻辑保存到队列中去,然后异步执行

@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
//TODO 保存阻塞队列
// 3.返回订单id
return Result.ok(orderId);
}

Executors.newSingleThreadExecutor();获取线程对象.submit(线程方法)

  • JAVA代码需要创建线程池newSingleThreadExecutor和阻塞队列BlockingQueue这里都是用的基础的
  • 类初始化后线程池会分配空闲线程执行new VoucherOrderHandler()。这是一个任务类,就是可以被线程执行的一段代码。

创建队列(Lua脚本判断资格和订单信息和代理对象定义在队列里面)

  • 当用户点击抢购秒杀发起请求的时候,执行seckillVoucher方法,先执行lua脚本,判断结果是否为0,结果不为0直接返回无库存或者下过单了

  • 如果为0将创建voucherOrder对象并放入阻塞队列中去,同时获取代理对象(后面代码保证事务的)

  • 当拿到订单信息以后,会执行创建订单handleVoucherOrder(voucherOrder)

  • 创建订单的时候加锁保证了一人一单,因为防止事务失效(因为spring事务是在threadlocal线程副本变量中,其他线程拿不到,这里是多线程会事务失效),所以将接口定义在最外面可以调用创建订单方法保证事务性

    线程会一直不断的执行他的run方法

while(True)从队列中获取订单信息

  • 这里使用了take方法,BlockingQueue的take()方法是一个阻塞式获取元素的方法 当队列中没有元素时,调用take()的线程会被挂起(阻塞)
  • 进入WAITING状态,不会占用 CPU 资源;直到有其他线程向队列中添加了元素(即新任务入队),阻塞的线程才会被唤醒,继续执行take()并返回新元素。

创建订单任务执行数据库扣减库存和保存订单ID的操作。

//创建队列消费消息完成下单
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为0
if (r != 0) {
// 2.1.不为0 ,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
// 2.4.用户id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞队列
orderTasks.add(voucherOrder);
//3.获取代理对象
proxy = (IVoucherOrderService)AopContext.currentProxy();
//4.返回订单id
return Result.ok(orderId);
}
//异步处理线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于线程池处理的任务
// 当初始化完毕后,就会去从对列中去拿信息
private class VoucherOrderHandler implements Runnable{
@Override
public void run() {
while (true){
try {
// 1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1.获取用户
Long userId = voucherOrder.getUserId();
// 2.创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 3.尝试获取锁
boolean isLock = redisLock.lock();
// 4.判断是否获得锁成功
if (!isLock) {
// 获取锁失败,直接返回失败或者重试
log.error("不允许重复下单!");
return;
}
try {
//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
// 5.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
log.error("用户已经购买过了");
return ;
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
// 扣减失败
log.error("库存不足");
return ;
}
save(voucherOrder);
}
  • 先利用Redis完成库存余量、一人一单判断,完成抢单业务
  • 再将下单业务放入阻塞队列,利用独立线程异步下单
  • 基于阻塞队列的异步秒杀存在哪些问题?
  1. 队列放满后可能丢订单,Redis 显示用户已经买过,Redis 库存已经减少,数据库却没有订单
  2. 内存队列不适合多实例部署,每台机器只能消费自己的内存队列,应用重启后队列也会消失
  3. 异步返回不是最终成功,不能直接向用户承诺数据库订单已经创建成功

消息队列#

初识#

image.webp
image.webp

消息队列:字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

流程

  • 队列的好处在于 解耦:快递员(生产者)把快递放到快递柜里边(Message Queue)去,我们(消费者)从快递柜里边去拿东西,这就是一个异步,
  • 如果耦合,那么这个快递员相当于直接把快递交给你
  • 秒杀中就变成了:我们下单之后,利用redis去进行校验下单条件,再通过队列把消息发送出去,然后再启动一个线程去消费这个消息,完成解耦,同时也加快我们的响应速度。

List#

  • Redis的list数据结构是一个双向链表,很容易模拟出队列效果。
  • 先进先出
  • 队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。 不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,
  • 不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。

image.webp
image.webp
优点

  • 利用Redis存储,不受限于JVM内存上限
  • 基于Redis的持久化机制,数据安全性有保证
  • 可以满足消息有序性

缺点

  • 无法避免消息丢失 只支持单消费者

PubSub#

  • PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。
  • 消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
  • SUBSCRIBE channel [channel] :订阅一个或多个频道 PUBLISH channel msg :向一个频道发送消息 PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道

image.webp
image.webp

基于PubSub的消息队列有哪些优缺点? 优点:

  • 采用发布订阅模型,支持多生产、多消费

缺点:

  • 不支持数据持久化
  • 无法避免消息丢失
  • 消息堆积有上限,超出时数据丢失

Stream#

private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有消息,继续下一次循环
continue;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
//处理异常消息
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
// 如果为null,说明没有异常消息,结束循环
break;
}
// 解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 3.创建订单
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());
} catch (Exception e) {
log.error("处理pendding订单异常", e);
try{
Thread.sleep(20);
}catch(Exception e){
e.printStackTrace();
}
}
}
}
}

image.webp
image.webp

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
优惠券秒杀
https://blog.f3f3.top/posts/redis/优惠券秒杀/
作者
Firefly
发布于
2026-08-16
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Firefly
Hello, I'm Firefly.
公告
此网站仅为个人学习笔记
分类
标签
最新动态
站点统计
文章
29
分类
11
标签
24
总字数
245,265
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.15.6
文章许可
CC BY-NC-SA 4.0