锁争用的缺点方法
在开始先容库存争用的详细办理方案之前,我们先理解一个小知识——并发库存锁。还记得我学打算机的时候,老师曾经演示过一段代码:
复制

public class ThreadCounter {private static int count = 0;public static void main(String[] args) throws Exception { Runnable task = new Runnable() {public void run() {for (int i = 0; i < 1000; ++i) { count += 1; } } }; Thread t1 = new Thread(task); t1.start(); Thread t2 = new Thread(task); t2.start(); t1.join(); t2.join(); cout << "count = " << count << endl; }}
1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.
从代码来看,我们运行后的结果估量是2000,但实际运行后却不是。为什么会发生这种情形?当多个线程并行读写同一个公共变量时,由于不存在互斥,多个线程的凑集会相互覆盖或者读取时很随意马虎读到其他线程刚刚写入的数据的一半,从而导致变量数据被破坏。另一方面,如果我们要担保多线程并发下某个变量的准确性,就须要该变量在修正期间不会被其他线程变动或读取。对付这种情形,我们一样平常会利用锁或者原子操作来保护库存变量:如果是大略的int类型数据,可以利用原子操作来担保数据的准确;如果是繁芜的数据构造或多步操作,可以通过加锁来担保数据的完全性。
考虑到我们之前的习气会有一定的惯性,为了帮助大家更好的理解竞争,这里再举一个我们常常犯缺点的例子。由于扣除库存的操作须要把稳原子性,因此我们在实践中常常会碰着以下方法:
复制
redis> get prod_1475_stock_115redis> set prod_1475_stock_1 14OK
1.2.3.4.
也便是说,先将变量从缓存中取出,对其进行-1操作,然后再放回缓存中。这是缺点的做法。
图片
如上图所示,缘故原由是多个线程一起读的时候,同时读到了5,设置回去的时候,都读到了6。实在每个线程都拿到了库存,但是实际值存货数量累计未发生变革。这可能会导致库存超卖。如果须要这样做,一样平常建议添加自旋互斥锁,以打消其他线程进行类似操作。
原子操作
昔时夜量用户并发修正变量时,利用互斥锁可以担保数据修正的精确性,但性能很低。假设有10000个用户竞争锁,排队等待修正做事器中的变量。这种设计效率极低。获取过程中锁须要自旋,可以多次考试测验抢锁。用户越多,竞争就越激烈,对系统资源的花费就越大,可能会导致系统不稳定。
为理解决这个问题,我会选择将库存数据存储在高性能内存缓存做事(如Redis)中进行集中管理。这样可以避免用户竞争锁时影响其他做事,也可以提高相应速率。 Redis本身支持原子操作,可以更好的应对高并发场景。这也是互联网行业常用的库存保护方案。
相反,不建议通过数据库行锁来保护库存修正。数据库资源非常宝贵。如果利用行锁来管理库存,不仅性能较差,而且系统也随意马虎变得不稳定。
为了减少锁竞争,提高系统效率,我们可以降落锁粒度或者引入其他优化方案。
图片
事实上,我们可以通过拆分热门物品的库存并将其存储在多个密钥中来显著减少锁竞争。例如,假设当前商品库存为100,则库存可以分为10个key,每个key存储10个库存,这些key分布在不同的Redis实例中。用户下单时,可以随机选择一个按键来扣除库存。如果某个钥匙的库存不敷,则记录该钥匙,并随机选择剩余的钥匙进行扣减,直至顺利完成库存扣减。
不过,除了这个方法之外,我还推举利用Redis的原子操作来处理库请安题。原子操作的粒度较小,Redis 实质上运行在单个线程上,供应全局唯一的决策。很多原子操作的底层实现乃至是用硬件实现的,性能精良,而且不存在锁争用问题。
以Redis的INCR和DECR操作为例。它们可以实现对库存的精确修正而无需锁定,从而同时担保高性能和数据同等性。
复制
redis> decr prod_1475_stock_114
1.2.
incr 和 decr 等操作是原子操作。我们可以根据返回值是否大于0来判断库存扣减是否成功。但是这里要把稳,如果当前值已经为负值,我们须要考虑是否退回之前扣除的补偿。并且为了减少修正操作,我们可以在推导之前做一个值检测。整体操作如下:
复制
//读取当前库存,确认是否大于零//如大于零则连续操作,小于即是谢绝后续redis> get prod_1475_stock_11//开始扣减库存、如返回值大于或即是0那么代表扣减成功,小于0代表当前已经没有库存//可以看到返回-2,这可以理解成同时两个线程都在操作扣库存,并且都没拿到库存redis> decr prod_1475_stock_1-2//扣减失落败、补偿多扣的库存//这里返回0是由于同时两个线程都在做补偿,终极规复0库存redis> incr prod_1475_stock0
1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.
这种库存保护选项确实很有代价,但它也有其局限性。库存准确性取决于企业成功“返还”先前扣除的库存的能力。如果在做事运行时“返回”操作被中断,则手动修复将变得非常困难,由于无法确定当前正在处理多少库存。常日,我们须要等到活动结束后才能查看终极库存。
为了彻底担保库存不丢失,常日须要依赖事务和回滚机制。但Redis作为外部库存做事,不属于数据库的缓存范畴。这就哀求我们在每个可能涌现故障的业务环节,妥善处理库请安题。因此,很多闪购系统在发生故障时每每会选择不退回库存,不是由于他们不想,而是由于在很多意想不到的情形下他们无法这样做。
至于利用SETNX指令或者数据库的CAS( and Swap)机制来实现互斥锁,虽然这样可以办理库请安题,但是这种锁机制会引入自旋等待。在高并发情形下,用户做事须要反复考试测验获取锁,摧残浪费蹂躏系统资源,同时给数据做事带来较大压力。因此,不推举这种方法。
代币库存
除了这种用数值记录库存的办法之外,还有一种更科学的“发行代币”的办法。这种办法可以避免之前由于抢货而涌现负库存的情形。
图片
详细来说,Redis中的列表用于保存多个token来表示库存。一个代币便是一份库存。抢库存时得到代币的用户可以连续支付:
复制
//放入三个库存redis> lpush prod_1475_stock_queue_1 stock_1redis> lpush prod_1475_stock_queue_1 stock_2redis> lpush prod_1475_stock_queue_1 stock_3//取出一个,超过0.5秒没有返回,那么抢库存失落败redis> brpop prod_1475_stock_queue_1 0.5
1.2.3.4.5.6.7.8.
当库存抢购失落败时,用户只会收到零。这种实现办法确实可以避免涌现故障后必须补偿库存的问题。然而,即便如此,如果我们的业务代码在非常处理上不完善,库存丢失的情形仍旧可能发生。同时,如果须要从行列步队中取出令牌,可以利用brpop进行壅塞等待,而利用rpop在压力测试场景中性能更好,由于不须要等待。
然而,当库存数量非常大时,代币办法可能不适宜。例如,如果有10,000个库存,则须要向Redis列表中插入10,000个令牌;如果有10万个库存,则必须连续插入10万个字符串,这会导致Redis在存储期间涌现大量中断。代顿。
至此,库存设计彷佛已经比较完全了,但如果产品团队提出“一种产品可以一次抢多库存”的需求(比如一次抢两袋米),这就办理方案可能无法知足哀求。由于我们依赖多个锁来减少争用,并且一次扣除多个库存会使设计变得繁芜,以是锁争用问题仍旧存在。
多库存闪购
事实上这种情形常常发生,这让我们对之前的优化有了更多的想法。对付同时闪购多只股票的情形,我们的设计须要做一些调度。
图片
之前,为了减少锁竞争,我们将库存分成10个不同的密钥并随机获取。但是,如果存在库存只剩下末了几种产品的极度情形,并且用户一次发卖了三种产品(如上例所示),则他可能须要考试测验所有库存密钥。终极考试测验了10把钥匙后,可能只有两把库存能够成功扣除。这时我们就面临一个选择:谢绝用户的订单,还是退回库存?
这取决于产品的设计思想。同时,我们还须要添加一个检测机制:如果库存已经售完,则停滞考试测验获取10个库存密钥。毕竟,如果没有库存,每个要求都会刷新Redis 10次,这会给Redis做事带来更大的压力。虽然Redis的O(1)操作理论上可以达到每秒10万次操作(OPS),但一个要求会刷新10次。空想情形下,抢购库存的接口性能约为每秒 10,000 个要求(QPS)。实际压测后,建议按照实测性能的70%进行漏斗式限流。
大家该当把稳到了,在“一种产品抢多库存”的场景下,库存拆分方案并没有减少拼锁次数,反而增加了掩护的繁芜度。当库存越来越少时,系统性能会明显低落,这样的设计已经不符合我们最初的目的了(这种因业务需求而导致的设计错位问题在实际项目中很常见,须要我们更深入的理解)产品哀求)。
那么,该当如何优化呢?我们可以考虑将原来的10个分割键合并为1个,然后利用rpop来实现多个库存的扣减操作。对付库存不敷的情形(例如用户想购买3件商品但库存只剩下2件),产品方须要给出是否连续处理交易的明确建议。同时,在每次操作开始时,可以利用LLEN(O(1)操作)来检讨列表中是否有足够的库存来支持rpop。
复制
//取之前看一眼库存是否空了,空了不连续了(llen O(1))redis> llen prod_1475_stock_queue3//取出库存3个,实际抢到俩redis> rpop prod_1475_stock_queue 3"stock_1""stock_2"//产品说数量不足,不许可连续交易,将库存返还 redis> lpush prod_1475_stock_queue stock_1redis> lpush prod_1475_stock_queue stock_2
1.2.3.4.5.6.7.8.9.10.11.12.13.14.
通过这样的设计,我们大大降落了排序系统的锁竞争压力。要知道Redis是一个性能非常好的缓存做事。其 O(1) 繁芜度指令在利用长链接的多线程中进行了测试。 5.0版本的Redis可以运行到100,000 OPS,而6.0版本的网络性能会更好。这种利用Redis原子操作来减少锁冲突的方法对付各种措辞来说都是常见且大略的。不过要把稳不要将Redis做事与繁芜的业务逻辑混在一起,否则会影响我们库存接口的效率。
自旋互斥锁超时
如果我们在库存竞争时须要操作多个决策键来完成竞争,那么原子方法就不适宜了。由于原子操作的粒度太小,无法以事务办法掩护多个数据的ACID。这种多步操作适宜利用自旋互斥锁来实现。不过流量大的时候不建议利用这种方法,由于它的核心是如果我们要担保用户体验,就须要逻辑代码多次循环来抢锁。 ,直到得到锁,如下:
复制
//业务逻辑须要循环抢锁,如循环10次,每次sleep 10ms,10次失落败后返回失落败给用户//获取锁后设置超时时间,防止进程崩溃后没有开释锁导致问题//如果获取锁失落败会返回nilredis> set prod_1475_stock_lock EX 60 NXOK//抢锁成功,扣减库存redis> rpop prod_1475_stock_queue 1"stock_1"//扣减数字库存,用于展示redis> decr prod_1475_stock_13// 开释锁redis> del prod_1475_stock_lock
1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.
图片
这种方法的缺陷是,抢锁阶段排队抢锁的线程越多,等待韶光就会越长。其余,由于多个线程一起循环检讨,高并发时Redis的压力会很大。如果有100人下单时,100个线程会每10ms检讨一次。此时Redis操作数为:
100 个线程 × (÷10ms) 次 =
CAS乐不雅观锁:锁操作是后缀的
另一种推举的方法是利用 CAS(和 Swap)乐不雅观锁定。与自旋互斥锁比较,当并发抢股的线程较少时,CAS 乐不雅观锁的效率更高。传统的锁机制是先获取锁,然后再对数据进行操作。在这个过程中,抢锁本身就会花费性能。纵然没有其他线程参与竞争,抢锁的开销仍旧存在。
CAS乐不雅观锁的核心是记录或监控当前的库存信息或版本号,并在此根本上进行预操作。当线程准备修正数据时,系统会首先检讨当前库存版本号是否与预期同等。如果同等则修正成功;否则,重新读取最新数据并重试。这种办法可以避免锁竞争带来的性能丢失,在低并发场景下更有上风。
图片
如上图所示,如果在操作过程中创造监控值发生了变革,那么就会回滚之前的操作;如果期间没有变革,则提交交易完成操作。操作期间的所有操作都是事务性的。
复制
//开缘由务redis> multiOK// watch 修正值// 在exec期间如果涌现其他线程修正,那么会自动失落败回滚实行discardredis> watch prod_1475_stock_queue prod_1475_stock_1//事务内对数据进行操作redis> rpop prod_1475_stock_queue 1QUEUED//操作步骤2redis> decr prod_1475_stock_1QUEUED//实行之前所有操作步骤//multi 期间 watch有数值有变革则会回滚redis> exec3
1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.
可以看到,通过这种方法我们可以快速实现批量减少库存,并显著减少锁竞争韶光。它的优点,正如我们刚才所说,当竞争线程很少时,它特殊高效,但是当竞争线程很多时,它会须要大量的重试。不过,即便如此,CAS 乐不雅观锁也会比利用自旋锁具有更好的性能。利用此方法时,我建议尽可能少的内部步骤。同时须要把稳的是,如果Redis是模式,利用multi时,必须在一个slot内,以担保原子性。
Redis Lua实现Redis锁的方法
另一种类似“事务+乐不雅观锁”的实现办法是利用Redis的Lua脚本来进行多步盘点操作。由于Redis内Lua脚本的实行是连续且原子的,因此所有操作都不会被其他操作打断,从而避免了锁争用问题。
其余,Lua脚本可以根据不同的情形灵巧处理不同的操作。商家只须要调用指定的Lua脚本并通报干系参数即可实现高性能的库存扣减。这不仅提高了操作的原子性,还显著减少了等待多个要求带来的RTT(来回韶光),提高了全体系统的相应速率和并发处理能力。
为了方便演示如何实行Lua脚本,我利用了PHP实现:
复制
<?php$script = <<<EOF// 获取当前库存个数local stock=tonumber(redis.call('GET',KEYS[1])); //没找到返回-1if stock==nil thenreturn -1; end //找到了扣减库存个数local result=stock-ARGV[1]; //如扣减后少于指定个数,那么返回0if result<0 thenreturn 0; else //如果扣减后仍旧大于0,那么将结果放回Redis内,并返回1 redis.call('SET',KEYS[1],result); return 1; endEOF;$redis = new \Redis();$redis->connect('127.0.0.1', 6379);$result = $redis->eval($script, array("prod_stock", 3), 1);echo $result;
1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.
这样我们就可以远程注入各种连贯逻辑的操作,实现一些库存补货操作。
总结
图片
鱼云专注于供应高性能云做事器和物理做事器租赁做事。我们致力于为企业供应安全、稳定、高效的办理方案,确保数据无忧、业务顺畅。