一、优惠券秒杀
1. 全局唯一ID
为了增加ID的安全性,我们不直接使用Redis自增的数值,而是拼接一些其它信息:

ID的组成部分:符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
2. Redis实现全局唯一Id
(1) RedisIdWorker
java
@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;
}
}
说明:
① String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
- 按日期拆分 Redis 计数器的 Key,避免单个 Key 的计数器值无限增大(比如累计到几十亿,既占内存又不方便管理);
- 让每个日期的计数器从
1开始,减少 ID 的长度(如果全局一个计数器,数值会快速变大); - 方便按日期统计 / 清理数据(比如删除 30 天前的计数器 Key)。
举个例子:订单业务的 Key,2026-03-11 是icr:order:2026:03:11,2026-03-12 就变成icr:order:2026:03:12,每天的计数器独立。
② return timestamp << COUNT_BITS | count.longValue();
这是核心的位运算逻辑,目的是把「相对时间戳」和「每日计数器」拼接成一个完整的 ID。
timestamp << COUNT_BITS:把timestamp(相对时间戳)左移 32 位 ,放到 64 位long的高位区域;比如timestamp=100,左移 32 位后变成100 * 2^32(二进制就是 100 后面跟 32 个 0);count.longValue():把 Redis 返回的计数器值转为long(因为返回的是Long包装类);|(按位或):把高位的时间戳和低位的计数器拼接成一个数(因为两部分的二进制位不重叠,按位或等价于拼接)。
(2) 测试类
java
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
说明:
CountDownLatch 的工作原理:
- 你(主线程)要统计 300 个工人(子线程)完成 "每人生产 100 个零件(生成 100 个 ID)" 的总耗时;
- 你先给工人发了一个 "倒计时牌"(CountDownLatch (300)),初始数字是 300;
- 每个工人完成自己的 100 个零件后,就把倒计时牌数字减 1(
latch.countDown()); - 你站在旁边等(
latch.await()),直到倒计时牌数字变成 0(所有工人都完成),才开始计算从开工到收工的总时间; - 如果没有这个倒计时牌,你可能刚安排完工人就看表,此时工人还在干活,统计的时间毫无意义。
3. 实现秒杀下单
(1) 思路分析
-
秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
-
库存是否充足,不足则无法下单
① 当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件。
② 比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。

