优惠券秒杀
自增id存在问题:
使用分布式ID(全局唯一ID):
分布式ID实现
符号位、时间戳、序列号
分布式ID生成器
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 26 27 28 29 30 31 32 33 34 35 36 37 38
| @Component public class RedisIdWorker {
@Resource private StringRedisTemplate stringRedisTemplate;
private static final long BEGIN_TIMESTAMP = 1640995200;
private static final int COUNT_BITS = 32;
public long nextId(String keyPrefix){ LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date); return timestamp << COUNT_BITS | count; }
public static void main(String[] args) { LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0); long second = time.toEpochSecond(ZoneOffset.UTC); System.out.println("second = " + second); } }
|
测试类
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 26 27 28 29 30 31 32 33 34 35
| @SpringBootTest public class RedisIdWorkerTest {
@Resource private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test public void testNextId() 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("生成3w个id共耗时" + (end - begin) + "ms"); } }
|
业务流程
- 库存是否充足
- 秒杀时间是否到
扣减库存、创建订单 是两次MySQL操作,所以使用**@Transactional**保证事务同成功同失败
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| @Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource private ISeckillVoucherService seckillVoucherService;
@Resource private RedisIdWorker redisIdWorker;
@Transactional @Override public Result seckillVoucher(Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始"); } if (voucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已结束"); } if (voucher.getStock() < 1) { return Result.fail("秒杀券已抢空"); } boolean success = seckillVoucherService.update() .setSql("stock = stock -1") .eq("voucher_id", voucherId).update(); if (!success) { throw new RuntimeException("秒杀券扣减库存失败"); } VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); voucherOrder.setUserId(UserHolder.getUser().getId()); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); } }
|
库存超卖
Jmeter 进行压力测试,并发出现安全问题
解决方法:
乐观锁的弊端:只要发现数据有修改,就直接终止操作了,导致成功率低。
更改为:只要库存大于0就可以进行修改。
1 2 3 4 5 6
| boolean success = seckillVoucherService.update() .setSql("stock = stock -1") .eq("voucher_id", voucherId) .gt("stock", 0) .update();
|
单机一人一单
同一个优惠券,一个用户只能下一单
方法:增加 根据优惠券id和用户id查询订单
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| @Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource private ISeckillVoucherService seckillVoucherService;
@Resource private RedisIdWorker redisIdWorker;
@Transactional @Override public Result seckillVoucher(Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始"); } if (voucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已结束"); } if (voucher.getStock() < 1) { return Result.fail("秒杀券已抢空"); } Long userId = UserHolder.getUser().getId(); 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(); if (!success) { throw new RuntimeException("秒杀券扣减库存失败"); } VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); } }
|
但同时存在并发安全问题,这个业务中是判断用户是否购买过,即判断是否存在数据库记录,而不是判断是否修改过,所以无法使用乐观锁方案,而是使用悲观锁方案
即:
- 乐观锁:判断修改过
- 悲观锁:判断是否存在数据库记录(只能)
最终使用悲观锁
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| @Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource private ISeckillVoucherService seckillVoucherService;
@Resource private RedisIdWorker redisIdWorker;
@Override public Result seckillVoucher(Long voucherId) { SeckillVoucher voucher = seckillVoucherService.getById(voucherId); if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒杀尚未开始"); } if (voucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("秒杀已结束"); } if (voucher.getStock() < 1) { return Result.fail("秒杀券已抢空"); } Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()) { IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); } }
@Transactional public Result createVoucherOrder(Long voucherId) { Long userId = UserHolder.getUser().getId(); 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(); if (!success) { throw new RuntimeException("秒杀券扣减库存失败"); } VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); } }
|
细节
- 锁的范围尽量小。
synchronized尽量锁代码块,而不是方法,锁的范围越大性能越低
- 锁的对象一定要是一个不变的值。我们不能直接锁
Long 类型的 userId,每请求一次都会创建一个新的 userId 对象,synchronized 要锁不变的值,所以我们要将 Long 类型的 userId 通过 toString()方法 转成 String 类型的 userId,toString()方法底层(可以点击去看源码)是直接 new 一个新的String对象,显然还是在变,所以我们要使用 intern() 方法从常量池中寻找与当前字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)
- 我们要锁住整个事务,而不是锁住事务内部的代码。 先获取锁,再开启事务,事务结束后,才会释放锁。如果我们锁住事务内部的代码会导致锁释放时,事务未提交,其它线程能够获取锁,仍然会存在超卖问题
- Spring 的 @Transactional 事务是基于代理对象的,这里直接调用相当于 this.xxx, 并非代理对象,因此事务不会生效,所以要拿到代理对象
(补)
生成代理对象生效步骤:
-
引入AOP依赖,动态代理
1 2 3 4
| <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
|
-
暴露动态代理对象,启动类上加入(默认关闭)
@EnableAspectJAutoProxy(exposeProxy = true)
集群一人一单
高并发,部署到多个不同机器
步骤:
存在问题:存在两个JVM,synchronized不能跨 JVM 进行上锁
因此,使用分布锁。