黑马点评03:优惠劵秒杀

全局唯一ID

秒杀业务中,数据库自增 ID 不够用:

  • *多台服务器同时处理请求 → 每台数据库自增 ID 可能冲突*
  • *数据量极大(千万、亿级)→ 自增 ID 容易暴露业务量*
  • *分库分表后,单表自增 ID 无法保证全局不重复*

基于 Redis 的全局唯一 ID 生成策略:
采用 时间戳差值(高位) + 自增序列(低位) 的方式,通过 Redis 的 INCR 命令每天重置序列号,保证 ID 在分布式环境下唯一且趋势递增。相比数据库自增,性能更高、支持分库分表;相比雪花算法,实现更简单、无时钟回拨风险(依赖 Redis 时间)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 配置常量
private static final long START_TIMESTAMP = 1672531200L; // 2025-01-01
private static final int COUNT_BITS = 32; // 序列号占用32位

public long nextId(String keyPrefix) {
// 1. 获取当前时间戳差值(秒级)
long now = System.currentTimeMillis() / 1000;
long timestamp = now - START_TIMESTAMP; // 比如 = 123456789


// 2. 获取序列号(Redis自增)
String key = "icr:" + keyPrefix + ":" + LocalDate.now();
Long sequence = redisTemplate.opsForValue().increment(key); // 比如 = 5

// 3. 位运算合并
// timestamp 左移 32 位,空出低32位给 sequence
long id = (timestamp << COUNT_BITS) | sequence;

return id;

}

乐观锁解决库存超卖

img

1
2
3
4
5
6
sql

update seckill_voucher
set stock = stock - 1
where voucher_id = ?
and stock > 0 -- 这是乐观锁的关键!

一人一单

执行流程

1
2
3
4
5
6
7
8
请求进来
-> Redis Lua 判断库存和一人一单
-> Redis 预扣库存
-> Kafka 异步发送订单消息
-> 立即返回订单ID
-> Java 发送 Kafka 订单消息
-> 数据库事务保存订单并扣减库存
-> 唯一索引和 stock > 0 做最终兜底

image-20260927151114814

为了能在内存中极速完成校验,Redis 中专门设计了两个 key:

  • 库存 Key (String 类型):seckill:stock:{voucherId},存储当前优惠券的剩余库存量。在秒杀开始前,系统会提前将 MySQL 中的库存量“预热”(加载)到 Redis 中。
  • 记录购买者的 Key (Set 类型):seckill:order:{voucherId},这是一个哈希集合。里面专门存放所有成功抢到该优惠券的 userId。

*用 Lua 脚本,是因为它既能保证*“检查库存 + 扣库存 + 记录用户”*这三步的原子性,又能做条件判断(库存不足就返回失败),而且性能极高,不会让线程阻塞等待。Redis 事务做不了条件判断,分布式锁性能差且会阻塞。Lua 脚本是秒杀场景下唯一正确的选择。*

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 1. 接收 Java 客户端传入的参数
local voucherId = KEYS[1] -- 秒杀券 ID
local userId = ARGV[1] -- 用户 ID

-- 2. 拼接底层的缓存 Key
local stockKey = 'seckill:stock:' .. voucherId -- 记录库存的 String Key
local orderKey = 'seckill:order:' .. voucherId -- 记录购买者的 Set Key

-- 3. 校验逻辑一:判断库存
-- 利用 redis.call 发起 get 命令,转为数字后判断
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1 -- 返回错误码 1:库存不足
end

-- 4. 校验逻辑二:判断一人一单
-- 利用 sismember 检查当前 userId 是否已经在这个券的 Set 集合里
if (redis.call('sismember', orderKey, userId) == 1) then
return 2 -- 返回错误码 2:该用户已经买过
end

-- 5. 资格锁定(写入操作)
redis.call('incrby', stockKey, -1) -- 扣减 1 个库存
redis.call('sadd', orderKey, userId) -- 将当前用户 ID 塞入购买者集合

return 0 -- 返回 0:校验通过,抢购成功
private static final DefaultRedisScript<Long> SECKILL_SCRIPT ;
//静态代码块进行初始化
static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}
 
/**
 * 下单秒杀优惠卷
 * @param voucherId 优惠券id
 * @return
 */
public Result seckillVoucher(Long voucherId){
    //获取用户id
    Long userId = UserHolder.getUser().getId();
    //1.执行lua脚本
  Long result=stringRedisTemplate.execute(
            SECKILL_SCRIPT
            , Collections.emptyList()
    , voucherId.toString(),userId.toString()         );
 
    //判断结果是否为0
    int r=result.intValue();
 
    //不为0,则代表没有购买资格
    if (r!=0){
        return Result.fail(r==1?"库存不足":"不能重复下单");
    }

kafka

代码里生产者在这里:

1
2
3
4
5
kafkaTemplate.send(
VOUCHER_ORDER_TOPIC,
voucherId.toString(),
JSONUtil.toJsonStr(voucherOrder)
);

也就是把 VoucherOrder 转成 JSON,发送到 Kafka 的 voucher.orders topic。这里的 key 是 voucherId,value 是订单 JSON。

消费者在这里:

1
2
3
4
5
@KafkaListener(topics = VOUCHER_ORDER_TOPIC, groupId = "voucher-order-group")
public void handleVoucherOrder(String message) {
VoucherOrder voucherOrder = JSONUtil.toBean(message, VoucherOrder.class);
createVoucherOrder(voucherOrder);
}

它会监听 voucher.orders 这个 topic,一旦有订单消息,就反序列化成 VoucherOrder,然后调用 createVoucherOrder() 创建订单。