(2) 代码实现
VoucherOrderServiceImpl
java
@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("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
//6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
说明:
① Resource private ISeckillVoucherService seckillVoucherService;
核心解释 :这是 Spring 的依赖注入,目的是引入操作「秒杀优惠券表(SeckillVoucher)」的服务类。
ISeckillVoucherService:MyBatis-Plus 自动生成的服务接口,专门用于操作SeckillVoucher(秒杀优惠券)表,包含查询、修改、删除等方法;- 为什么需要它 :秒杀逻辑的第一步是查询优惠券的秒杀时间、库存等信息(这些存在
SeckillVoucher表),所以必须注入这个服务来操作该表。
② 扣减库存的 update 链式调用
java
seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
这是MyBatis-Plus 的链式更新语法,目的是扣减秒杀优惠券的库存,拆解每一步:
| 代码片段 | 作用 |
|---|---|
seckillVoucherService.update() |
初始化 MyBatis-Plus 的更新构造器(UpdateWrapper) |
.setSql("stock = stock - 1") |
设置要执行的 SQL 片段:将库存字段减 1(直接写 SQL 片段,而非 set ("stock", 具体值),避免多线程下覆盖) |
.eq("voucher_id", voucherId) |
添加更新条件:只更新voucher_id等于传入的优惠券 ID 的记录 |
.update() |
执行最终的更新操作,对应 SQL:UPDATE seckill_voucher SET stock = stock - 1 WHERE voucher_id = ? |
4. 库存超卖问题分析
(1) 存在的问题
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

(2) 解决方案(我们采用乐观锁)

① 乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功。这套机制的核心逻辑在于:如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对它进行过修改,它的操作就是安全的,如果不大1,则数据被修改过,乐观锁还有一些变种的处理方式比如cas
② 代码实现
java
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock",voucher.getStock())
.update();
说明:
.gt("stock", voucher.getStock()) ✅ 核心防超卖逻辑
gt是greater than的缩写,意为大于。- 这是乐观锁 / 库存校验的关键 :要求数据库中当前的库存值 ,必须大于 代码中拿到的预期库存 (
voucher.getStock())。 - 为什么能防超卖?多线程并发请求时,只有当数据库库存确实大于预期值时,才会执行扣减;如果某线程执行时库存已不足,该条件会直接失败,避免
stock变成负数。
5. 一人一单
(1) 版本1.0
java
@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 修饰整个方法 → 全局串行,性能崩溃
java
public synchronized Result createVoucherOrder(Long voucherId) {
❌ 所有用户的秒杀请求都要排队执行(比如 1000 个用户下单,只能一个接一个处理);
❌ 本来锁的目标是 "一人一单"(只锁单个用户),结果锁了所有用户,完全违背并发设计初衷。
(2) 版本2.0
java
@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);
}
}
说明:
锁在事务内 → 一人一单规则仍失效,锁的释放时机早于事务提交时机,导致 "一人一单" 规则被突破。
① @Transactional 注解的方法,其事务提交时机是「整个方法执行完毕、返回结果后」,而 synchronized 锁的释放时机是「代码块执行完毕」。
② 真实执行时序(以同一个用户的两个并发请求为例):
bash
线程A(用户1)执行步骤:
1. 获取用户1的锁 → 2. 查订单(count=0)→ 3. 扣库存 → 4. 创订单 → 5. 释放锁(synchronized代码块执行完)→ 6. Spring提交事务(订单真正入库)
线程B(用户1,线程A释放锁后立即执行):
1. 获取用户1的锁 → 2. 查订单(此时线程A的事务还没提交,数据库中无订单,count=0)→ 3. 重复下单 → 4. 释放锁 → 5. Spring提交事务
最终结果:用户 1 会创建两个订单,"一人一单" 的核心规则完全失效。
(3) 版本3.0
java
synchronized (userId.toString().intern()) {
// 1. 获取 Spring 的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 2. 通过代理对象调用
return proxy.createVoucherOrder(voucherId);
}
说明:
- 保证事务生效 :通过
proxy调用,Spring 代理对象会拦截方法,开启事务、提交 / 回滚,数据一致性得到保证。 - 保证一人一单 :锁还在外层,释放锁前事务一定提交,并发安全性得到保证。
6. 集群环境下的并发问题
同一个用户(比如用户 100),同时发送请求到 服务器 A 和 服务器 B。
- 服务器 A 拿到锁,正在查库存 → 库存充足 → 准备扣减。
- 服务器 B 拿到锁,正在查库存 → 库存充足 → 准备扣减。
- 原因:服务器 A 的锁只认识服务器 A 里的线程,它根本不知道服务器 B 里还有个线程在抢同一个资源。
- 结果 :两个服务器同时执行了扣减逻辑 ,导致了 超卖 ,且同一个用户买了 两份订单(一人多单)。
二、分布式锁
1. Redis 实现分布式锁版统一
(1) SimpleRedisLock
java
private static final String KEY_PREFIX="lock:"
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
long threadId = Thread.currentThread().getId()
// 获取锁
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
说明:
① .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
1). setIfAbsent 对应 Redis 的 SETNX 命令,核心逻辑是:如果 key 不存在,则设置值并返回 true;如果 key 已存在,则直接返回 false------ 这是实现分布式锁 "互斥性" 的核心(保证同一时间只有一个线程能拿到锁)。
2). threadId + "" 的小细节,Thread.currentThread().getId() 返回的是 long 原始类型 ,而 Redis 操作的 value 必须是 String 类型。拼接空字符串是最简洁的语法糖,能将 long 强制转为 String,满足 setIfAbsent 的参数类型要求。等价效果:String.valueOf(threadId),但写法更简洁。
② 为什么自动拆箱有风险?Boolean.TRUE.equals(success) 好在哪?
1). 代码中 Boolean success = ... 定义的是包装类 Boolean 对象 ,如果直接写 return success;,会触发 自动拆箱 (将 Boolean 转为 boolean 原始类型)。
- 当 Redis 操作异常 / 返回
null时,success会是null; - 此时拆箱过程会尝试把
null转为boolean,直接抛出NullPointerException,导致程序崩溃。
2). Boolean.TRUE.equals(success) 的核心优势
Boolean.TRUE是 Boolean 类的单例常量,调用其equals()方法时,内部自带 null 校验逻辑;- 无论
success是null、Boolean.FALSE还是Boolean.TRUE,都能安全返回结果:
success 取值 |
return success;(直接拆箱) |
return Boolean.TRUE.equals(success); |
|---|---|---|
null |
抛 NullPointerException |
返回 false |
Boolean.FALSE |
抛 NullPointerException |
返回 false |
Boolean.TRUE |
正常返回 true |
返回 true |
(2) 修改业务代码
java
@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 {
//获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
//释放锁
lock.unlock();
}
}
2. Redis分布式锁误删情况说明
(1) 存在的问题
- 线程 1成功获取锁,开始执行业务
- 线程 1 遇到阻塞 (比如 JVM GC 垃圾回收、CPU 繁忙),导致业务执行超时 → Redis 锁自动过期释放。
- 线程 2 刚好来抢锁,成功获取锁,开始执行自己的业务。
- 线程 1恢复执行,业务做完了,它不知道锁已经过期被线程 2 拿走了,直接执行「删除锁」逻辑 → 误删线程 2 的锁。

