并发下编写SQL的注意事项
在并发下,如果非原子性代码运行到某一行暂停,其他线程重新操作是否会出问题?
下面,这里以一个领取优惠券功能的Demo版本展示,领取优惠券的步骤如下;
注:这里只是简单模拟,不涉及秒杀和队列;
1.获取优惠券是否存在 2.校验优惠券是否可以领取,时间,库存,是否超过限制 3.扣减库存 4.保存领券记录
t_coupon表
CREATE TABLE `t_coupon` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `category` varchar(11) DEFAULT NULL COMMENT '优惠卷类型[NEW_USER注册赠券,TASK任务卷,PROMOTION促销劵]', `publish` varchar(11) DEFAULT NULL COMMENT '发布状态, PUBLISH发布,DRAFT草稿,OFFLINE下线', `coupon_img` varchar(524) DEFAULT NULL COMMENT '优惠券图片', `coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题', `price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格', `user_limit` int(11) DEFAULT NULL COMMENT '每人限制张数', `start_time` datetime DEFAULT NULL COMMENT '优惠券开始有效时间', `end_time` datetime DEFAULT NULL COMMENT '优惠券失效时间', `publish_count` int(11) DEFAULT NULL COMMENT '优惠券总量', `stock` int(11) DEFAULT '0' COMMENT '库存', `create_time` datetime DEFAULT NULL, `condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用', `version` int(11) DEFAULT '0' COMMENT '版本号', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
t_coupon_record表
CREATE TABLE `t_coupon_record` ( `id` bigint(11) unsigned NOT NULL AUTO_INCREMENT, `coupon_id` bigint(11) DEFAULT NULL COMMENT '优惠券id', `create_time` datetime DEFAULT NULL COMMENT '创建时间获得时间', `use_state` varchar(32) DEFAULT NULL COMMENT '使用状态 可用 NEW,已使用USED,过期 EXPIRED;', `user_id` bigint(11) DEFAULT NULL COMMENT '用户id', `user_name` varchar(128) DEFAULT NULL COMMENT '用户昵称', `coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题', `start_time` datetime DEFAULT NULL COMMENT '开始时间', `end_time` datetime DEFAULT NULL COMMENT '结束时间', `order_id` bigint(11) DEFAULT NULL COMMENT '订单id', `price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格', `condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
准备数据:
INSERT INTO `t_coupon` (`id`, `category`, `publish`, `coupon_img`, `coupon_title`, `price`, `user_limit`, `start_time`, `end_time`, `publish_count`, `stock`, `create_time`, `condition_price`, `version`) VALUE (19, 'PROMOTION', 'PUBLISH', null, '有效中-21年1月到25年1月-20元满减-5元抵扣劵-限领取2张-不可叠加使用', 5.00, 3, '2000-01-29 00:00:00', '2025-01-29 00:00:00', 10, 5, '2020-12-26 16:33:03', 20.00, 0);
业务代码版本1
@Override public JsonData testReduceCoupon(long couponId) { LoginUser loginUser = LoginInterceptor.threadLocal.get(); // 查询符合类型的优惠券 CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>().eq(CouponConstant.COUPON_ID, couponId) .eq(CouponConstant.PUBLISH_STATE, CouponPublishEnum.PUBLISH.name()) .eq(CouponConstant.CATEGORY_STATE, CouponCategoryEnum.PROMOTION.name())); if (ObjectUtils.isEmpty(couponDO)) { throw new BizException(BizCodeEnum.COUPON_NO_EXITS); } // 校验优惠券是否可以领取 checkCoupon(couponDO, loginUser.getId()); // 构建领卷记录 CouponRecordDO couponRecordDO = new CouponRecordDO(); BeanUtils.copyProperties(couponDO, couponRecordDO); couponRecordDO.setCreateTime(new Date()); couponRecordDO.setUseState(CouponStateEnum.NEW.name()); couponRecordDO.setUserId(loginUser.getId()); couponRecordDO.setUserName(loginUser.getName()); couponRecordDO.setCouponId(couponId); couponRecordDO.setId(null); // 扣减优惠券库存 int row = couponMapper.reduceStockV1(couponId); if (row == 1) { // 新增领券记录 couponRecordMapper.insert(couponRecordDO); } else { log.warn("领券失败:{},用户:{}", couponDO, loginUser); throw new BizException(BizCodeEnum.COUPON_NO_STOCK); } return JsonData.buildSuccess(); }
reduceStockV1对应的sql,如下:
<update id="reduceStockV1"> update t_coupon set stock = stock - 1 where id = #{couponId} </update>
上面的代码有没有问题?
如果直接简单的请求是看不出问题,但是如果在并发下就会有问题,t_coupon表的stock值会有扣减成负数的可能,也叫超发;
使用Jmeter设置线程数为30,Ramp-Up时间为2秒,循环次数为3,也就是30个请求分2秒执行完毕,每秒执行15个请求,循环3次,一共有30 * 3个请求,运行结果如下:
stock出现了负数,分析如下:
由于扣减库存成功了,couponRecordMapper.insert也就会相应的执行成功;
上面的操作会出现库存超发等问题,原因是这些操作都不是原子操作,在并发情况下会出现问题;
防止库存扣减为负数,可使用下面的方式处理:
- synchronized同步代码块,Lock锁
synchronized,Lock锁作用范围是单个jvm实例, 如果做了集群分布式等,那么本地锁就失效了,而且单机JVM加锁后就是串行等待问题;
- 使用分布式锁
- 数据库更新扣减
-- 如果num大于已有库存,则会变负数 update t_coupon set stock=stock - #{num} where id = #{couponId} and stock > 0; -- 修复了负数问题 update t_coupon set stock=stock - #{num} where id = #{couponId} and (stock - #{num}) >= 0; update t_coupon set stock=stock - #{num} where id = #{couponId} and stock >= #{num}
-
- 如果扣减库存最多1个,则直接使用下面这种
update t_coupon set stock = stock - 1 where id = #{couponId} and stock > 0
在id是主键索引的前提下,如果每次只是减少1个库存,则可以采用上面的方式,只做数据安全校验,可以有效减库存,避免大量无用sql,只要有库存就也可以操作完成;
将上面的测试代码的sql更改如下:
<update id="reduceStockV2"> update t_coupon set stock = stock - 1 where id = #{couponId} and stock > 0 </update>
运行结果如下:
虽然这种能防止库存扣减为负数,但是新增记录那里的数量超过user_limit设置的值,还是上面的那个原子的问题没有解决;
注:上面的sql,如果有人修改了库存,如thread-1查询stock为10,thread-2扣减1个,还剩9个,thread-3更新库存,变回了10个,那thread-1更新的时候发现还是10个,但是中间更改的过程,thread-1并不知道,也就是ABA问题;
可使用下面的方式避免ABA:
<!-- 扣减库存带版本号,不存在ABA问题 --> <update id="reduceStockV3"> update t_coupon set stock = stock - 1, version = version + 1 where id = #{couponId} and version = #{version} and stock > 0 </update>
增加版本号主要是为了解决ABA问题,数据读取后,更新前数据被别人篡改过,version只能做递增,但是这种会产生因version不一致而不能更新的sql;
上面处理方式中前两者是在业务层面上保证这些操作为原子性来处理的,后面的则是在SQL层面上处理的,但是SQL层面只是处理库存扣减不为负数;
下面使用Redis作为分布式锁来解决原子性问题;
@Override @Transactional(rollbackFor = {Exception.class}, propagation= Propagation.REQUIRED) public JsonData testReduceCoupon(long couponId) RLock lock = redissonClient.getLock("lock:coupon:" + couponId); lock.lock(); try { log.info("领卷接口加锁成功.."); return addCoupon(couponId, CouponCategoryEnum.PROMOTION); } finally { lock.unlock(); log.info("领卷接口解锁成功.."); } } private JsonData addCoupon(long couponId, CouponCategoryEnum promotion) { LoginUser loginUser = LoginInterceptor.threadLocal.get(); // 查询符合类型的优惠券 CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>().eq(CouponConstant.COUPON_ID, couponId) .eq(CouponConstant.PUBLISH_STATE, CouponPublishEnum.PUBLISH.name()) .eq(CouponConstant.CATEGORY_STATE, promotion.name())); if (ObjectUtils.isEmpty(couponDO)) { throw new BizException(BizCodeEnum.COUPON_NO_EXITS); } checkCoupon(couponDO, loginUser.getId()); // 构建领卷记录 CouponRecordDO couponRecordDO = new CouponRecordDO(); BeanUtils.copyProperties(couponDO, couponRecordDO); couponRecordDO.setCreateTime(new Date()); couponRecordDO.setUseState(CouponStateEnum.NEW.name()); couponRecordDO.setUserId(loginUser.getId()); couponRecordDO.setUserName(loginUser.getName()); couponRecordDO.setCouponId(couponId); couponRecordDO.setId(null); // TODO 扣减库存 int row = couponMapper.reduceStockV2(couponId); // 保存领券记录 if (row == 1) { couponRecordMapper.insert(couponRecordDO); } else { log.warn("发放优惠券失败:{},用户:{}", couponDO, loginUser); throw new BizException(BizCodeEnum.COUPON_NO_STOCK); } return JsonData.buildSuccess(); }
设置stock为5,并发执行结果如下: