同事的问题代码(第四期)

前言

🎉先祝各位老铁新年快乐啊🎉

快过年了,趁着最近活不是很多,今天和大家一起对我们的项目进行code review一下,让各位老铁都体验一下当"技术总监" 的 🚀feeling🚀。

问题代码

优惠券领取

写优惠券领取代码的大哥还是有实力的,工作经验至少也有8年以上了,毕竟都30好几的老大哥了👵

这大哥还是有点东西的,自己实现了一个简单的 锁管理工具 使用了 ConcurrentHashMap、单例模式、 AtomicBooleanvolatile+双检锁 实现的单例, 看注释还差点用上了弱引用

我们就先看看controller 层和 核心的service代码吧!😻

原代码逻辑

Controller层:

看一下 Controller 层的伪代码,主要的逻辑:

  1. 首先接口有限流注解
  2. 用户校验
  3. 数据库领取核心业务,库存扣减 (核心逻辑)
  4. 推送领取成功记录(告知第三方)
java 复制代码
    @RedisRateLimiter(value = 200,limit = 1)
    @PostMapping(value = "/claim")
    public Object claim(@RequestBody EquityClaimReqEx claimReqEx) {
        //1、用户信息验证
        Result<?> result =  claimService.check(claimReqEx);
        if(!result.isSuccess()){
            return result;
        }
        
        //2、领券--独立分段事务
        Result<?> claimCore = claimService.claimTran(claimReqEx);
        if(!claimCore.isSuccess()){
            return claimCore;
        }
        //3、记录领取明细
        return claimService.record(claimReqEx);
    }

Controller 层居然没有加锁

service核心代码:

看一下service代码的处理核心逻辑,实际代码逻辑还是比较复杂,个人写了伪代码

  1. 随机获取一个优惠券 随机获取优惠券的逻辑是用的SQL的 limit 1,多少还是可以优化的吧!
  2. 给优惠券加锁,获取锁失败直接返回,用的 redis setnx特性
  3. 锁库存,锁的逻辑还是自定义封装的锁工具👼给我看麻了啊
  4. 查询库存库存 并扣减 ,失败则回滚
  5. 更新优惠券码的领取状态、和用户领取状态,失败则回滚
java 复制代码
@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.READ_COMMITTED,rollbackFor = RuntimeException.class)
public Result<?> claimCoreTransactional2(ActivityEquityClaimReqEx claimReq) {
    long startTimeCore = System.currentTimeMillis();
    //------第(1)步:随机获取一个未发放的券码
    //原作者注释:进行商品信息获取:通过权益、站点、活动id,获取唯一商品
    EquityGoods equityGoods  =  equityGoodsDao.findByClaim(claimReq.getActivityId(),claimReq.getEquityId());
    if(null == equityGoods){
        return Result.error("权益商品异常:无可领取权益商品!");
    }
    // -------- 第(2)步: 给券码加锁
    //原作者注释: 针对同样的用户暴力请求,不做阻塞等待,直接异常返回处理
    Boolean acquired = redisTemplate.opsForValue().setIfAbsent(equityGoods.getRedeemCode(), "Lock", Duration.ofSeconds(60*5));
    if (acquired) {
        try {
            // ------- 第(3)步:锁库存
            //原作者注释:获取自定义锁对象(这里根据业务清理考虑奖券领取情况)
            ClainReentrantLock claimLock = claimLockManager.getLockForClaim(getLockKey(claimReq), 60 * 24, TimeUnit.MINUTES);
            Boolean isCompleted = false;
            //原作者注释:1、减库存、商品获取、领取情况 核心逻辑保证数据一致性
            try {
                claimLock.lock();
                //--------第(4)步:查询库存库存 并扣减 ,扣减失败回滚,成功进入下一步
                Inventory inventory = inventoryDao.findByconfirm(claimReq);
                boolean isok= inventoryDao.reduceInventory(inventory.getId(), isCompleted);
                if (!isok) {
                    throw new RuntimeException("更新配置权益核销状态失败!");
                }
                //----------------------第(5)步:更新优惠券码的领取状态、和用户领取状态,成功则完成领取,失败则回滚
                Boolean ret = updateUserRecordAndRedeemCode();
                if(!ret){
                    throw new RuntimeException("对不起,系统繁忙,稍后再试试吧!");
                }
            } catch (Exception e) {
                e.printStackTrace();
                throw e;
            } finally {
                // 释放锁资源
                claimLock.unlock();
                if (isCompleted) {
                    //站点权益分配完成,标记当前锁
                    claimLock.markForCleanup();
                }
                long endTimeCore = System.currentTimeMillis();
                logger.info("================领取逻辑耗时测试. Timed :{}", endTimeCore - startTimeCore);
            }
            return Result.ok();
        }finally {
            // 释放锁
            redisTemplate.delete(equityGoods.getRedeemCode());
        }
    }else{
        return Result.error("对不起,系统繁忙,稍后再试试吧!");
    }

}

