互联网大厂电商业务,扣库存操作究竟该如何加锁?

0 Bug案例

demo 类

起俩线程分别执行add、compare

乍看,a、b“同时”自增,应一直相等,compare中判断不会为true。但看日志:不仅有a<b,a>b有时也true。

评论区就笑了,这是你代码太垃圾,操作两个字段a和b,有线程安全问题,应该为add方法加锁,确保a和b的++原子性。

那就在add方法加锁

public synchronized void add()

但加锁后问题并没解决。

1 为何锁能解决线程安全问题?

因为只有一个线程能拿到锁,所以加锁后的代码中的资源操作线程安全。

但该案例中的 add 始终只有一个线程在操作呀,显然只为 add 加锁毫无意义。所以因为两个线程是交错执行add、compare中的业务逻辑,而且这些业务逻辑不是原子性:a++和b++操作中可穿插在compare方法的比较代码。a<b这种比较操作在字节码层面是三步,非原子:

  1. 加载a
  2. 加载b
  3. 比较

应该为add、compare都加锁,确保add执行时,compare无法读取a和b:

public synchronized void add()
public synchronized void compare()

所以,使用锁一定要梳理清楚线程、业务逻辑和锁三者关系。

2 锁和被保护的对象是否同一层面?

2.1 案例

累加counter:

测试:

因为传参运行100万次,所以执行后应输出100万,但:

why?在非静态的wrong方法上加锁,只能确保多线程无法执行同一实例的wrong,无法保证不执行不同实例的wrong。静态counter在多实例是共享的,所以会出现线程安全问题。

解决方案

在类中定义一个Object类型的静态字段,在操作counter之前对该字段加锁。

就这?直接把wrong定义为静态不就行?锁不就是类级别?是可以,但不可能为解决线程安全改变代码结构,随便把实例方法改为static方法。

3 加锁前,考虑锁粒度和业务场景

方法上加synchronized加锁是简单,但别滥用:

  1. 没必要
    绝大多数业务代码是MVC三层架构,数据经过无状态的Controller=>Service=>Repository=>DB
    没必要使用synchronized保护啥数据。
  2. 大概率降低性能
    使用Spring时,默认Controller、Service、Repository都是单例,加synchronized会导致整个程序几乎只能支持单线程,造成极大性能问题。即使确实有一些共享资源需要保护,也要尽可能减小锁粒度。像concurrentHashMap 。

3.1 案例

业务代码有个ArrayList会被多线程操作而需保护,但又有段比较耗时的不涉及线程安全的操作,如何加锁?推荐只在操作ArrayList时给这ArrayList加锁。

细化锁后,性能还无法满足,就要考虑另一个维度的粒度问题:区分读写场景以及资源的访问冲突,考虑

4 悲观锁 V.S 乐观锁

一般业务代码很少需要进一步考虑这两种更细粒度的锁,自己结合业务的性能需求考虑是否要继续优化:

  1. 读写差异明显场景,考虑ReentrantReadWriteLock读写锁
  2. 若JDK版本>8、共享资源的冲突概率也没那么大,考虑StampedLock乐观读
  3. JDK的ReentrantLockReentrantReadWriteLock都提供了公平锁版本,在没有明确需求情况下不要轻易开启公平锁,在任务很轻情况下开启公平锁可能会让性能下降百倍

5 死锁

锁粒度,够用就好,即程序逻辑中有时存在一些细粒度锁。但一个业务逻辑若涉及多个锁,易死锁。

5.1 案例

下单流程需锁定订单中多个商品的库存,拿到所有商品的锁后再进行下单扣减库存,全部操作完成后,释放所有锁。
上线后发现,下单失败概率高,失败后用户需重新下单。

事故原因

扣减库存的顺序不同,导致并发下多个线程可能相互持有部分商品的锁,又等待其他线程释放另一部分商品的锁,于是死锁。

核心业务代码

先定义一个商品类型,包含:

  • 商品名
  • 库存剩余
  • 商品的库存锁

三个属性,每种商品默认库存1000个。

然后,初始化10个这样的商品对象,模拟商品清单:

模拟购物车进行商品选购,每次从商品清单(items字段)随机选购三个商品(不考虑每次选购多个同类商品的逻辑,购物车中不体现商品数量):

下单代码

先声明一个List保存所有获得的锁,然后遍历购物车中的商品依次尝试获得商品的锁,最长等待10秒,获得全部锁之后再扣减库存;如果有无法获得锁的情况则解锁之前获得的所有锁,返回false下单失败。

private boolean createOrder(List<Item> order) {
    // 存放所有获得的锁
    List<ReentrantLock> locks = new ArrayList<>();

    for (Item item : order) {
        try {
            // 获得锁10秒超时
            if (item.lock.tryLock(10, TimeUnit.SECONDS)) {
                locks.add(item.lock);
            } else {
                locks.forEach(ReentrantLock::unlock);
                return false;
            }
        } catch (InterruptedException e) {
        }
    }
    // 锁全部拿到之后执行扣减库存业务逻辑
    try {
        order.forEach(item -> item.remaining--);
    } finally {
        locks.forEach(ReentrantLock::unlock);
    }
    return true;
}

测试下单

模拟在多线程情况下进行100次创建购物车和下单操作,最后通过日志输出成功的下单次数、总剩余的商品个数、100次下单耗时,以及下单完成后的商品库存明细:

@GetMapping("wrong")
public long wrong() {
    long begin = System.currentTimeMillis();
    //并发进行100次下单操作,统计成功次数
    long success = IntStream.rangeClosed(1, 100).parallel()
            .mapToObj(i -> {
                List<Item> cart = createCart();
                return createOrder(cart);
            })
            .filter(result -> result)
            .count();
    log.info("success:{} totalRemaining:{} took:{}ms items:{}",
            success,
            items.entrySet().stream().map(item -> item.getValue().remaining).reduce(0, Integer::sum),
            System.currentTimeMillis() - begin, items);
    return success;
}

运行程序,输出日志,100次下单操作成功了65次,10种商品总计10000件,库存总计为9805,消耗195件符合预期(65次下单成功,每次下单包含三件商品),总耗时50秒。

为什么会这样?

使用JDK自带的VisualVM工具跟踪一下,重新执行方法后不久就可以看到,线程Tab中提示了死锁问题,根据提示点击右侧线程Dump按钮进行线程抓取操作:

查看抓取出的线程栈,在页面中部可以看到如下日志:

显然出现死锁,线程4在等待的一个锁被线程3持有,线程3在等待的另一把锁被线程4持有。

5.2 为何死锁

购物车添加商品的逻辑,随机添加三种商品,假设一个购物车中的商品是item1和item2,另一个购物车中的商品是item2和item1,一个线程先获取到了item1的锁,同时另一个线程获取到了item2的锁,然后两个线程接下来要分别获取item2和item1的锁,这个时候锁已经被对方获取了,只能相互等待一直到10s超时。

5.3 避免死锁

为购物车中的商品排序,让所有线程一定先获取item1锁然后获取item2锁,就不会有问题。

只需修改一行代码,对createCart获得的购物车按商品名排序:

@GetMapping("right")
public long right() {
    ...
.    
    long success = IntStream.rangeClosed(1, 100).parallel()
            .mapToObj(i -> {
                List<Item> cart = createCart().stream()
                        .sorted(Comparator.comparing(Item::getName))
                        .collect(Collectors.toList());
                return createOrder(cart);
            })
            .filter(result -> result)
            .count();
    ...
    return success;
}

测试发现不管执行多少次都是100次成功下单,而且性能相当高,达到了3000以上TPS:

虽然死锁,但因为尝试获取锁的操作并非无限阻塞,所以没有造成永久死锁,之后的改进就是避免循环等待,通过对购物车的商品进行排序来实现有顺序的加锁,避免循环等待。

锁的实现较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且对于分布式锁要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。

如果你的业务代码涉及复杂的锁操作,强烈建议Mock相关外部接口或数据库操作后对应用代码进行压测,通过压测排除锁误用带来的性能问题和死锁问题。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值