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

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

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

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

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);
    }
}
相关推荐
柏油5 小时前
MySQL InnoDB 行锁
数据库·后端·mysql
咖啡调调。5 小时前
使用Django框架表单
后端·python·django
白泽talk5 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务
摆烂工程师5 小时前
全网最详细的5分钟快速申请一个国际 “edu教育邮箱” 的保姆级教程!
前端·后端·程序员
一只叫煤球的猫6 小时前
你真的会用 return 吗?—— 11个值得借鉴的 return 写法
java·后端·代码规范
Asthenia04126 小时前
HTTP调用超时与重试问题分析
后端
颇有几分姿色6 小时前
Spring Boot 读取配置文件的几种方式
java·spring boot·后端
AntBlack6 小时前
别说了别说了 ,Trae 已经在不停优化迭代了
前端·人工智能·后端
@淡 定7 小时前
Spring Boot 的配置加载顺序
java·spring boot·后端