この記事では、Redis のアトミック操作に関する関連知識をお届けします。同時アクセスの正確性を確保するために、Redis ではロックとアトミック操作の 2 つの方法が提供されています。皆様のお役に立てれば幸いです。
Redis を使用すると、複数のユーザーが同時に注文した場合など、同時アクセスの問題が必然的に発生します。 Redis にキャッシュされた製品在庫は同時に更新されます。同時書き込み操作が発生すると、データが変更されます。同時書き込みリクエストを制御しないと、データが修正され、通常の業務利用に影響を与える可能性があります (たとえば、在庫データのエラーにより異常な発注が発生するなど)。
同時アクセスの正確性を保証するために、Redis はロックとアトミック操作という 2 つのメソッドを提供します。
ロックは一般的な方法です。データを読み取る前に、クライアントはまずロックを取得する必要があります。取得しないと操作を実行できません。クライアントがロックを取得すると、クライアントがデータ更新を完了してロックを解放するまでロックを保持します。
これは良い解決策のように思えますが、実際には 2 つの問題があります: 1 つは、ロック操作が多い場合、システムの同時アクセスのパフォーマンスが低下することです。2 つ目は、Redis クライアントがロックするときに、分散ロックを使用する必要がありますが、分散ロックの実装は複雑で、ロックとロック解除の操作を行うために追加のストレージ システムが必要です。これについては次のレッスンで紹介します。
アトミック操作は、同時アクセス制御を提供するもう 1 つの方法です。アトミック操作とは、実行中にアトミック性を維持する操作を指し、アトミック操作の実行時に追加のロックを必要としないため、ロックのない操作が実現されます。このようにして、同時実行制御が保証され、システムの同時実行パフォーマンスへの影響を軽減できます。
同時アクセス中に何を制御する必要がありますか?
同時アクセス制御と呼ばれるものは、同じデータにアクセスして操作する複数のクライアントのプロセスを制御して、Redis インスタンスで実行されるときにクライアントから送信された操作が相互に排他的であることを保証することを指します。たとえば、クライアント A のアクセス操作が実行されている間、クライアント B の操作は実行できず、クライアント A の操作が完了するまで待つ必要があります。
同時アクセス制御に対応する操作は主にデータ変更操作です。クライアントがデータを変更する必要がある場合、基本プロセスは 2 つのステップに分かれています。
このプロセスを「読み取り-変更-書き込み」操作 (読み取り-変更-書き込み、RMW 操作と呼ばれます) と呼びます。複数のクライアントが同じデータに対して RMW 操作を実行する場合、RMW 操作に含まれるコードがアトミックに実行できるようにする必要があります。同じデータにアクセスするRMWのオペレーションコードをクリティカルセクションコードと呼びます。
ただし、複数のクライアントがクリティカルセクションのコードを同時に実行すると、潜在的な問題が発生する可能性があるため、複数のクライアントが製品在庫を更新する例を用いて説明します。
最初にクリティカル セクションのコードを見てみましょう。クライアントが製品在庫から 1 を差し引くことを希望していると仮定します。疑似コードは次のとおりです:
current = GET(id) current-- SET(id, current)
ご覧のとおり、クライアントはまず製品に基づいて Redis から製品の現在の在庫値を読み取ります。 id. (読み取りに相当) を取得すると、クライアントはインベントリ値を 1 減らして (変更に相当)、インベントリ値を Redis に書き込みます (書き込みに相当)。複数のクライアントがこのコードを実行する場合、これはクリティカル セクション コードです。
クリティカル セクションのコードの実行を制御するメカニズムがない場合、データ更新エラーが発生します。先ほどの例では、クライアント A とクライアント B の 2 台があり、クリティカル セクションのコードを同時に実行するとエラーが発生します。
クライアント A は、t1 で在庫値 10 を読み取り、1 を差し引いていることがわかります。t2 では、クライアント A は差し引かれた在庫値 9 をまだ書き込んでいません。Redis に戻り、このとき、クライアント B は在庫価額 10 を読み取り、これも 1 減算され、B によって記録された在庫価額も 9 になります。 t3 のときに、A はインベントリ値 9 を Redis にライトバックし、t4 のときに B もインベントリ値 9 をライトバックします。
正しいロジックに従って処理された場合、クライアント A と B はそれぞれ在庫値を 1 回差し引き、在庫値は 8 になるはずです。したがって、ここでの在庫金額は明らかに誤って更新されます。
この現象が発生する理由は、クリティカル セクション コード内のクライアントには、データの読み取り、データの更新、データの書き戻しという 3 つの操作が含まれており、これら 3 つの操作は実行中に相互に排他的ではないためです。前のクライアントによって変更された値に基づいて変更するのではなく、同じ初期値に基づいて変更します。
データの同時変更の正確性を保証するために、ロックを使用して並列操作を直列操作に変えることができます。直列操作は相互に排他的です。クライアントがロックを保持した後は、他のクライアントはロックが解放されるまで待つことしかできず、その後ロックを取得して変更を行うことができます。
次の疑似コードは、クリティカル セクション コードの実行を制御するためのロックの使用を示しています。ご覧ください。
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 のアトミック操作を 10 分で理解するの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。