🐢

优惠券秒杀

14644 字
73 分钟
优惠券秒杀

初识#

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

数据表

  • tb_voucher:优惠券的基本信息,优惠金额、使用规则等。

  • tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息。

  • 因此可以看出两张表中的数据是一对一关系,普通优惠卷用户可以随意抢购,没有条件限制。

  • 特价优惠卷不仅有抢购的开始结束时间,还有库存,一旦不在限时抢购时间范围内亦或是库存为0,抢购行为均会失败。

  • 由于优惠力度大,会引来用户疯抢,秒杀场景出现(库存立即被一扫而光),高并发问题也就应运而生,像超卖、一人一单、多个命令的原子性如何保证、分布式缓存、秒杀性能保证等等。

全局唯一Id#

Redis#

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

  • 采用数据库自增ID就存在ID规律性明显和受单表数据量限制。

  • 如果我们的id具有太明显的规则,用户猜测信息,比如商城在一天时间内,卖出了多少单

  • 商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,

  • 拆分表了之后,从逻辑上讲他们是同一张表,所以他们的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命令,这是绝对原子性的操作,即便在高并发下也能保证每次拿到的数字不一样。

key的设计,把订单当天日期拼接为key的一部分,首先如果不拼接订单当天的日期,相当于所有订单的序列号都储存在一个key中,由于分配给序列号的空间只有32位,当总订单量超过42.9亿时就会溢出报错,更别说因此形成的BigKey还会对服务的性能造成巨大影响。为了避免空间溢出和BigKey,我们按天date拼接Key,意味着每天的序列号都会从0重新开始计算,此外通过Redis还可以直接查看每天的数据量,方便统计。

位运算拼接并返回,我们最开始计算出来的31位的时间戳是在中间的,那通过位运算符<< 32,即将时间戳向左移动32位,空出低位的32个0。在此运算结果的基础上与序列号count做按位或运算 | count,相当于把count直接塞入32个低位里,最终拼成一个完整的64位Long型数据。

小结:这种方案生成的 ID 趋势递增(有利于数据库索引),且长度为 64 位整型,对数据库非常友好。

缺点是强依赖 Redis 服务,每次生成 ID 都要有一次网络开销。RedisIdWorker 本质上就是“借助 Redis 维护序列号的雪花算法变种”。它牺牲了一点点网络性能,换来了无需配置机器 ID (WorkerId) 和不用担心时钟回拨的便利性,非常适合中小体量的微服务架构!

雪花算法#

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

结构:同样是一个 64 位的 long 型数字。

1 bit 符号位。

41 bits 时间戳(毫秒级):可以使用 69 年。

10 bits 机器 ID(WorkerId):前 5 位是数据中心 ID,后 5 位是机器 ID。支持部署 1024 台服务器。

12 bits 序列号:单台机器每毫秒可以生成 212=4096212=4096 个 ID。

优点:

  • 完全本地生成,不依赖外部中间件(如 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);
}
}

秒杀下单#

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)
    底层实现synchronizedReentrantLockFOR UPDATECAS 指令、版本号机制(Version)
    CPU 开销线程阻塞/唤醒涉及内核态切换,开销大不阻塞线程,但自旋重试会消耗 CPU
    适用场景写多读少,并发冲突概率极高的场景读多写少,并发冲突概率低的场景
    在秒杀中的应用“一人一单”防重时,用分布式锁拦截,本质偏悲观扣减库存防超卖时,用 WHERE stock > 0 拦截,属于乐观锁思想
  • 使用悲观锁会发现虽然解决了超卖问题,性能较差,吞吐量暴跌,

  • 乐观锁,严格意义上它不是锁而是一种锁的思想。

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

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

  • 乐观锁还有一些变种的处理方式比如cas。

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

