Redis-16-无锁原子操作,如何应对并发

==作者:YB-Chi==

[toc]

简介

一旦有了并发写操作,数据就会被修改,如果我们没有对并发写请求做好控制,就可能导致数据被改错,影响到业务的正常使用(例如库存数据错误,导致下单异常)。

为了保证并发访问的正确性,Redis提供了两种方法,分别是加锁原子操作

  1. 加锁是一种常用的方法,在读取数据前,客户端需要先获得锁,否则就无法进行操作。当一个客户端获得锁后,就会一直持有这把锁,直到客户端完成数据更新,才释放这把锁。

    看上去好像是一种很好的方案,但是,其实这里会有两个问题:一个是,如果加锁操作多,会降低系统的并发访问性能;第二个是,Redis客户端要加锁时,需要用到分布式锁,而分布式锁实现复杂,需要用额外的存储系统来提供加解锁操作。

  2. 原子操作是另一种提供并发访问控制的方法。原子操作是指执行过程保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。这样一来,既能保证并发控制,还能减少对系统并发性能的影响。原子操作的目标是实现并发访问控制,那么当有并发访问请求时,我们具体需要控制什么呢?

并发访问中需要对什么进行控制?

并发访问控制对应的操作主要是数据修改操作。当客户端需要修改数据时,基本流程分成两步:

  1. 客户端先把数据读取到本地,在本地进行修改;
  2. 客户端修改完数据后,再写回Redis。

我们把这个流程叫做“读取-修改-写回”操作(Read-Modify-Write,简称为RMW操作)。当有多个客户端对同一份数据执行RMW操作的话,我们就需要让RMW操作涉及的代码以原子性方式执行。访问同一份数据的RMW操作代码,就叫做临界区代码。

不过,当有多个客户端并发执行临界区代码时,就会存在一些潜在问题。我们先看下临界区代码。假设客户端要对商品库存执行扣减1的操作,伪代码如下所示:

1
2
3
current = GET(id)
current--
SET(id, current)

可以看到,客户端首先会根据商品id,从Redis中读取商品当前的库存值current(对应Read),然后,客户端对库存值减1(对应Modify),再把库存值写回Redis(对应Write)。当有多个客户端执行这段代码时,这就是一份临界区代码。

如果我们对临界区代码的执行没有控制机制,就会出现数据更新错误。在刚才的例子中,假设现在有两个客户端A和B,同时执行刚才的临界区代码,就会出现错误。

image

如果按正确的逻辑处理,客户端A和B对库存值各做了一次扣减,库存值应该为8。所以,这里的库存值明显更新错了。

为了保证数据并发修改的正确性,我们可以用锁把并行操作变成串行操作,串行操作就具有互斥性。一个客户端持有锁后,其他客户端只能等到锁释放,才能拿锁再进行修改。

下面的伪代码显示了使用锁来控制临界区代码的执行情况。

1
2
3
4
5
LOCK()
current = GET(id)
current--
SET(id, current)
UNLOCK()

虽然加锁保证了互斥性,但是加锁也会导致系统并发性能降低

img

Redis的两种原子操作方法

为了实现并发控制要求的临界区代码互斥执行,Redis的原子操作采用了两种方法:

  1. 把多个操作在Redis中实现成一个操作,也就是单命令操作;
  2. 把多个操作写到一个Lua脚本中,以原子性方式执行单个Lua脚本。

单命令操作

你可能也注意到了,虽然Redis的单个命令操作可以原子性地执行,但是在实际应用中,数据修改时可能包含多个操作,至少包括读数据、数据增减、写回数据三个操作,这显然就不是单个命令操作了,那该怎么办呢?

别担心,Redis提供了INCR/DECR命令,把这三个操作转变为一个原子操作了。INCR/DECR命令可以对数据进行增值/减值操作,而且它们本身就是单个命令操作,Redis在执行它们时,本身就具有互斥性。

1
DECR id 

所以,如果我们执行的RMW操作是对数据进行增减值的话,Redis提供的原子操作INCR和DECR可以直接帮助我们进行并发控制。

但是,如果我们要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,那么,Redis的单命令操作已经无法保证多个操作的互斥执行了。所以,这个时候,我们需要使用第二个方法,也就是Lua脚本。

Lua脚本

