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

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

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

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

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);
    }
}
相关推荐
摇滚侠7 分钟前
Spring Boot 3零基础教程,IOC容器中组件的注册,笔记08
spring boot·笔记·后端
程序员小凯3 小时前
Spring Boot测试框架详解
java·spring boot·后端
你的人类朋友3 小时前
什么是断言?
前端·后端·安全
程序员小凯4 小时前
Spring Boot缓存机制详解
spring boot·后端·缓存
i学长的猫5 小时前
Ruby on Rails 从0 开始入门到进阶到高级 - 10分钟速通版
后端·ruby on rails·ruby
用户21411832636025 小时前
别再为 Claude 付费!Codex + 免费模型 + cc-switch,多场景 AI 编程全搞定
后端
茯苓gao5 小时前
Django网站开发记录(一)配置Mniconda,Python虚拟环境,配置Django
后端·python·django
Cherry Zack5 小时前
Django视图进阶:快捷函数、装饰器与请求响应
后端·python·django
爱读源码的大都督6 小时前
为什么有了HTTP,还需要gPRC?
java·后端·架构
码事漫谈6 小时前
致软件新手的第一个项目指南:阶段、文档与破局之道
后端