黑马复盘 -- 优惠券秒杀
全局ID生成器
在分布式系统下用来生成全局唯一ID的工具:
唯一性,高可用,递增性,安全性,高性能
MySQL自增 ID 缺陷
1,ID 可被预测
2,单库自增 ID 有性能上限,高并发场景扛不住
3,分库分表自增 ID 会重复
全局 ID
符号位(1 bit),时间戳(31 bit),序列号(32 bit)
@ComponentpublicclassRedisIdWorker{//开始时间戳privatestaticfinallongBEGIN_TIMESTAMP=1640995200L;//序列号位数privatestaticfinalintCOUNT_BITS=32;privateStringRedisTemplatestringRedisTemplate;publicRedisIdWorker(StringRedisTemplatestringRedisTemplate){this.stringRedisTemplate=stringRedisTemplate;}publiclongnextId(StringkeyPrefix){//这个参数是业务前缀// 1.生成时间戳LocalDateTimenow=LocalDateTime.now();longnowSecond=now.toEpochSecond(ZoneOffset.UTC);longtimestamp=nowSecond-BEGIN_TIMESTAMP;// 2.生成序列号// 2.1.获取当前日期,精确到天Stringdate=now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));// 2.2.自增长longcount=stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix+":"+date);// 3.拼接并返回returntimestamp<<COUNT_BITS|count;}}1,每个业务用自己的自增序列
2,INCR icr:order:2026:06:01如果这个key不存在,创建它,值设置为1;每天自增都会清0,防止redis内存过大
UUID
雪花算法
优惠券下单
表现层
@PostMapping("seckill")publicResultaddSeckillVoucher(@RequestBodyVouchervoucher){voucherService.addSeckillVoucher(voucher);returnResult.ok(voucher.getId());}- 其中 voucher 是这个优惠券实体类:
优惠券分为普通优惠券和秒杀优惠券;
@TableField(exist=false)privateIntegerstock;这个字段在 Java 类里有,但 tb_voucher 表里没有对应的列。
- SeckillVoucher
@TableId(value="voucher_id",type=IdType.INPUT)privateLongvoucherId;把 SeckillVoucher 的字段放进 Voucher里,是为了省事——前端展示优惠券时基本都要同时显示库存和时间,每次手动合并太麻烦,干脆在 Voucher 类里用 @TableField(exist = false) 带上这些字段。
新增优惠券
@Override@TransactionalpublicvoidaddSeckillVoucher(Vouchervoucher){// 保存优惠券save(voucher);// 保存秒杀信息SeckillVoucherseckillVoucher=newSeckillVoucher();seckillVoucher.setVoucherId(voucher.getId());seckillVoucher.setStock(voucher.getStock());seckillVoucher.setBeginTime(voucher.getBeginTime());seckillVoucher.setEndTime(voucher.getEndTime());seckillVoucherService.save(seckillVoucher);// 保存秒杀库存到Redis中stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());}}保存秒杀券的库存到缓存;
实现下单功能
1,秒杀时间是否开始或者结束
2,库存是否充足
@RestController@RequestMapping("/voucher-order")publicclassVoucherOrderController{@ResourceprivateIVoucherOrderServicevoucherOrderService;@PostMapping("seckill/{id}")publicResultseckillVoucher(@PathVariable("id")LongvoucherId){returnvoucherOrderService.seckillVoucher(voucherId);}}略过部分简单的代码。
// 5. 扣减库存booleansuccess=seckillVoucherService.update().setSql("stock = stock - 1")// SET stock = stock - 1.eq("voucher_id",voucherId)// WHERE voucher_id = ?.update();// 执行更新- 这是 MyBatis-Plus 提供的链式查询语法。 MyBatis-Plus 的 ServiceImpl 里继承来的 update() 方法,它返回一个UpdateWrapper 对象,让你可以链式拼接 SQL 条件。
- 在 MySQL 里完成加减,而不是在 Java里算好再传进去,能避免并发问题
超卖问题
悲观锁串行执行
/** * 悲观锁方式:秒杀扣减库存 * 必须加 @Transactional !!!(锁和事务绑定) */@TransactionalpublicResultseckillByPessimisticLock(LongvoucherId){LonguserId=UserHolder.getUser().getId();// 1.【核心】查询优惠券库存 + 加悲观锁 (FOR UPDATE)// 关键SQL:SELECT * FROM tb_seckill_voucher WHERE voucher_id = ? FOR UPDATESeckillVouchervoucher=seckillVoucherService.lambdaQuery().eq(SeckillVoucher::getVoucherId,voucherId).last("FOR UPDATE")// 这行就是加悲观锁!.one();// 2. 判断库存if(voucher.getStock()<=0){returnResult.fail("库存不足");}// 3. 扣减库存(因为加了锁,只有一个线程能执行到这)booleansuccess=seckillVoucherService.lambdaUpdate().set(SeckillVoucher::getStock,voucher.getStock()-1).eq(SeckillVoucher::getVoucherId,voucherId).update();if(!success){returnResult.fail("扣减失败");}// 4. 创建订单...returnResult.ok();}- @Transactional 到底是什么?为什么必须加?
- 它的作用:Spring 声明式事务
保证方法内所有数据库操作要么全部成功,要么全部失败
比如:扣减库存成功、创建订单失败 → 事务回滚,库存恢复 - 悲观锁必须加它的生死原因
FOR UPDATE 加的锁,和事务绑定!
事务开启 → 加锁
事务执行完毕(提交 / 回滚) → 自动释放锁
如果不加 @Transactional,Spring 不会开启事务,查询完数据锁立刻释放,等于没加锁!
@Transactional = 给悲观锁提供生命周期容器
没有它,悲观锁瞬间失效。
- MySQL InnoDB 引擎的行锁分为两种:
共享锁 (S 锁):读锁,多个线程可以同时加 S 锁,互不阻塞
排他锁 (X 锁):写锁,一个线程加了 X 锁,其他线程不能加任何锁,必须阻塞等待
所有写操作(INSERT/UPDATE/DELETE)都会自动加 X 锁,这是数据库的基本规则,没有例外。
利用MySQL的条件式乐观锁
乐观锁,判断之前查询的数据是否有被修改;
1,版本号
2,CAS
3,条件式乐观锁
booleansuccess=seckillVoucherService.update().setSql("stock = stock - 1")// 原子扣减.eq("voucher_id",voucherId).gt("stock",0)// ←这就是乐观锁!.update();MySQL 执行 UPDATE 时,会用行锁锁住这一行,两个 UPDATE 不会同时执行,会排队;
在高并发的场景下,条件式的乐观锁比版本号性能更好,能让更多线程执行;
一人一单
同一个优惠券,一个用户只能下一单
通过悲观锁synchronized+事务来实现
@TransactionalpublicResultcreateVoucherOrder(LongvoucherId){// 5.一人一单LonguserId=UserHolder.getUser().getId();synchronized(userId.toString().intern()){// 5.1.查询订单intcount=query().eq("user_id",userId).eq("voucher_id",voucherId).count();// 5.2.判断是否存在if(count>0){// 用户已经购买过了returnResult.fail("用户已经购买过一次!");}// 6.扣减库存booleansuccess=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){// 扣减失败returnResult.fail("库存不足!");}// 7.创建订单VoucherOrdervoucherOrder=newVoucherOrder();// 7.1.订单idlongorderId=redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 7.2.用户idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回订单idreturnResult.ok(orderId);}}- synchronized (userId.toString().intern()) {
锁住同一个用户的对象头,防止一人多单。
这里有个spring代理对象没搞懂
并发安全问题
- 这个锁可以解决单机情况下的问题,但是如果是集群模式下就失效了
- 两台服务器各有一个 JVM,各有各的常量池。服务器 A 的 "1001"和服务器 B 的 “1001” 是两个不同的对象头,synchronized管不到对方。
