這篇文章為大家帶來了關於redis原子操作的相關知識,為了確保並發訪問的正確性,Redis 提供了兩種方法,分別是加鎖和原子操作,希望對大家有幫助。
我們在使用Redis 時,不可避免地會遇到並發存取的問題,比如說如果多個使用者同時下單,就會對緩存在Redis 中的商品庫存並發更新。一旦有了並發寫操作,資料就會被修改,如果我們沒有對並發寫請求做好控制,就可能導致資料被改錯,影響到業務的正常使用(例如庫存資料錯誤,導致下單異常)。
為了確保並發存取的正確性,Redis 提供了兩種方法,分別是加鎖和原子操作。
加鎖是常用的方法,在讀取資料前,客戶端需要先取得鎖,否則就無法進行操作。當一個客戶端取得鎖後,就會一直持有這把鎖,直到客戶端完成資料更新,才釋放這把鎖。
看起來好像是個很好的方案,但是,其實這裡會有兩個問題:一個是,如果加鎖操作多,會降低系統的並發存取效能;第二個是,Redis 用戶端要加鎖時,需要用到分散式鎖,而分散式鎖實現複雜,需要用額外的儲存系統來提供加解鎖操作,我會在下節課向你介紹。
原子操作是另一種提供並發存取控制的方法。原子操作是指執行過程保持原子性的操作,原子操作執行時並不需要再加鎖,實現了無鎖操作。這樣一來,既能確保並發控制,還能減少對系統並發效能的影響。
並發存取中需要對什麼進行控制?
我們說的並發存取控制,是指對多個客戶端存取操作同一份資料的過程進行控制,以保證任何一個客戶端發送的操作在 Redis 實例上執行時具有互斥性。例如,客戶端 A 的存取操作在執行時,客戶端 B 的操作不能執行,需要等到 A 的操作結束後,才能執行。
並發存取控制對應的操作主要是資料修改操作。當客戶端需要修改資料時,基本流程分成兩個步驟:
我們把這個流程叫做「讀取 - 修改 - 寫回」操作(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,同時執行剛才的臨界區程式碼,就會出現錯誤,你可以看下面這張圖。
可以看到,客戶端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 能访问共享数据,系统的并发性能当然就下降了。
和加锁类似,原子操作也能实现并发控制,但是原子操作对系统并发性能的影响较小,接下来,我们就来了解下 Redis 中的原子操作。
Redis 的两种原子操作方法
为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:
我们先来看下 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 > 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 脚本,如下所示:
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 进行传递。
redis-cli --eval lua.script keys , args
这样一来,访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作就可以原子性地执行了。即使客户端有多个线程同时执行这个脚本,Redis 也会依次串行执行脚本代码,避免了并发操作带来的数据错误。
推荐学习:《Redis视频教程》、《2022最新redis面试题大全及答案》
以上是十分鐘搞懂redis原子操作的詳細內容。更多資訊請關注PHP中文網其他相關文章!