一次 Redis SETNX 的小插曲,我重新理解了原子性
最近项目里用 Redis 做并发控制时,我碰到了 SETNX。
它的行为很好理解。
redis
SETNX key value
当 key 不存在时写入成功,已经存在时写入失败。
所以多个请求同时操作同一个 key 时,可以得到这样的结果。
text
请求 A -> SETNX -> 成功
请求 B -> SETNX -> 失败
这很适合用来做抢占式的并发控制。
看到这里时,我突然产生了一个疑问。
Redis 本身已经有 EXISTS 和 SET。
那我自己先判断一次,再决定要不要写,不也能完成同样的事情吗?
text
EXISTS key
如果不存在
SET key value
而且 EXISTS 是原子操作,SET 也是原子操作。
既然两条命令分别都是原子的,为什么还需要 SETNX?
这个问题让我在 SETNX 上停了挺久。
两个原子操作为什么还会出问题
先假设现在有两个请求 A 和 B。
两个请求都执行下面的逻辑。
java
if (!exists(key)) {
set(key, value);
}
key 一开始不存在。
一种完全合法的执行顺序是这样的。
text
A -> EXISTS -> key 不存在
B -> EXISTS -> key 不存在
A -> SET key A
B -> SET key B
两个 EXISTS 都正常执行完了。
两个 SET 也都正常执行完了。
整个过程中,没有任何一条 Redis 命令执行到一半被另一条命令打断。
问题出现在两条命令之间。
A 执行完 EXISTS 以后,需要再向 Redis 发一次 SET。
在这个间隔里,B 可以执行自己的 EXISTS。
所以 A 和 B 都拿到了同一个判断结果。
text
key 不存在
接下来两个请求都会执行 SET。
普通 SET 不要求 key 必须不存在,因此两个请求都会成功,只是后执行的写入会覆盖前一次写入。
这时我才把之前混在一起的两个概念拆开。
text
EXISTS
可以是一个原子操作。
text
SET
也可以是一个原子操作。
但它们组合成下面这段流程以后
text
EXISTS
|
| 这里允许其他命令执行
v
SET
整个流程并没有得到同一个原子边界的保护。
我后来用"两道门"把它想通了
可以把 EXISTS 看成第一道门,把 SET 看成第二道门。
A 和 B 都要经过这两道门。
第一道门一次只允许一个请求完整通过,第二道门也是一样。
但两道门之间还有一段空间。
所以顺序可以变成
text
A 通过 EXISTS
B 通过 EXISTS
A 通过 SET
B 通过 SET
每一道门的规则都没有被破坏。
只是没人规定 A 过完第一道门以后,必须连续通过第二道门,B 才能进来。
我之前对"原子"的理解就卡在这里。
我把"每个步骤都是原子的"理解成了"整段流程也是原子的"。
这两件事没有直接的等价关系。
SETNX 相当于把两道门合在一起
再回头看 SETNX 就容易理解了。
redis
SETNX key value
它需要完成两个动作。
text
检查 key 是否存在
如果不存在则写入
区别在于,这两个动作属于同一条 Redis 命令的语义。
从外部来看,可以把它理解成这样。
text
┌──────────────────────────┐
│ 检查 key + 不存在则写入 │
└──────────────────────────┘
A 开始执行这条命令之后,B 无法插进 A 的"检查"和"写入"之间。
因此两个请求同时执行 SETNX 时,会形成这样的顺序。
text
A -> SETNX
检查不存在
写入成功
命令结束
B -> SETNX
检查已经存在
写入失败
A 成功写入之后,B 才能看到这个 key。
所以不会出现下面这种情况。
text
A -> 检查不存在
B -> 检查不存在
A -> 写
B -> 写
这也是我后来对 SETNX 最直观的理解。
原来的
text
EXISTS + SET
是两道独立的门。
SETNX 把"判断是否存在"和"条件写入"放进了同一道门。
如果 EXISTS 后面还是 SETNX 呢
顺着这个问题继续想,还会出现一种写法。
text
EXISTS key
如果不存在
SETNX key value
假设 A 和 B 都先执行了 EXISTS。
text
A -> EXISTS -> 不存在
B -> EXISTS -> 不存在
随后两边继续执行 SETNX。
text
A -> SETNX -> 成功
B -> SETNX -> 失败
结果仍然是安全的。
因为最终决定谁可以写进去的操作还是 SETNX。
前面的 EXISTS 没有帮忙解决并发冲突,只增加了一次查询。
所以这种需求直接执行
redis
SETNX key value
就够了。
做锁时为什么又经常看到 SET NX EX
继续用于分布式锁时,还会碰到一个类似的问题。
如果只执行
redis
SETNX lock:key value
请求获得锁以后程序异常退出,没有执行删除操作,这个 key 就可能一直存在。
于是需要给锁增加过期时间。
如果拆成下面两条命令
redis
SETNX lock:key value
EXPIRE lock:key 30
又会留下命令之间的间隔。
可能刚执行完 SETNX,程序就在执行 EXPIRE 之前退出。
因此实际使用时可以把 NX 和过期时间一起交给 SET。
redis
SET lock:key value NX EX 30
其中 NX 表示 key 不存在时才写入,EX 30 表示 30 秒后过期。
这样一次命令完成了条件写入和过期时间设置。
这次我重新理解了几个定义
这次问题最后留下来的东西,比记住 SETNX 这条命令更有用。
原子操作
一个操作对外表现为不可再插入其他操作的执行单元。
例如一次 SETNX 执行期间,其他 Redis 命令不会进入它内部的条件判断与写入过程。
原子边界
原子性总要对应一个范围。
text
操作 A
是一个原子操作,
text
操作 B
也是一个原子操作,
不能因此直接推出
text
A + B
也是一个原子操作。
A 和 B 之间如果允许其他操作执行,它们就处在两个不同的原子边界里。
竞态条件
多个执行者操作共享状态时,结果受到实际执行顺序影响,就可能产生竞态条件。
EXISTS + SET 的问题就属于这种情况。
text
A -> EXISTS -> false
B -> EXISTS -> false
A -> SET
B -> SET
不同的交错顺序可能得到不同结果。
Check-Then-Act
这种代码很常见。
java
if (!exists(key)) {
set(key, value);
}
它先检查共享状态,再根据检查结果执行操作,因此被称为 Check-Then-Act。
检查完成到执行动作之间,状态可能已经发生变化。
SETNX 解决这类问题的方式很直接,它把这次检查和条件写入放到了同一个原子操作中。
这次开发里的小插曲最后可以留下一条检查习惯。
以后再看到一段并发代码里连续出现"读取、判断、修改"时,我会先看它们处于几个原子边界。
如果业务要求这几个动作之间不能被其他请求插入,那么每一步单独原子还不够。