秒杀场景下的 Redis 分布式锁优化与业务设计
秒杀是高并发场景的典型代表,核心挑战在于库存扣减的准确性与订单生成的原子性。面对海量请求,如何保证数据一致性与系统可用性,是架构设计的重点。
全局唯一 ID 生成策略
商品秒杀本质仍是订单购买,需要一张订单表记录流水。直接使用数据库自增 ID 在高并发下存在明显缺陷:
- ID 规律暴露信息:用户可通过 ID 推算出下单时间间隔,甚至推测其他用户的下单情况,存在安全隐患。
- 单表性能瓶颈:高并发下订单量巨大,MySQL 单表难以承载,分库分表后自增 ID 容易重复。
因此,我们需要一个全局唯一且无规律的 ID 生成器。虽然 Redis 的 INCR 命令能保证递增性和高性能,但纯数字序列依然有规律可循。通常采用 Redis 计数器配合时间戳拼接的方式,或者使用 Snowflake 算法。
以 Redis 自增 ID 策略为例,每天重置 Key 方便统计,ID 结构包含时间戳与秒内计数器:
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. 生成序列号(每天一个 Key)
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue()
.increment("icr:" + keyPrefix + ":" + date);
// 3. 拼接并返回
return timestamp << COUNT_BITS | count;
}
}
超卖问题的解决
超卖是多线程并发修改库存导致的典型安全问题。常见的解决方案包括悲观锁和乐观锁。
乐观锁实现
乐观锁认为冲突概率低,更新时校验数据是否被修改。在秒杀场景中,主要有两种实现方式:
- 版本号机制:每次扣减库存更新版本号,操作前比对版本号。
- CAS 思想:直接利用库存数量作为标志位。
针对 CAS 方案,SQL 层面可以这样优化:
UPDATE seckill_voucher
SET stock = stock - 1
WHERE voucher_id = ? AND stock > 0;
这里的关键在于判断条件 stock > 0 而非 stock = old_stock。如果多个线程都读到库存不足,前者能确保只要还有库存就能继续扣减,避免'假死'导致无法买完的情况。
一人一单的需求设计
秒杀商品优惠力度大,平台常要求'一人一单'。实现逻辑通常是先查询该用户是否有订单,若无则扣减库存并创建订单。
// 查询订单
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)
.gt("stock", 0)
.update();
并发安全陷阱
上述两步操作并非原子性,若不加锁,同一用户的多个请求可能同时通过查询,导致重复下单。单机环境下可用 synchronized 锁住用户对象:
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 查询、扣减、创建订单逻辑
// ...
}
return Result.ok(orderId);
}
但在集群模式下,不同节点 JVM 内的锁监视器互不干扰,synchronized 无法解决跨节点的并发问题。此时必须引入分布式锁。
分布式锁的实现与优化
分布式锁需满足互斥、高可用及安全性。基于 Redis 的实现主要依赖 SETNX 命令。
基础加锁与释放
@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);
}
}
关键隐患处理
- 锁超时与误删:设置过期时间防止死锁,但需确保只删除自己的锁。代码中已加入
threadId比对。 - 原子性问题:判断标识与删除 Key 是两个独立操作,中间若发生阻塞可能导致误删他人锁。需使用 Lua 脚本保证这两步的原子性。
-- unlock.lua
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
Java 端调用 Lua 脚本执行解锁,确保逻辑不可分割。
总结
生产级分布式锁的设计要点如下:
- 获取锁:使用
SETNX并设置过期时间,保存线程唯一标识。 - 释放锁:校验标识一致性,并通过 Lua 脚本保证原子性。
- 特性保障:
SETNX提供互斥,EX防止死锁,Redis 集群保障高可用。
当然,当前方案仍有改进空间,例如支持可重入锁、增加重试机制、处理主从同步延迟等边界情况。在实际落地时,建议结合业务场景权衡取舍。