int var 5; // 定义一个变量,用于存放当前从内存中读到的值
do {
// 1. 获取当前主内存中的最新值
// getIntVolatile 保证了可见性,每次都从主内存直接读取,而不是从线程的工作内存读
var 5 = this.getIntVolatile(var1, var2);
// 2. compareAndSwapInt (CAS操作)
// 尝试将内存中的值修改为 var5 + var4。
// 核心逻辑:如果当前内存地址(var1+var2)里的值 等于 期望值(var5),说明期间没有其他线程修改过,那就把它更新为 var5 + var4,返回 true,结束循环。
// 如果不等于,说明期间有其他线程改了数据,返回 false,继续下一轮 do 循环(这就叫自旋)。
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5; // 返回修改前的值

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

compareAndSwapInt 作为一个方法,它自己如何保证原子性?如果判断相等和修改值这两步之间被其他操作介入了怎么办?

回答:CAS 的原子性是由 CPU 硬件指令保证的。

JNI 调用:Java 的 compareAndSwapInt 是一个 native 方法,它会调用底层 C++ 代码。

操作系统与 CPU 层面:在 x86 架构的 CPU 中,底层对应的是一条叫 cmpxchg 的汇编指令。

硬件锁(Lock 前缀):在多核 CPU 下,为了保证 cmpxchg 指令的绝对安全,汇编代码会在前面加一个 lock 前缀(即 lock cmpxchg)。

lock 前缀的作用是:在执行这条指令时,会锁住总线(或使用缓存行锁定 MESI 协议),确保同一时刻只有一个 CPU 能访问该内存地址。

结论:CAS 并非真正的“无锁”,它只是在软件层面无锁,在硬件层面依然依赖了极其底层的轻量级锁机制来保证原子性。

比较 + 修改”是 CPU 层面的一条原子操作

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=旧版本号。

image.webp
image.webp

image.webp
image.webp

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

boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherId)
.eq("stock",voucher.getStock()).update();
//where id = ? and stock = ?
  • 只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,
  • 通过测试发现会有很多失败的情况,因为乐观锁实现里面没有实现自旋,
  • 在库存为某个值时,多个线程同时查询库存(同一个值),但只有一个线程能够成功执行update语句,其他均失败且不会重试

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()

自旋#

CAS 失败之后,一般不会立刻阻塞线程,而是重新读取最新值,再尝试 CAS。

线程不挂起、不阻塞,而是在 CPU 上不断重试。

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

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

CAS优化#

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() 非强一致快照
适合场景并发不高、需要精确原子值高并发统计计数

一人一单#

基础#

image.webp
image.webp

一个用户只能下一个单,而不是让一个用户下多个单,即实现一人一单

@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 的第二次请求”同时到达时,才会发生阻塞,完美实现了“防重/一人一单”的需求,同时极大保全了系统性能 事务注解

事务注解#

方法上的@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 没机会在外面加开启事务,提交事务,回滚事务

  • 事务生效是因为Spring对当前类实现了动态代理,是拿到了它的动态代理对象进行的事务管理,而现在的this调用是非代理对象不拥有事务功能(Spring事务失效的可能性之一),因此事务管理将会失效

@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 内存占用过大甚至 OOM。

集群失效(最致命):现在的系统都是多节点集群部署。Nginx 把用户的第一次请求打到了服务器 A,第二次请求打到了服务器 B。synchronized 只能锁住当前的 JVM。跨服务器的并发,JVM 锁彻底失效

分布式锁#

初识#

满足分布式系统或集群模式下多进程可见并且互斥的锁

分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么就能锁住线程,不让线程进行,让程序串行执行

基于 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,获取锁失败。体现在一人一单业务上就是多个线程都同时尝试在某一个用户账号上抢购,

  • 每个线程抢购前都需要执行setnx命令,key需要代表用户唯一标识,也就是说只有一个线程能够执行成功setnx命令,其他都失败,这也就是Redis分布式锁的思想。

//分布式锁
public class SimpleRedisLock implements ILock {
//锁的名称
private String name;
//引入Redis
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
//Redis中的key设计
private static final String KEY_PREFIX ="lock:";
//锁的名称不能写死
//任何业务需要有不同的锁
@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);
}

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

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

自动拆箱

  • 它是 Java中的一种类型转换机制,指将包装类对象(如 Integer、Boolean 等)自动转换为对应的基本 数据类型(如 int、boolean 等)的过程。与之相对的,将基本数据类型转换为包装类对象的过程称为自动装箱。

  • 自动拆箱时,如果包装类对象的值为 null,则会引发空指针异常Boolean.TRUE.equals(isLock)

  • 获取锁:

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

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

//释放锁
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
@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();
//获取锁对象
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表示) 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

如果一致则释放锁

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脚本#

  • 线程1要执行释放锁的时候突然阻塞了,线程1获取的锁超时释放,

  • 线程2拿到了这把锁正在执行业务,结果线程1又好了,开始释放锁(先前已经判断完这把锁属于自己,但其实不是,这是线程2的锁,两把锁的名字key又是一样的)。

  • 线程2直接裸奔,线程3自然就可以获取锁进来一起执行业务,那依旧会发生并发问题。

