代码审查时发现的一处分布式锁错误用法

代码审查时发现的一处分布式锁错误用法

​ 今天测试反应在商品入库存的时候会出现一个偶现的问题,多次入库后,突然发现商品的库存量是乱的,但是专门针对这个功能去测试的时候,却发现功能又是正常的,无法稳定复现问题,测试希望开发审查下代码看下是哪里的原因。

​ 于是开发我们立马定位到商品入库存的那段代码,大致代码如下:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public Boolean inStockProduct(InStockRequest request) {
    DistributedLock lock = distributedLockService.lock("inStockProduct", String.valueOf(request.getProductKid()), 1, TimeUnit.SECONDS);   
    try {
        Product existsProduct = super.getById(request.getProductKid());
        if (Objects.isNull(existsProduct)) {
            throw new BusinessException("商品kid非法");
        }

        //记录到出入库记录表
        ProductInOut inOutData = new ProductInOut();
        //........此处省略字段赋值
        Boolean result = productInOutService.save(inOutData);

        if (result) {
            //更新商品表的库存数量
            Product productData = new Product();
            productData.setKid(request.getProductKid());
            productData.setNum(existsProduct.getNum() + request.getNum());
            result = super.updateById(productData);
        }

        return result;
    } finally {
        distributedLockService.unlock(lock);
    }
}

看的出写这段代码的人知道在入库时要针对商品id加分布式锁,那么这个分布式锁用的对不对了,我们这个分布式锁是通过redis来实现的,下面我们来看看分布式锁的lock方法是怎么实现的;

java 复制代码
public DistributedLock lock(String prefix, String ids, long timeout, TimeUnit timeoutUnit, long expire, TimeUnit expireUnit) {
    String uuid = UUID.randomUUID().toString();
    DistributedLock distributedLock = new DistributedLock(true, prefix, ids, uuid);
    if (StringUtils.hasText(ids)) {
        String key = lockPrefix + ":" + ids;
        //加锁
        lockByKey(key, uuid, timeout, timeoutUnit, expire, expireUnit);
    }
    return distributedLock;
}
java 复制代码
private void lockByKey(String key, String value, long timeout, TimeUnit timeoutUnit, long expire, TimeUnit expireUnit) {
    try {
        //超时时间比失效时间大,则失效时间默认使用超时时间
        if (TimeUnit.MILLISECONDS.convert(timeout, timeoutUnit) > TimeUnit.MILLISECONDS.convert(expire, expireUnit)) {
            expire = timeout;
            expireUnit = timeoutUnit;
        }
        long nanoTime = System.nanoTime();
        long nanoTimeout = TimeUnit.NANOSECONDS.convert(timeout, timeoutUnit);
        //在timeout的时间范围内不断轮询锁
        while (System.nanoTime() - nanoTime < nanoTimeout) {
            //锁不存在的话,设置锁并设置锁过期时间,即加锁
            if (setNX(key, value, expire, expireUnit)) {
                logger.debug("获取分布式锁成功,KEY={}", key);
                return;
            }
            logger.debug("分布式锁获取等待中...,KEY={}", key);
            //短暂休眠,避免一直轮询,CPU消耗太高
            Thread.sleep(10, RANDOM.nextInt(100));
        }
    } catch (Exception e) {
        logger.debug("获取分布式锁失败,KEY=" + key, e);
    }
    throw new BusinessException("300", "服务器忙,请稍后再试", "获取分布式锁失败,KEY=" + key);
}

通过上面的代码可以看到分布式锁是通过redis的setNX来实现的。