Redis会把整个Lua脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了Lua脚本中操作的原子性。如果我们有多个操作要执行,但是又无法用INCR/DECR这种命令操作来实现,就可以把这些要执行的操作编写到一个Lua脚本中。然后,我们可以使用Redis的EVAL命令来执行脚本。这样一来,这些操作在执行时就具有了互斥性。

当一个业务应用的访问用户增加时,我们有时需要限制某个客户端在一定时间范围内的访问次数,比如爆款商品的购买限流、社交网络中的每分钟点赞次数限制等。

那该怎么限制呢?我们可以把客户端IP作为key,把客户端的访问次数作为value,保存到Redis中。客户端每访问一次后,我们就用INCR增加访问次数。

不过,在这种场景下,客户端限流其实同时包含了对访问次数和时间范围的限制,例如每分钟的访问次数不能超过20。所以,我们可以在客户端第一次访问时,给对应键值对设置过期时间,例如设置为60s后过期。同时,在客户端每次访问时,我们读取客户端当前的访问次数,如果次数超过阈值,就报错,限制客户端再次访问。你可以看下下面的这段代码,它实现了对客户端每分钟访问次数不超过20次的限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//获取ip对应的访问次数
current = GET(ip)
//如果超过访问次数超过20次,则报错
IF current != NULL AND current > 20 THEN
ERROR "exceed 20 accesses per second"
ELSE
//如果访问次数不足20次,增加一次访问计数
value = INCR(ip)
//如果是第一次访问,将键值对的过期时间设置为60s后
IF value == 1 THEN
EXPIRE(ip,60)
END
//执行其他操作
DO THINGS
END

可以看到,在这个例子中,我们已经使用了INCR来原子性地增加计数。但是,客户端限流的逻辑不只有计数,还包括访问次数判断和过期时间设置

对于这些操作,我们同样需要保证它们的原子性。否则,如果客户端使用多线程访问,访问次数初始值为0,第一个线程执行了INCR(ip)操作后,第二个线程紧接着也执行了INCR(ip),此时,ip对应的访问次数就被增加到了2,我们就无法再对这个ip设置过期时间了。这样就会导致,这个ip对应的客户端访问次数达到20次之后,就无法再进行访问了。即使过了60s,也不能再继续访问,显然不符合业务要求。

所以,这个例子中的操作无法用Redis单个命令来实现,此时,我们就可以使用Lua脚本来保证并发控制。我们可以把访问次数加1、判断访问次数是否为1,以及设置过期时间这三个操作写入一个Lua脚本,如下所示:

1
2
3
4
5
local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
redis.call("expire",KEYS[1],60)
end

假设我们编写的脚本名称为lua.script,我们接着就可以使用Redis客户端,带上eval选项,来执行该脚本。脚本所需的参数将通过以下命令中的keys和args进行传递。

1
redis-cli  --eval lua.script  keys , args

这样一来,访问次数加1、判断访问次数是否为1,以及设置过期时间这三个操作就可以原子性地执行了。即使客户端有多个线程同时执行这个脚本,Redis也会依次串行执行脚本代码,避免了并发操作带来的数据错误。

小结

在并发访问时,并发的RMW操作会导致数据错误,所以需要进行并发控制。所谓并发控制,就是要保证临界区代码的互斥执行。

Redis提供了两种原子操作的方法来实现并发控制,分别是单命令操作和Lua脚本。因为原子操作本身不会对太多的资源限制访问,可以维持较高的系统并发性能。

但是,单命令原子操作的适用范围较小,并不是所有的RMW操作都能转变成单命令的原子操作(例如INCR/DECR命令只能在读取数据后做原子增减),当我们需要对读取的数据做更多判断,或者是我们对数据的修改不是简单的增减时,单命令操作就不适用了。

而Redis的Lua脚本可以包含多个操作,这些操作都会以原子性的方式执行,绕开了单命令操作的限制。不过,如果把很多操作都放在Lua脚本中原子执行,会导致Redis执行脚本的时间增加,同样也会降低Redis的并发性能。所以,我给你一个小建议:在编写Lua脚本时,你要避免把不***需要***做并发控制的操作写入脚本中

当然,加锁也能实现临界区代码的互斥执行,只是如果有多个客户端加锁时,就需要分布式锁的支持了。

摘选自:极客时间-Redis核心技术与实战

文章作者: CYBSKY
文章链接: https://cybsky.top/2022/10/27/cyb-mds/module/Redis/Redis-16-无锁原子操作,如何应对并发/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 CYBSKY