居然用自己写了一个锁的管理工具👍就是后来人不好维护啊😭

存在的问题

核心service层算是实现得比较复杂的了吧,先是锁券码,然后是锁库存,库存扣减也是利用 SQL set num = num-1 where id =? 去扣减(数据库行锁)。因为库存锁是写在事务里面的,可能引起事务还没提交,所已经释放了,所以还是有并发风险。

  1. 限流注解的实现大有问题,往期文章说过这个问题,详情见: 同事代码问题(第三篇)

  2. Controller层的用户校验是没有加锁的,有没有可能,第一个用户并发请求发生重复领取呢,建议还是在Controller 层 做一个 userId 锁吧。

    userId 加锁 防住用户重复点击,重复领取等问题,并发请求。控制单人并发,提高系统整体的有效并发请求。

    之前也有同事,在Controller 层直接对库存加锁的,这样同一时刻 只能有一个有效请求,用户体验不好,但是能减少数据库压力。给userId加锁,用户多的话,压力给到数据库了(秒杀场景的话,库存扣减大多现在redis中lua脚本执行 )。

  3. 利用limit 1 返回一个可领取的优惠券,这样多个用户进来,很大概率 获取到的都是同一个优惠券,这样大多数的请求就成无效请求了,用户体验不好

    (当然实际业务场景,没啥并发,这里也是为了技术而技术了)。

  4. 锁问题,锁的券码,券码的查询逻辑是limit 1。注释写的是防止单个用户暴力请求,多少有问题,防止用户重复请求可以 锁userId啊! //原作者注释: 针对同样的用户暴力请求,不做阻塞等待,直接异常返回处理 12 Boolean acquired = redisTemplate.opsForValue().setIfAbsent(equityGoods.getRedeemCode(), "Lock", Duration.ofSeconds(60*5));

  5. 自定义的锁管理工具,自己爽了✈,别人难受了啊😣

  6. 一个方法里面 两个try

  7. 事务里面加锁,存在并发问题,事务还没提交,锁已经释放了同时同时同时同时

优化方案

接口做了限流之后,我们的锁就只需要锁用户就行了,库存扣减 和 券码领取 的状态修改,交给数据库处理就行了,数据更新行的时候会进行锁,并发不大的话,数据库也能抗住,关键是代码简化了许多。

Controller 直接 对userId加锁,获取到不锁直接fastfail,当然错误的限流实现也得修复(详: 同事代码问题(第三篇)

java 复制代码
    @RedisRateLimiter(value = 200,limit = 1)
    @PostMapping(value = "/claim")
    public Object claim(@RequestBody EquityClaimReqEx claimReqEx) {
        // 对领取业务 的用户ID 加锁
        Boolean lock = redissonLockClient.tryLock(RedisKeys.COUPON_RECEIVE_LOCK+user.getId(),20);
        if(!lock){
            return "服务繁忙.....";
        }
        try{
            //1、用户信息验证 库存校验
            Result<?> result =  claimService.check(claimReqEx);
            if(!result.isSuccess()){
                return result;
            }

            //2、领券--独立分段事务
            Result<?> claimCore = claimService.claimTran(claimReqEx);
            if(!claimCore.isSuccess()){
                return claimCore;
            }
            //3、记录领取明细
            return claimService.record(claimReqEx);
        }finally{
            //解锁
            redissonLockClient.unlock(RedisKeys.COUPON_RECEIVE_LOCK+user.getId());

        }
    }

service 层 以前的券码锁、库存锁直接 去掉,获取券码的逻辑 从redis 获取或者 是 线程安全的队列中获取. 更新劵码的状态时候,需要在where 条件中 添加status='未领取',如果更新失败 就回滚事务。

当然如果从从redis 或者 队列 中获取的话,我们就得去我们缓存数据了,比如券码已经被消费了、或者消费失败了 就要去删除缓存 或者是重新添加到缓存中。

java 复制代码
@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.READ_COMMITTED,rollbackFor = RuntimeException.class)
public Result<?> claimCoreTransactional2(ActivityEquityClaimReqEx claimReq) {
    long startTimeCore = System.currentTimeMillis();
    //------第(1)步:随机获取一个未发放的券码  从队列 ConcurrentLinkedQueue goodQueue 或者 是 redis set 中获取一个随机 券码,
    //从队列中 或者 redis 中获取一个券码,不用用户同一时间进来能获取到不同的 券码,提高请求效率,和并发量;
    EquityGoods equityGoods  =  goodQueue.poll();
    if(null == equityGoods){
        return Result.error("权益商品异常:无可领取权益商品!");
    }
    //第(2)步:更新券码状态为领取
    //`set status = 2(领取) where status= 1(未领取) and id = xx`
    boolean updateRet = updateGoods(id);
    if(!updateRet){
        throw new RuntimeException("更新配置权益核销状态失败!");
    }
    //--------第(3)步:查询库存库存 并扣减 ,扣减失败回滚,成功进入下一步  
   // ` set num= num-1 where id= xx and num>0`
    Inventory inventory = inventoryDao.findByconfirm(claimReq);
    boolean isok= inventoryDao.reduceInventory(inventory.getId(), isCompleted);
    if (!isok) {
        throw new RuntimeException("更新配置权益核销状态失败!");
    }
    //----------------------第(4)步:更新优惠券码的领取状态、和用户领取状态,成功则完成领取,失败则回滚
    Boolean ret = updateUserRecordAndRedeemCode();
    if(!ret){
        throw new RuntimeException("对不起,系统繁忙,稍后再试试吧!");
    }

    return Result.ok();
}

