秒杀测试案例 2023-02-24 10:32 基于redis和MySQL乐观锁实现秒杀优惠券场景,一人一单。MySQL乐观锁改良控制不出现超卖和少卖问题,使用redisson分布式锁在用户维度加锁控制一人一单。 源码:https://github.com/hanhanhanxu/SeckillTest > 文中图片看不清的地方可以鼠标右键->在新标签页中打开图片 ## 1、场景 一个基本的秒杀场景 现有80抵100的优惠券要给用户送福利,限时秒杀100张先到先得。 这种秒杀场景一般要注意的点有:超卖、少卖、一人一单。 ## 2、表结构: ```sql create table voucher ( id bigint(20) unsigned not null auto_increment primary key comment '主键', shop_id bigint(20) unsigned default null comment '商铺id', title varchar(255) not null comment '券标题', sub_title varchar(255) default null comment '副标题', rules varchar(1024) default null comment '使用规则', pay_value bigint(10) unsigned not null comment '支付金额,单位:分,例如:200,代表2元', actual_value bigint(10) unsigned not null comment '抵扣金额,单位:分,例如:100,代表1元', type tinyint(1) unsigned not null default '0' comment '券类型,0普通券,1秒杀券', status tinyint(1) unsigned not null default '1' comment '状态,1上架,2下架,3过期', create_time datetime not null default CURRENT_TIMESTAMP comment "创建时间", update_time datetime not null default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP comment "更新时间" ) default charset = utf8mb4 comment '优惠券表'; create table seckill_vouscher ( voucher_id bigint(20) unsigned not null primary key comment '主键,关联的优惠券的id', stock int(8) unsigned not null comment '库存', begin_time datetime not null default '0000-00-00 00:00:00' comment "生效时间", end_time datetime not null default '0000-00-00 00:00:00' comment "失效时间", create_time datetime not null default CURRENT_TIMESTAMP comment "创建时间", update_time datetime not null default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP comment "更新时间" ) default charset = utf8mb4 comment '秒杀券表,与优惠券是一对一关系'; create table voucher_order ( id bigint(20) not null primary key comment '主键', user_id bigint(20) unsigned not null comment '下单的用户id', voucher_id bigint(20) unsigned not null comment '购买的优惠券id', pay_type tinyint(1) unsigned not null default '1' comment '支付方式,1余额支付,2支付宝,3微信', status tinyint(1) unsigned not null default '1' comment '订单状态,1未支付,2已支付,3已核销,4已取消', create_time datetime not null default CURRENT_TIMESTAMP comment "创建时间,也即下单时间", pay_time datetime default null comment "支付时间", use_time datetime default null comment "核销时间", refund_time datetime default null comment "退款时间", update_time datetime not null default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP comment "更新时间" ) default charset = utf8mb4 comment '优惠券订单表'; ``` voucher表是优惠券表,存放普通优惠券和秒杀优惠券。普通优惠券日常天天都有,秒杀券由于优惠力度较大,所以只在特定情况下上架一批。 当添加秒杀券时,会同时向voucher和seckill_vouscher表中添加信息。 用户购买一张优惠券时,会向voucher_order添加一条记录。 ## 3、接口 ### 3.1、添加优惠券 比较简单,只展示service层逻辑: ```java /** * 新增一张秒杀券 * @param voucher */ @Override public void addSeckillVoucher(Voucher voucher) { //保存优惠券 save(voucher); SeckillVoucher seckillVoucher = new SeckillVoucher(); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); //保存秒杀券 seckillVoucherService.save(seckillVoucher); } ``` ### 3.2、基于redis的分布式id生成器 用户抢到优惠券后一般会返回给用户一个订单id,用户拿着这个订单id去商家核销使用优惠券,所以订单id一般是要给长长的号码,又不能太有规律,所以一般不会选择MySQL的自增id。 基于这个场景下的id需要满足三个需求:1、不重复 2、不容易发现规律 3、由于我们要将其持久化到MySQL,所以为了保证效率,应该是趋势递增的。 这种东西在业内叫“发号器”,指的就是能生成分布式唯一Id的东西。有多种解决方案,这里使用基于redis自增实现的。 ```java package xyz.riun.seckilltest.utils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import xyz.riun.seckilltest.constants.RedisConstant; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; /** * @Author:Hanxu * @url:https://riun.xyz/ * @Date:2023/2/23 16:09 * 基于redis的分布式id生成器 * 最长可用到2090-01-19 03:14:07,每秒并发及每天最多获取id个数 4294967295(42亿) */ @Component public class RedisIdWorker { private static final long startTime = 1640995200; private static final int COUNT_BITS = 32; //private static final int startIncr = 0; @Autowired private StringRedisTemplate stringRedisTemplate; /** * 首位是符号位固定为0 后续31位是秒级时间戳 最后32位是自增数字 * * 时间戳计算方式是当前时间-起始时间,31位二进制位最大可表示2^31-1( 2147483647)。 * 如果用2022.1.1 00:00:00( 1640995200)作为起始时间,最长可用到2090-01-19 03:14:07 (1640995200+2147483647 = 3788478847,秒级时间戳转为时间) * * 自增数字在redis里从1开始自增,并发获取id时(同一秒内来获取id),前31位秒级时间戳可能相同,因此每秒支持获取4294967295(42亿)个不同的id(2^32-1 redis的自增首个获取到的值是1,因此这32个二进制位不可能全为0) * 但由于自增数字只占32个二进制位,所以假设一秒内获取了2^32-1次id,那么今天就无法再获取其他id了,因为再继续自增,32个二进制位存不下,位移时就会丢失数据,导致和之前生成的id重复。 * 因此每天最多支持获取4294967295(42亿)个不同的id。要想改善这个问题可以增多自增数字占的位数,减少时间戳占的位数。 * * 这里key是 incr:bizKey:yyyy:MM:dd 所以每天都会有一个新的key去做自增,这样可以方便的统计每天获取了多少id,做其他业务上的统计。 * @param bizKey 业务标识 * @return 业务内的唯一id */ public long nextId(String bizKey) { //当前时间戳 - 起始时间戳 LocalDateTime now = LocalDateTime.now(); long nowTime = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowTime - startTime; //自增位 incr:voucher:20230223 String formatTime = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); Long incrNum = stringRedisTemplate.opsForValue().increment(RedisConstant.INCR_PRE_KEY + bizKey + ":" + formatTime); //incrNum = startIncr + incrNum; //位移 return (timestamp << COUNT_BITS) | (incrNum); } public static void main(String[] args) { long startTime = LocalDateTime.of(2022, 1, 1, 0, 0, 0).toEpochSecond(ZoneOffset.UTC); System.out.println(startTime); //1640995200 long nowTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); System.out.println(nowTime); System.out.println(nowTime - startTime); long timeMax = startTime + 2147483647; System.out.println(timeMax); LocalDateTime localDateTime = Instant.ofEpochSecond(timeMax).atZone(ZoneOffset.UTC).toLocalDateTime(); System.out.println(localDateTime); } } ``` ### 3.3、抢购秒杀券 这个接口是最核心的,所以从controller层开始讲起: ```java /** * 购买秒杀券,没有登录部分,所以直接传入userId模拟某个用户购买 * @param voucherId * @return */ @PostMapping("seckill") public Long seckillVoucher(Long voucherId, Long userId) { long orderId = voucherOrderService.seckillVoucher(voucherId, userId); return orderId; } ``` seckillVoucher接口就是秒杀逻辑,这里将要秒杀的优惠券id:voucherId和用户id:userId传下去。 这个接口的逻辑应该是这样的: 1、先检查秒杀券能否购买,也就是常规的时间检查,库存检查。我们按照正常的可用购买往下走 2、秒杀券库存-1 3、优惠券订单添加该用户的购买记录,使用分布式id生成器作为订单id 4、一切成功,返回订单id 第一步是个查询sql,第2、3步是修改sql,而且2、3步应该是原子性的,所以我们要将其封装为一个事务。 #### ①超卖问题 这里面最容易发生问题的点就是第1-2步,在高并发的情况下,很容易出现优惠券剩下1张,然后多个请求并发过来,同时做了第一步的判断,都确定有库存可以向下执行,然后都对库存做了-1操作,这样就容易出现超卖情况。 解决这种情况有很多方式,最常见的就是加锁。 **悲观锁** 如果加悲观锁,要切记需要使用**分布式锁**,而不能使用synchronized或者ReentrantLock这种JVM层面的锁。如果使用后者,那么在集群项目中多个请求并发打到多台机器上,每个机器上的线程都能获取它所在机器上面的锁,那么这个锁在服务层面就是失效的。 比如可以使用redis分布式锁,每次执行时先抢占锁,由于在外层加了锁就一定能保证数据的安全。不过这种每个线程过来都需要抢占一下锁,效率太低了,一般不用。 **乐观锁** 如果使用乐观锁,一般的做法是添加一个version版本号字段: 像update table set stock = stock - 1 and version = version + 1 where id = #{id} and version = #{version}; 这样。 但在库存场景下,stock本身就能作为版本号控制,因为我们是先查询库存当库存充足才去减库存的,也就是说我们是知道库存是多少的: update table set stock = stock - 1 where id = #{id} and stock = #{stock}; 但是乐观锁有一个问题,就是并发执行时一定会只有一个线程(请求)能够执行成功,其他并发的线程全部失败。就是说如果有100个人同时抢100个库存的秒杀券,他们刚好在同一时间执行,理论上来说100个人100张券应该改好抢完。但是如果同时执行到这条sql:update table set stock = stock - 1 where id = #{id} and stock = #{stock}; 由于数据库的行锁,只有一个人能够执行成功,抢到券。剩余的99个人执行时都是不满足stock = #{stock};的,他们都会失败。 这是我们不希望看到的,版本号形式的乐观锁失败率太高了。也就是会发生**少卖问题**。 我们是100张相同的券,只要有券的库存,都希望人们能够抢到,所有我们只需要关系库存是否有就行了,因此可以稍微改变一下: **update table set stock = stock - 1 where id = #{id} and stock > 0;** 这样100个人同时来抢100张券的话,他们就都能够执行成功了,都能够抢到了。 #### ②一人一单 这种优惠力度的活动,一般是希望一人只能购买一单,让更多用户参与进来的。为了避免刷单,我们可以优化一下,在购买时做个检查:当前用户是否购买过,已购买过就无法购买了。 也就是在第二部之前添加一步: 1、先检查秒杀券能否购买,也就是常规的时间检查,库存检查。我们按照正常的可用购买往下走 2、当前用户是否购买过该秒杀券,没有购买过可以往下走 3、秒杀券库存-1 4、优惠券订单添加该用户的购买记录,使用分布式id生成器作为订单id 5、一切成功,返回订单id 现在试想有个人没有购买过秒杀券,然后想刷单购买多张秒杀券,也就是准备并发的用他自己的信息(userId)调我们的接口。当一个线程走到第二步时,没有购买过,向下走;此时还有若干个携带同样用户信息的线程也走到第2步,由于前面的线程还没有向数据库中插入订单信息,所以这若干个线程也能走过第二步,继续往下走。这些线程同时往下走,意味着同一个用户能够购买多张优惠券,这和我们一人一单的需求是不符的。 要解决这个问题,可以在**第2步之前添加redis分布式锁,不过锁的粒度要特别小,锁当前用户**。即redis锁的key是这样:lock:order:userId。 这样不同用户进来时就不会被锁互斥,只有同一个用户的多个请求并发进来时,才会被锁住。 解锁的时机比较重要,**一定要等到事务提交之后才能解锁**。否则可能出现:一个线程获取锁执行完,事务还没提交,然后先解锁了。这时另外一个线程过来,拿着同一个用户信息,加锁,前面的事务没有提交也就意味着数据库中没有用户的订单信息,也就是说还能通过第2步。 所以一定要等待事务执行完,然后再解锁。 #### ③核心代码: ```java /** * @Author:Hanxu * @url:https://riun.xyz/ * @Date:2023/2/23 18:52 * 优惠券订单相关 */ @Slf4j @Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Autowired private ISeckillVoucherService seckillVoucherService; @Autowired private IVoucherOrderService voucherOrderService; @Resource private RedisIdWorker redisIdWorker; @Resource private RedissonClient redissonClient; /** * 购买一张秒杀券 * @param voucherId * @return */ @Override public long seckillVoucher(Long voucherId, Long userId) { //检测秒杀券是否可以正常购买 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); checkAvailable(seckillVoucher); //检测用户是否购买过 //每个用户维度加锁 lock:order:userId RLock rLock = redissonClient.getLock(RedisConstant.LOCK_PRE_KEY + RedisConstant.BIZ_ORDER + ":" + userId); boolean isLock = rLock.tryLock(); if (!isLock) { log.error("可能存在刷单行为:userId:{} voucherId:{}", userId, voucherId); throw new RuntimeException("正在购买中,请勿重复提交!"); } try { //使用代理执行对应方法,确保事务生效 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); long orderId = proxy.createVoucherOrder(voucherId, userId); return orderId; } finally { //一定要在事务提交后再解锁。 // 若事务未提交时解锁,则可能voucherOrder还未写入,那么其他线程进入createVoucherOrder方法判断count=0,可继续向下执行,就不再是一人一单了。 rLock.unlock(); } } @Override @Transactional(propagation= Propagation.REQUIRED, isolation= Isolation.READ_COMMITTED) public long createVoucherOrder(Long voucherId, Long userId) { //查询用户是否已经购买 如果不加分布式锁,这里可能有多个线程同时满足条件,同时向下执行,那么一个用户就有可能通过抢单软件抢到多个优惠券 // select count(*) from voucher_order where user_id = #{userId} and voucher_id = #{voucherId} int count = voucherOrderService.query().eq("user_id", userId).eq("voucher_id", voucherId).count(); if (count > 0) { throw new RuntimeException("已经购买过!"); } //减库存 stock > 0 控制不会超卖 // update seckill_vouscher set stock = stock - 1 where voucher_id = #{voucherId} and stock > 0 boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update(); if (!success) { throw new RuntimeException("库存不足!"); } //添加订单信息 long nextId = redisIdWorker.nextId(RedisConstant.BIZ_ORDER); VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setVoucherId(voucherId); voucherOrder.setUserId(userId); voucherOrder.setId(nextId); voucherOrderService.save(voucherOrder); return nextId; } private void checkAvailable(SeckillVoucher seckillVoucher) { //其他判断如时间... if (seckillVoucher.getStock() < 1) { throw new RuntimeException("库存不足!"); } } } ``` ## 4、源码 https://github.com/hanhanhanxu/SeckillTest ## 5、测试 ### 5.1、添加秒杀券 postman调用接口: ![](https://minio.riun.xyz/riun1/2023-02-23_2XbC6Cm1QqmBGGobWW.jpg) 向voucher和seckill_vouscher中添加一条记录,voucher_order中没有任何记录。 ![](https://minio.riun.xyz/riun1/2023-02-23_2XbEA7bgI3g2dGdQxw.jpg) ### 5.2、用户购买一张秒杀券 ![](https://minio.riun.xyz/riun1/2023-02-23_2XbJ8xQRyJ0GMJQaKz.jpg) 返回 155442610268274789 数据库中新增了一条订单记录,秒杀券库存由100变为99 ![](https://minio.riun.xyz/riun1/2023-02-23_2XbKDYdfcMqZdNWiaI.jpg) ### 5.3、多用户秒杀压测 jmeter中200个线程,每个线程循环100遍压测秒杀接口: ![](https://minio.riun.xyz/riun1/2023-02-23_2XbVitknwXdvI4faER.jpg) userId使用以下代码写入本地文件: ```java public static void main(String[] args) throws IOException { //向文件中写数据 FileWriter fileWriter = new FileWriter(new File("E:\\TestFloder\\Seckill\\userId.txt")); BufferedWriter bufferedWriter = new BufferedWriter(fileWriter); for (int i = 100003; i < 100503; i++) { bufferedWriter.write(i + "\n"); } bufferedWriter.close(); fileWriter.close(); } ``` 文件中是从100003到100502 ![](https://minio.riun.xyz/riun1/2023-02-23_2XbRCmvmVtMSRTzNNc.jpg) jmeter将拿着这些userId并发的去秒杀剩余99张秒杀券。 执行,查看汇总报告。rt 133,tps 1400,99.5%的异常是因为20000个请求只有99张券,异常是正确的: ![](https://minio.riun.xyz/riun1/2023-02-23_2XbX58yBEUdkfCka1r.jpg) 查看数据库: seckill_vouscher表中该秒杀券的库存为0,说明全部被买掉了。 ![](https://minio.riun.xyz/riun1/2023-02-23_2XbXXsYs1nqxTrqLBT.jpg) voucher_order表中出现很多记录,执行select count(*) from voucher_order;查看结果为100,说明订单记录也是一张也不多一张也不少: ![](https://minio.riun.xyz/riun1/2023-02-23_2XbYTIFzkcE3vjkWXz.jpg) ## 6、总结 秒杀场景下要注意的点一般有:超卖问题、少卖问题、一人一单、事务提交后再解锁。 --END--
发表评论