(2) 解决方案
① 核心逻辑:在存入锁时,放入自己线程的标识。在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
② 代码实现
加锁
java
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@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);
}
释放锁
java
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);
}
}
3. 分布式锁的原子性问题
我们结合线程 1、线程 2 的动作,一步步拆解:
| 时间节点 | 线程 1 动作 | 线程 2 动作 | Redis 锁状态 | 关键变化 |
|---|---|---|---|---|
| 1 | 持有锁(value = 线程 1ID),执行业务 | 阻塞等待锁 | 存在(属于线程 1) | 线程 1 正常拿锁 |
| 2 | 执行到解锁步骤,先get锁,发现 value 是自己的 ID,进入if判断 |
等待 | 存在(属于线程 1) | 线程 1 完成 "验证",还没执行 delete |
| 3 | 线程 1 突然卡顿 / GC(CPU 切换) | 无 | 锁过期自动释放 | 致命点!锁因为超时被 Redis 回收,线程 1 还没删 |
| 4 | 卡顿中 | 尝试抢锁,成功获取锁(value = 线程 2ID),开始执行业务 | 存在(属于线程 2) | 线程 2 拿到了锁,业务正在跑 |
| 5 | 线程 1 恢复执行 | 执行业务中 | 存在(属于线程 2) | 线程 1 从if里继续往下走,直接执行delete |
| 6 | 完成删除 | 业务中 | 被删空 | 线程 1 把线程 2 的锁删了!线程 2 的业务还没做完,锁没了 |
核心后果 :线程 1 明明做了 "验证归属" 的判断,却依然误删了线程 2 的锁 ------ 因为验证和删除中间被打断了。
4. 利用Java代码调用Lua脚本改造分布式锁
java
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());
}
经过以上代码改造后,我们就能够实现 拿锁比锁删锁的原子性动作了~
说明:
① 静态代码块:加载 Lua 脚本
static final:脚本只需要加载一次,类加载时初始化,高效复用DefaultRedisScript:Spring Data Redis 提供的封装类,用来和 Redis 模板配合执行 Lua 脚本- 脚本位置:
unlock.lua放在项目resources文件夹下,里面就是 "判断 + 删除" 的逻辑
② unlock () 方法:执行 Lua 脚本释放锁
- 参数 1:
UNLOCK_SCRIPT→ 刚才加载好的脚本对象 - 参数 2:
Collections.singletonList(KEY_PREFIX + name)→ 脚本里的KEYS[1],就是锁的 key(比如lock:order) - 参数 3:
ID_PREFIX + Thread.currentThread().getId()→ 脚本里的ARGV[1],就是当前线程的唯一标识(用来和锁里存的 value 对比)