审批业务

这个就简单了,主要就是两个操作 提交审批审批撤销三个操作,实际上也没啥大问题。先看看代码吧,看看可能存在什么问题👀

原代码逻辑

提交审批

  1. 更新主表的审批状态为 待审批
  2. 插入一条待审批的日志 到日志表

审批

  1. 更新 日志表 的审批状态
  2. 更新主表的审批状态
java 复制代码
@Transactional(rollbackFor = Exception.class)
@Override
public void approve(LbApproveLogUpdateReq req) {

    Integer updateCount = logMapper.updateApproveStatusByIdAndApproveStatus(req);
    if (updateCount <= 0) {
        throw new JeecgBootException(NewLaborConstant.MSG_ERROR_APPROVE_OPERATE_FAIL);
    }

    // 修改主表记录。
    baseMapper.updateApproveStatus(req);
}

撤回

  1. 更新 主表
  2. 更新日志表
java 复制代码
@Transactional(rollbackFor = Exception.class)
@Override
public void approveCancel(Long id) {

    Integer updateCount = baseMapper.updateApproveCancel(id);
    if (updateCount <= 0) {
        throw new JeecgBootException(NewLaborConstant.MSG_ERROR_APPROVE_CANCEL_OPERATE_FAIL);
    }

    // 删除审批记录表信息。
    logMapper.removeByMainIdAndApproveStatus(logId);
}

存在问题

看起来确实没啥问题,当时都搬到台面上来了,怎么也得鸡蛋里面挑骨头🦴

存在的问题就是,审批 的时候 更新顺序是 日志表-->主表 撤回的时候主表---->日志表

👹同时操作同一个业务数据,容易造成死锁呀,毕竟方法都加了事务的,行锁需要整个事务执行完成才能释放

优化方案

对于这种需要在事务里面 更新多表的 代码,尽量更新的顺序一致吧🚀

总结

分享了两个案例,一个是优惠券领取,一个是审批、撤回业务。在优化券领取场景中,锁对象 还是很有讲究的,以及锁和事务 的配合使用 ,使用不当还是存在并发的问题。后面的审批业务,分享了在事务中 因为更新顺序不一致 可能引发死锁的问题。

希望本篇文章能对你有所帮助,感谢各位老铁,一键三连呀😁😊😋

ps 往期文章:
最近发现一些同事的代码问题(java)
最近发现同事的代码问题(第二篇)
最近发现一些同事的代码问题(java)

相关推荐
青云交1 分钟前
Java 大视界 -- Java 大数据在智能电网电力市场交易数据分析与策略制定中的关键作用(162)
java·大数据·数据分析·交易策略·智能电网·java 大数据·电力市场交易
m0Java门徒8 分钟前
Java 递归全解析:从原理到优化的实战指南
java·开发语言
Asthenia041212 分钟前
详细分析:ConcurrentLinkedQueue
后端
云徒川17 分钟前
【设计模式】原型模式
java·设计模式·原型模式
uhakadotcom22 分钟前
Ruff:Python 代码分析工具的新选择
后端·面试·github
uhakadotcom25 分钟前
Mypy入门:Python静态类型检查工具
后端·面试·github
喵个咪30 分钟前
开箱即用的GO后台管理系统 Kratos Admin - 定时任务
后端·微服务·消息队列
Asthenia041232 分钟前
ArrayList与LinkedList源码分析及面试应对策略
后端
张张张31240 分钟前
4.2学习总结 Java:list系列集合
java·学习
KATA~43 分钟前
解决MyBatis-Plus枚举映射错误:No enum constant问题
java·数据库·mybatis