在当前的互联网环境中,高并发业务场景十分常见,并发场景下的线程安全问题,产生的根本原因在于:多线程/多进程之间存在数据共享。
解决线程安全问题的方案很多,最根本的方法就是在系统层面采用"share-noting"的架构方式。但是很多业务场景不一定适合该种架构,或者实现起来比较困难,除此之外,比较通用的解决方案主要有以下两种:
1.将并行操作转化成串行操作,常用的实现方式:
a.加锁,使临界区资源,只能有一个线程/进程可以访问。
b.执行业务逻辑的工作线程只分配一个,这也可以从根本上防止并发问题的产生。
2.基于操作系统提供给上层应用的原子操作能力,实现"CAS"的原子操作。
以上方案各有优劣,都有各自的使用场景,这里我们不做过多比较。其实工作遇到的问题,在一些开源软件中也会遇到,在一些比较著名开源软件中又是如何解决线程安全问题呢?或者能从中学到一些知识,下面我们可以参考一下redis 对于线程安全问题的解决方案。
使用过redis的老铁都知道,在redis的线程模型中,处理读写命令的工作线程只有一个,这种处理方式符合上面解决线程问题中的方案1:将并行处理转换成串行处理。
但是有经验的老铁都清楚,串行处理不能充分利用系统资源,性能不及并行处理。这种说法也不全对,还需要看场景,在redis中,处理的数据都在内存中,数据操作效率极高,单线程的情况下,qps轻松破10w。反而在使用多线程时,为了保证线程安全,采用了一些同步机制,以及多线程的上下文切换,却对性能造成了一定的影响。
如此看来,在单线程模式下,redis的性能比较高,且可以避免多线程情况下的线程安全问题。但是在redis使用过程中,线程安全问题依旧存在,此话怎讲呢?
线程安全,是站在reids的角度来说的,redis使用单线程模型,是不存在线程安全问题的,以为他只有一个线程,不存在多线程间数据的共享,俗话说没有共享就没有伤害。
而线程不安全,是站在客户端的角度说的,redis是只有一个线程在工作,但是客户单端却是有成千上万个的,对于客户端来说,redis是被共享的资源,所以对于客户端来说依旧存在线程安全问题
防止多客户端场景下的线程问题,依旧可以使用开头提供的解决方案,比如在客户端层面使用加锁的方式,将多客户端的并行操作变成串行操作。只有一个客户端操作完后,其他客户端才能操作,所有客户端段排队处理数据。这样会明显降低数据处理效率。
这里可能有老铁会有这样的疑问,上面不是说redis单线程串行化处理数据不是很快吗,为啥将客户端的并行处理变成串行后,性能就有问题了呢?这里做一下说明,redis单线程处理性能高,是因为redis都是纯内存操作,而客户端虽然是使用redis,但是使用redis不是内存操作,会涉及到网络io,性能远远不及内存操作。
虽然上述线程安全问题不是redis造成的,但是redis却提供了一些原子操作命令,可以很好的解决某些场景下的线程安全问题。
为了更方便的说明redis的解决方案,我们先举例复现一下线程安全的问题。
我们以电商场景下的商品库存扣减的场景为例,如下图:
1.商品库存开始为10,此时客户端A和客户端B,同时下单扣减库存。
2.两个客户端从redis获取到当前库存数为10。
3.两个客户端在本地将库存数减1,然后写回redis。
4,此时redis中存库数为9,正常情况下应该是8,造成超卖问题。
以上扣减库存的操作实际上分为三步:读库存,减库存,写库存。
读库存和写库存操作,在redis中是单线程执行的,是原子性的,但是整个扣减库存的操作却不是原子性的,这也是出现线程不安全的根本原因。
对于解决这个问题,redis提供了一些复合命令,将多个操作合并成一个操作命令,此时这个复合命令就变成一个原子操作,也就不会再出现上述的线程安全问题了。
这里的复合操作就是 INCR/DECR,这两个操作就是对数值进行递减/递增,也就是上述的操作中:获取数据,加/减1,写入数据。只不过使用INCR和DECR命令,可以将这三个操作合并在一个命令中了,这个命令在redis中可以原子性执行,从而保证了线程安全。
虽然redis提供的原子操作可能很好的解决线程安全问题,但是功能比较简单,使用场景有限。对于一些复杂的业务需求,如果也需要保证线程安全,那有没有好的方法呢?
这里我们也举例说明一下:商场对商品促销,售卖的前20个商品打8折,超过20后保持原价。具体见如下伪代码:
cnt = get(count);
if(cnt > 20) {
price = 原价 * 0.8
INCR count
} else {
price = 原价
}
do things.
对于上述业务,要想保证逻辑的正确性,需要确保上述整个代码的执行具有原子性,否则高并发的场景下很容易产生并发问题。
在redis中可以使用Lua脚本,将复杂业务逻辑放到Lua脚本中,redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。
如何在redis中使用lua脚本,可以参考https://redis.io/commands/eval/
Lua脚本之所以可以保证线程安全,是因为redis使用同一个Lua解释器来执行所有命令,同时,redis保证以一种原子性的方式来执行脚本:当Lua脚本在执行的时候,不会有其他脚本和命令同时执行,这种语义类似于 MULTI/EXEC。从别的客户端的视角来看,一个lua脚本要么不可见,要么已经执行完。
虽然Lua脚本好用,但是也不要滥用,虽然,Lua脚本的开销非常低,构造一个快速执行的脚本并非难事。但是当正在执行一个比较慢的脚本时,所以其他的客户端都无法执行命令,所以在Lua脚本中避免把不需要做并发控制的操作写入到脚本中。
除此之外,Lua脚本只能保证原子性,不保证事务性,当Lua脚本遇到异常时,已经执行过的逻辑是不会回滚的。