一人多单

  • 不论怎么样线程1肯定会安全执行完业务完成了一人一单,但是如果业务需求是一人两单、一人三单呢,上述代码恐怕就难以胜任了。
  • 根源出在获取锁标识并判断是否一致动作和释放锁的动作不具备原子性,让其他线程有了可乘之机。为了解决这一问题,我们引入Lua脚本,解决多条命令原子性问题。

image.webp
image.webp

image.webp
image.webp

用Lua脚本代替Java代码释放锁的那段逻辑,这里介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性,这样就可以实现拿锁比锁删锁是一个原子性动作了。

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}

Redission#

初识#

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

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

  • 重入问题:获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。

  • **不可重试:**目前的分布式只能尝试一次,合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。

  • **超时释放:**加锁时增加了过期时间,这样的我们可以防止死锁,如果卡顿的时间超长,虽然采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患

  • 主从一致性Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题

image.webp
image.webp

image.webp
image.webp

导入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);
}
}
Long userId = UserHolder.getUser().getId();
//创建锁对象 这个代码不用了,因为我们现在要使用分布式锁
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("lock:order:" + userId);
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock();
//加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}

可重入锁原理#

image.webp
image.webp

死锁

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

一种锁可以辨认出当前的持锁者便是可重入锁,恰好Redission实现了这样的功能,

ReentrantLock可重入锁的实现方式

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

核心原理

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

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

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

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

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

Redission是基于Redis实现的,在实现可重入性这块也借鉴了ReentrantLock的方式。

为了记录“是谁持有了锁”以及“重入了几次”,Redis 原本的 String 结构(只能存一个值)不够用了,必须升级为 Hash 数据结构。

Hash 结构拆解为三层含义:

  • KEY(大 Key):锁的名字,比如叫 lock锁是否存在
  • FIELD(小 Key):持有这把锁的线程标识这把锁被哪个线程持有

不能仅仅是 Java 中的 threadId,因为分布式环境下,不同服务器上的 JVM 可能会生成相同的线程 ID。所以 Redisson 使用的是 UUID + ”:” + threadId,确保全网唯一。

  • 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); //写数据 ,写成一个hash结构
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],彻底释放分布式锁,并通过发布订阅机制通知其他正在等锁的线程。

锁重试与WatchDog机制#

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

  • 不设置过期时间? 如果服务宕机,锁永远无法释放,导致死锁。
  • 设置固定过期时间(比如 10 秒)? 假如遇到网络抖动或 Full GC,业务代码执行了 15 秒。结果第 10 秒时锁自动释放了,其他线程趁虚而入,导致并发安全防线全面崩溃!

怎么破局?

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

2.实现解析

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

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) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
  • 当 10 秒倒计时结束,执行 renewExpiration() 内部的代码:

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

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

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

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

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

  • 不会,看门狗的 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中,比如是否库存足够,比如是否一人一单,
  • 只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,直接给用户返回成功,
  • 在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?

问题

  • 我们怎么在redis中去快速校验一人一单,还有库存判断
  • 由于校验和tomct下单是两个线程,如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了。

image.webp
image.webp

  • Lua脚本的具体逻辑,涉及到判断库存、判断重复下单、扣减库存、保存用户、发送消息到队列

  • 判断库存是否充足只需要导redis中去根据key找对应的value是否大于0即可,如果不充足,则直接结束,

  • 如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,

  • 如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua来操作

  • 判断当前redis中返回的结果是否是0 ,如果是0,则表示可以下单,则将之前说的信息存入到到queue中去

  • 来个线程异步的下单,前端可以通过返回的订单id来判断是否下单成功。

秒杀资格判断#

  • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
@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脚本

-- 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
@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);
}

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

//异步处理线程池
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();
}
}
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);
}
@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完成库存余量、一人一单判断,完成抢单业务
  • 再将下单业务放入阻塞队列,利用独立线程异步下单
  • 基于阻塞队列的异步秒杀存在哪些问题?
    • 内存限制问题
    • 数据安全问题

消息队列#

初识#

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格式匹配的所有频道

支持与分享

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

打赏
优惠券秒杀
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.
公告
此网站仅为个人学习笔记
分类
标签
最新动态
站点统计
文章
26
分类
10
标签
21
总字数
159,373
运行时长
0
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.15.6
文章许可
CC BY-NC-SA 4.0