高并发无锁扣库存

1,063 阅读4分钟

跟我回忆一道经典线程安全问题;两个线程同时修改一个变量,怎么保证变量修改正常?聪明的你脱口而出,加锁,volatile...
截屏2022-10-10 22.05.01.png

对于JMM来说,保证原子性,可见性,有序性就能保证线程安全。加锁保证原子性,有序性;volatile保证可见性,两连斩杀,质保三年。

优化

锁分很多种,上述的锁属于悲观锁,并发不高的情况对性能没影响,但高并发的情况,会严重拖累性能,因为大量线程在排队抢锁。我们经手的都是高并发(大约也就可能应该有那么多亿吧🤔),必须寻找可替代方案,保证线程安全的同时提高性能。

回忆一下,我们通过保证原子性,有序性,可见性三管齐下保证线程安全;而加锁通过限制串行保证原子性,那有没有方法,不加锁也能保证原子性。

当然有,不然我写这篇博文做什么!!!
一个高并发抢优惠券场景中... 截屏2022-10-10 22.07.16.png 如你所见,我喜欢的简洁代码风格。基于redis的list结构,pop成功,说明抢到库存了。得益于redis的单线程,你不用担心原子问题;得益于redis的reactor模型,该段代码并行执行,在redis端排队处理;得益于redis的高性能,你不用担心过多socket访问拖累程序。不过这个做法有个前提,需要提前把库存加到redis。 image.png

问题

上面的操作仅在redis扣库存,redis不保证持久性,数据还需要落地到db。
一次完整的扣优惠券库存流程是这样的,前端请求打到redis,如果扣成功则生成日志记录行为,生成用户优惠券,最后发mq,消费者消费消息实现优惠券库存修改落地。

方案

这次要解决的是异步高并发修改db的数据。
前半步是高并发扣优惠券库存,redis扣成功就返回给用户;后半步重点在保证db的数据落地,走柔性事务,另外起一个对账任务,截取某时间段的数据对账,矫正

贴代码

基于redis扣库存

image.png

两种异常

上面的扣库代码会出现两种异常情况

  1. 请求未到达redis,超时
  2. 请求达到redis,redis完成库存扣减,因网络抖动超时

两种情况中,如果是第二种还好,可以通过补偿返回库存解决,第一种就不能补偿,因为他没扣,补偿库存会导致超卖。那怎么解决这个问题呢?

容忍少发

如果业务允许容忍少发,毕竟少发比超发损失更小,就什么都不用做。

补偿

如果使用补偿的方案,就不能简单的用list pop方案,改成lua脚本

  1. 先扣库存,
  2. 设置扣库存流水号到redis,

发生异常时,补偿的lua脚本

  1. 判断是否存在流水号
  2. 如果存在,说明是第二种情况,则返回
  3. 如果不存在,说明是第一种情况,则实现扣库存,设置流水号

以上的场景就是经典的接口重试与幂等。

jemeter

最后是jemeter压测比较,我的电脑是m1,开了挺多程序,简单设置1000个请求,两倍提升,还可以。

无锁

image.png

有锁

image.png

最后的疑问

无锁有加锁方案的区别中,加锁使用synchronized,无锁采用redis。synchronized在高并发下必然膨胀成重锁,而redis方案消耗网络IO,这些网络请求在redis端也是串行执行业务,为什么都是串行,前者和后者有三倍差异?
原因在于重锁的synchronized在公平(默认)模式除了抢到锁的线程外,其他线程做链表排队,sleep等待前一个节点执行完毕,将其唤醒,该过程涉及大量线程切换,线程在入队和唤醒之间切换,消耗大量的CPU资源。而redis方案中消耗的是网络IO,CPU是闲置的(想想为啥计算线程池核心线程数的时候,io密集,乐观情况下CPU还能在等待期间抽空做其他的事情,自然资源消耗更小。

总结

1.前半部在redis扣优惠券库存,redis扣成功就返回成功给用户,这一步redis的数据不会出问题 2.后半部接收mq,基于cas落地优惠券库存修改,一致性要求不高的场景屡试不爽

特别鸣谢

@小天狼星