「黑马点评」三、优惠券秒杀
基于 Redis 的全局唯一 ID 生成器
分布式系统下用来生成唯一性 ID 的工具:唯一性、高可用、高性能、递增性、安全性

image.png|500
示例
@Component
public class RedisIdWorker {
private static final long BEGIN_TIMESTAMP = 1735689600L;
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. 生成序列号
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
return timestamp << COUNT_BITS | count;
}
}
测试生成速度,利用线程池,提交任务,用 CountDownCountDownLatch 计数线程,以 await 到真正结束时间

image.png|500
实现优惠券秒杀下单
业务背景
平价券:随意抢,不限制 特价券:优惠大,有限制

image.png|500
秒杀券下单流程

image.png|500
@Override
public Result<Long> seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.error("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.error("秒杀已结束");
}
// 4. 判断库存是否充足
if (voucher.getStock() <= 0) {
return Result.error("库存不足");
}
// 5. 扣减库存
boolean isSuccess = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
if (!isSuccess) {
return Result.error("库存不足");
}
// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisIdWorker.nextId("order"));
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
voucherOrder.setPayType(1); // 设置支付方式,1:余额支付
voucherOrder.setStatus(1); // 设置订单状态,1:未支付
voucherOrderMapper.insert(voucherOrder);
return Result.success(voucherOrder.getId());
}
超卖分析
问题:同时查到了符合数量的库存数并同时扣减库存,出现负库存,实际上就是线程安全问题

image.png|500
加锁解决
悲观锁:线程安全问题必定发生,只要操作数据就加锁 乐观锁:线程安全问题不一定发生,只在操作数据时判断数据有没有被其他线程修改

image.png|500
乐观锁如何判断数据有没有被操作过
版本号方案:数据只要被修改,就修改版本号,确保修改时的版本号与查询时的版本号一致

image.png|500
CAS 方案:简化版本号,在本案例中用 stock 本身有没有变化替代版本号

image.png|500
示例:CAS 方案
// 5. 扣减库存
boolean isSuccess = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).eq("stock", voucher.getStock())
.update();
if (!isSuccess) {
return Result.error("库存不足");
}
问题:成功率太低,不应每次都判断 stock 与查询得到的一致
改进:只需要修改时的 stock 大于 0 即可
// 5. 扣减库存
boolean isSuccess = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 使用大于0的条件替代等于当前库存的条件
.update();
if (!isSuccess) {
return Result.error("库存不足");
}
一人一单
背景:同一个用户一种优惠券只能下一单

image.png|500
示例
// 一人一单
Long userId = UserHolder.getUser().getId();
long count = voucherOrderMapper.selectCount(userId, voucherId);
if (count > 0) {
return Result.error("您已经购买过该优惠券");
}
结果:出现了线程安全问题,同一个用户的大量请求同时经过了一人一单的检验
解决:要加锁,但是没有数据,所以需要加悲观锁,从一人一单的检验开始到创建订单加上悲观锁
将一人一单、扣减库存、创建订单提取成一个逻辑函数,并为方法加上 synchronized,不妥,因为一人一单是针对同一个用户的,并非所有用用户,如果给方法上锁,这就直接退化为串行
所以应该为用户 id 上锁,userId.toString().intern(),如果只是 toString 那么返回的都是新创建出的 String 对象,所以使用 .intern 返回字符串对象池中的对象
那为什么锁不加在抽离出的 createVoucherOrder 方法内呢,因为这个方法带了 @Transactional,如果锁加在方法内部,就是先释放锁后提交事务,锁的范围太小还是会出现线程安全问题
所以把锁改到了调用 createVoucherOrder 的地方,先提交事务再释放用户锁
那又为什么调用的是从 AopContext 获取到的 proxy 的 createVoucherOrder 方法呢,是因为 @Transactional 是由 Spring 管理的,不通过代理对象的话,事务管理会失效,所以要从 AopContext 中获取到代理对象,才能让事务生效
同时要代理对象暴露出来,需要再 Application 中添加注解 @EnableAspectJAutoProxy(exposeProxy = true),并添加依赖 aspectjweaver
@Override
public Result<Long> seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.error("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.error("秒杀已结束");
}
// 4. 判断库存是否充足
if (voucher.getStock() <= 0) {
return Result.error("库存不足");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result<Long> createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long count = voucherOrderMapper.selectCount(userId, voucherId);
if (count > 0) {
return Result.error("您已经购买过该优惠券");
}
// 扣减库存
boolean isSuccess = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 使用大于0的条件替代等于当前库存的条件
.update();
if (!isSuccess) {
return Result.error("库存不足");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(redisIdWorker.nextId("order"));
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
voucherOrder.setPayType(1); // 设置支付方式,1:余额支付
voucherOrder.setStatus(1); // 设置订单状态,1:未支付
voucherOrderMapper.insert(voucherOrder);
return Result.success(voucherOrder.getId());
}
集群下的一人一单
前提:先在 services 中新建一个应用

image.png|500
重新运行,并修改 nginx 配置

image.png|500
但是还是有问题,集群中的每个 JVM 只维护他内部的锁,原先的写法无法管理集群情况下的锁

image.png|500