我们来分析下上面那个业务场景,假设有两个线程同时针对一个商品入库,两个线程必定是先后进入lockByKey方法的,第一个线程通过setNX成功获取锁,继续执行他的业务代码,在第一个线程没有释放锁的情况下,第二个线程调用setNX必然失败,短暂休眠后继续去获取锁,如果在超时时间后还没获取到锁则抛异常,第二个线程执行失败,如果在超时时间内第一个线程执行完成并释放锁之后,则第二个线程就能获取到分布式锁,然后执行第二个线程的业务代码,那么这两个线程就是顺序执行的。如果用户在前端点击太快,导致相同的HTTP请求发了两次,虽然我们可以要求前端去做控制,但是我们后端的接口的正确性必须是与前端的操作无关的,也就是就算前端连续发了两次相同的请求,后端也要保证结果的正确性,很显然在这种情况下,商品的库存量等于是多录入了一次。那么怎么解决了?

通常用户发送重复请求的现象是点击太快导致的,那么他们的请求时间间隔会非常的短,比如我们可以定义1秒的时间,1秒内针对同一个商品的入库认为是非法的,是可以丢弃的。相同的请求线程,第一个线程成功获取到锁,后面的线程获取锁如果失败则丢弃,现在的问题是分布式锁的lock方法在失败后会再次尝试,并没有直接返回失败,我们来看看分布式锁的lockAndHold方法的实现:

java 复制代码
public DistributedLock lockAndHold(String prefix, String id, long holdTime, TimeUnit holdTimeUnit) {
    String key = buildPrefix(prefix) + ":" + id;
    //锁不存在的话,设置锁并设置锁过期时间,即加锁
    String uuid = UUID.randomUUID().toString();
    boolean result = setNX(key, uuid, holdTime, holdTimeUnit);
    return new DistributedLock(result, prefix, id, uuid);
}

如果调用lockAndHold方法,那么在第一个线程没有释放线程的时候,第二个线程调用setNX必然是失败的,可以满足我们的业务需求。

那么针对入库时要加个判断,如果加锁失败则丢弃,那么商品入库存的代码可以修改为:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public Boolean inStockProduct(InStockRequest request) {
    DistributedLock lock = distributedLockService.lockAndHold("inStockProduct", String.valueOf(request.getProductKid()), 5, TimeUnit.SECONDS);
    if (!lock.isLocked()) {
        return false;
    }
    try {
        Product existsProduct = super.getById(request.getProductKid());
        if (Objects.isNull(existsProduct)) {
            throw new BusinessException("商品kid非法");
        }

        //记录到出入库记录表
        ProductInOut inOutData = new ProductInOut();
        //........此处省略字段赋值
        Boolean result = productInOutService.save(inOutData);

        if (result) {
            //更新商品表的库存数量
            Product productData = new Product();
            productData.setKid(request.getProductKid());
            productData.setNum(existsProduct.getNum() + request.getNum());
            result = super.updateById(productData);
        }

        return result;
    } finally {
        distributedLockService.unlock(lock);
    }
}
相关推荐
假装我不帅1 小时前
asp.net framework从webform开始创建mvc项目
后端·asp.net·mvc
神仙别闹1 小时前
基于ASP.NET+SQL Server实现简单小说网站(包括PC版本和移动版本)
后端·asp.net
计算机-秋大田2 小时前
基于Spring Boot的船舶监造系统的设计与实现,LW+源码+讲解
java·论文阅读·spring boot·后端·vue
货拉拉技术2 小时前
货拉拉-实时对账系统(算盘平台)
后端
掘金酱3 小时前
✍【瓜分额外奖金】11月金石计划附加挑战赛-活动命题发布
人工智能·后端
代码之光_19803 小时前
保障性住房管理:SpringBoot技术优势分析
java·spring boot·后端
ajsbxi3 小时前
苍穹外卖学习记录
java·笔记·后端·学习·nginx·spring·servlet
颜淡慕潇4 小时前
【K8S问题系列 |1 】Kubernetes 中 NodePort 类型的 Service 无法访问【已解决】
后端·云原生·容器·kubernetes·问题解决
尘浮生5 小时前
Java项目实战II基于Spring Boot的光影视频平台(开发文档+数据库+源码)
java·开发语言·数据库·spring boot·后端·maven·intellij-idea