<audio id="audio" title="29 | 无锁的原子操作：Redis如何应对并发访问？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/84/e3/846079205efc98381146183fa72df4e3.mp3"></audio>

你好，我是蒋德钧。

我们在使用Redis时，不可避免地会遇到并发访问的问题，比如说如果多个用户同时下单，就会对缓存在Redis中的商品库存并发更新。一旦有了并发写操作，数据就会被修改，如果我们没有对并发写请求做好控制，就可能导致数据被改错，影响到业务的正常使用（例如库存数据错误，导致下单异常）。

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

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

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

**原子操作是另一种提供并发访问控制的方法**。原子操作是指执行过程保持原子性的操作，而且原子操作执行时并不需要再加锁，实现了无锁操作。这样一来，既能保证并发控制，还能减少对系统并发性能的影响。

这节课，我就来和你聊聊Redis中的原子操作。原子操作的目标是实现并发访问控制，那么当有并发访问请求时，我们具体需要控制什么呢？接下来，我就先向你介绍下并发控制的内容。

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

我们说的并发访问控制，是指对多个客户端访问操作同一份数据的过程进行控制，以保证任何一个客户端发送的操作在Redis实例上执行时具有互斥性。例如，客户端A的访问操作在执行时，客户端B的操作不能执行，需要等到A的操作结束后，才能执行。

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

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

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

不过，当有多个客户端并发执行临界区代码时，就会存在一些潜在问题，接下来，我用一个多客户端更新商品库存的例子来解释一下。

我们先看下临界区代码。假设客户端要对商品库存执行扣减1的操作，伪代码如下所示：

```
current = GET(id)
current--
SET(id, current)

```

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

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

<img src="https://static001.geekbang.org/resource/image/dc/5c/dce821cd00c1937b4aab1f130424335c.jpg" alt="">

可以看到，客户端A在t1时读取库存值10并扣减1，在t2时，客户端A还没有把扣减后的库存值9写回Redis，而在此时，客户端B读到库存值10，也扣减了1，B记录的库存值也为9了。等到t3时，A往Redis写回了库存值9，而到t4时，B也写回了库存值9。

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

出现这个现象的原因是，临界区代码中的客户端读取数据、更新数据、再写回数据涉及了三个操作，而这三个操作在执行时并不具有互斥性，多个客户端基于相同的初始值进行修改，而不是基于前一个客户端修改后的值再修改。

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

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

```
LOCK()
current = GET(id)
current--
SET(id, current)
UNLOCK()

```

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

如下图所示，当客户端A加锁执行操作时，客户端B、C就需要等待。A释放锁后，假设B拿到锁，那么C还需要继续等待，所以，t1时段内只有A能访问共享数据，t2时段内只有B能访问共享数据，系统的并发性能当然就下降了。

<img src="https://static001.geekbang.org/resource/image/84/25/845b4694700264482d64a3dbb7a36525.jpg" alt="">

和加锁类似，原子操作也能实现并发控制，但是原子操作对系统并发性能的影响较小，接下来，我们就来了解下Redis中的原子操作。

## Redis的两种原子操作方法

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

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

我们先来看下Redis本身的单命令操作。

Redis是使用单线程来串行处理客户端的请求操作命令的，所以，当Redis执行某个命令操作时，其他命令是无法执行的，这相当于命令操作是互斥执行的。当然，Redis的快照生成、AOF重写这些操作，可以使用后台线程或者是子进程执行，也就是和主线程的操作并行执行。不过，这些操作只是读取数据，不会修改数据，所以，我们并不需要对它们做并发控制。

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

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

比如说，在刚才的库存扣减例子中，客户端可以使用下面的代码，直接完成对商品id的库存值减1操作。即使有多个客户端执行下面的代码，也不用担心出现库存值扣减错误的问题。

```
DECR id 

```

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

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

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

我再给你举个例子，来具体解释下Lua的使用。

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

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

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

```
//获取ip对应的访问次数
current = GET(ip)
//如果超过访问次数超过20次，则报错
IF current != NULL AND current &gt; 20 THEN
    ERROR &quot;exceed 20 accesses per second&quot;
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脚本，如下所示：

```
local current
current = redis.call(&quot;incr&quot;,KEYS[1])
if tonumber(current) == 1 then
    redis.call(&quot;expire&quot;,KEYS[1],60)
end

```

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

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

```

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

## 小结

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

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

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

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

当然，加锁也能实现临界区代码的互斥执行，只是如果有多个客户端加锁时，就需要分布式锁的支持了。所以，下节课，我就来和你聊聊分布式锁的实现。

## 每课一问

按照惯例，我向你提个小问题，Redis在执行Lua脚本时，是可以保证原子性的，那么，在我举的Lua脚本例子（lua.script）中，你觉得是否需要把读取客户端ip的访问次数，也就是GET(ip)，以及判断访问次数是否超过20的判断逻辑，也加到Lua脚本中吗？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事。我们下节课见。
