一次 Redis SETNX 的小插曲,我重新理解了原子性

一次 Redis SETNX 的小插曲,我重新理解了原子性

最近项目里用 Redis 做并发控制时,我碰到了 SETNX

它的行为很好理解。

redis 复制代码
SETNX key value

key 不存在时写入成功,已经存在时写入失败。

所以多个请求同时操作同一个 key 时,可以得到这样的结果。

text 复制代码
请求 A -> SETNX -> 成功
请求 B -> SETNX -> 失败

这很适合用来做抢占式的并发控制。

看到这里时,我突然产生了一个疑问。

Redis 本身已经有 EXISTSSET

那我自己先判断一次,再决定要不要写,不也能完成同样的事情吗?

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 解决这类问题的方式很直接,它把这次检查和条件写入放到了同一个原子操作中。

这次开发里的小插曲最后可以留下一条检查习惯。

以后再看到一段并发代码里连续出现"读取、判断、修改"时,我会先看它们处于几个原子边界。

如果业务要求这几个动作之间不能被其他请求插入,那么每一步单独原子还不够。

相关推荐
Sayai1 小时前
【无标题】ECharts 实现日志量异常检测:基线 ±Kσ 基带 + 灵敏度切换(Vue2 实战)
前端·javascript·echarts
用户3169353811832 小时前
SpringBoot + Vue 项目生产环境部署完整指南
前端·后端
Csvn2 小时前
🔍 TypeScript `satisfies` 操作符:既要类型安全,又要推断的原始字面量类型
前端
Csvn2 小时前
🚦 手写 Promise 并发控制:如何优雅限制异步请求的并发数?
前端
gb42152872 小时前
python中Web应用服务器
开发语言·前端·python
默_笙2 小时前
💌 为了把"主题色"传给孙子组件,我翻了五层楼——直到遇见了 useContext
前端·javascript
颜进强2 小时前
04 - 一张图看懂 OpenSpec 变更的一生:从创建到归档
前端·后端·ai编程
the_answer3 小时前
JS 垃圾回收机制
前端
小KK_3 小时前
JS 事件循环从小白视角入门:宏任务、微任务与 async/await 一网打尽
前端·javascript