返回首页

Redis进阶4.4: 原子操作实践

适用版本:Redis 7.2.x

很多业务一开始接入 Redis 时,只是把它当作“缓存”。但随着使用深入,很快就会遇到一类更关键的问题:

  • 怎样保证计数不会丢?
  • 怎样避免库存被扣成负数?
  • 怎样让“只执行一次”这类逻辑不被并发击穿?
  • 怎样安全释放分布式锁,而不是误删别人的锁?

这些问题表面上不同,本质上都落在同一个关键词上:原子操作

Redis 之所以特别适合做并发控制、计数、状态门禁,很大程度上就是因为它提供了丰富的原子命令,以及事务、Lua 等增强手段。掌握“原子操作实践”,其实就是学会把业务规则正确地映射到 Redis 的执行模型上。

这篇文章不只讲概念,而是围绕几个高频实战场景,分析应该怎么设计、怎么写命令、什么时候用单命令、什么时候升级到 Lua 或事务。


一、什么叫原子操作

在 Redis 语境里,原子操作通常指的是:

一个操作要么完整执行,要么根本不执行;执行过程中不会被其他客户端命令打断。

先看一个简单例子:

INCR page:view:1001

这条命令本身就是原子的。即使有很多客户端同时对同一个 key 执行 INCR,Redis 也会串行处理,不会出现“两个请求都读到旧值,然后覆盖写回”的问题。

但下面这种流程就不是单命令原子:

GET stock:item:1001
DECRBY stock:item:1001 1

因为这中间可能被其他请求插入操作。

所以理解原子操作的第一步,就是分清:

  • 单条命令是否天然原子
  • 多步业务是否需要组合成原子执行

二、Redis 中最常见的原子能力有哪些

大致可以分成三层:

1. 单命令原子能力

例如:

  • INCR / DECR
  • INCRBY
  • HINCRBY
  • SET key value NX EX seconds
  • SETNX
  • GETSET(旧命令风格,仍可理解)
  • LPUSH / RPOP 等单次结构修改命令

这些命令本身就是 Redis 的基本原子单位。

2. 事务原子提交能力

通过:

  • MULTI
  • EXEC
  • WATCH

适合处理“先读再写”“提交前检查是否被改过”的场景。

3. Lua 脚本原子执行能力

适合把多步判断逻辑压缩成一个不可拆分的服务端操作。

实践中最重要的不是“哪种能力最高级”,而是:

能用单命令解决,就不要上事务;能用短 Lua 清晰解决,再考虑 Lua。


三、实践一:计数器一定优先用原子自增命令

计数器是 Redis 最经典的原子操作场景。

1. 页面浏览量

INCR page:view:1001

2. 商品点赞数

INCRBY product:2001:likes 1

3. 用户积分增加

HINCRBY user:3001 score 10

这些命令都不需要事务,也不需要 Lua,因为它们已经天然满足“读改写合一”。

为什么不要自己拆成 GET + SET

错误示意:

GET page:view:1001
SET page:view:1001 101

如果多个客户端并发执行,会出现典型的覆盖问题。

因此计数场景的第一原则是:

只要 Redis 已经提供了单命令原子更新,就直接用原生命令。


四、实践二:限流计数要把“自增”和“设置过期”一起考虑

很多人写固定窗口限流时,第一反应是:

INCR rate:user:1001
EXPIRE rate:user:1001 60

这样在大多数时候能工作,但它其实有一个小风险:

  • INCR 成功了
  • 还没来得及 EXPIRE
  • 客户端异常退出或网络中断

结果就是这个 key 没有过期时间,变成脏数据。

一个更稳妥的 Lua 示例

local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current

执行:

EVAL "local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current" 1 rate:user:1001 60

这种写法把:

  • 第一次自增
  • 首次设置过期

合并成一个原子步骤,更适合真正的限流实现。

业务判断示例

如果返回值大于 100,就判定 60 秒窗口内超限。


五、实践三:分布式锁加锁要使用 SET NX EX

Redis 加锁最基础、也最常用的写法是:

SET lock:order:1001 request-token-abc NX EX 30

这个命令同时表达了三件事:

  • NX:只有 key 不存在时才设置成功
  • EX 30:锁 30 秒自动过期
  • request-token-abc:锁归属标识

为什么推荐这一种,而不是:

SETNX lock:order:1001 1
EXPIRE lock:order:1001 30

原因同样是原子性。拆成两条命令后,中间存在失败窗口;而 SET ... NX EX ... 是一次完成。

返回结果如何理解

  • 返回 OK:加锁成功
  • 返回空结果:说明锁已存在,加锁失败

六、实践四:释放锁必须校验锁归属

加锁正确只是第一步,释放锁更容易踩坑。

错误做法:

DEL lock:order:1001

为什么危险?

可能发生这样的顺序:

  1. 客户端 A 获取锁
  2. A 执行太久,锁过期
  3. 客户端 B 获取了同名新锁
  4. 客户端 A 结束时执行 DEL lock:order:1001
  5. 结果把 B 的锁删掉了

正确做法是:只有锁的值仍然等于自己的 token,才删除。

Lua 示例:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

执行:

EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end" 1 lock:order:1001 request-token-abc

这是 Redis 分布式锁里最经典的原子操作实践之一。


七、实践五:库存扣减不要直接 DECRBY 到负数

很多业务场景要求:

  • 库存足够才能扣减
  • 不足则失败
  • 绝不能把库存扣成负数

如果直接:

DECRBY stock:item:1001 3

虽然命令本身原子,但它不会替你判断“库存是否足够”。因此单靠 DECRBY 不够。

方案 1:事务 + WATCH

思路:

  1. WATCH stock:item:1001
  2. 读取当前库存
  3. 客户端判断是否足够
  4. MULTI
  5. DECRBY
  6. EXEC
  7. 冲突则重试

这种方式可行,但高并发下会有重试成本。

方案 2:Lua 一次完成判断与扣减

local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])

if stock < amount then
    return -1
end

return redis.call('DECRBY', KEYS[1], amount)

执行:

EVAL "local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if stock < amount then
    return -1
end
return redis.call('DECRBY', KEYS[1], amount)" 1 stock:item:1001 3

返回值约定:

  • -1:库存不足
  • 非负整数:扣减成功后的剩余库存

在工程实践里,这种方式通常比 WATCH + 重试 更直观。


八、实践六:幂等处理可以用“先占位,再执行”思路

有些接口要求“同一请求只能成功一次”,例如:

  • 支付回调
  • 消息消费去重
  • 表单防重复提交

这类场景适合使用一个幂等 key 做占位。

简单做法

SET idem:pay:order:1001 done NX EX 300

如果返回 OK,说明第一次处理,可以继续;如果设置失败,说明已经处理过或正在处理。

进一步优化:区分处理中与处理完成

也可以用更明确的状态值:

  • processing
  • done

例如先占位:

SET idem:pay:order:1001 processing NX EX 300

业务完成后再改为:

SET idem:pay:order:1001 done EX 86400

如果你希望“检查状态 + 更新状态”也具有原子条件判断能力,可以再引入 Lua。


九、实践七:状态流转要避免并发覆盖

订单状态、任务状态、审批状态,经常有这样的约束:

  • 只有 pending 才能改成 paid
  • 只有 running 才能改成 finished

错误做法通常是:

GET order:1001:status
SET order:1001:status paid

这中间如果被其他客户端改成了 canceled,就会发生状态覆盖。

方案 1:WATCH 做乐观锁

WATCH order:1001:status
GET order:1001:status
MULTI
SET order:1001:status paid
EXEC

前提是客户端确认读到的状态仍然允许流转。

方案 2:Lua 做条件更新

local current = redis.call('GET', KEYS[1])
if current ~= ARGV[1] then
    return 0
end
redis.call('SET', KEYS[1], ARGV[2])
return 1

执行:

EVAL "local current = redis.call('GET', KEYS[1])
if current ~= ARGV[1] then
    return 0
end
redis.call('SET', KEYS[1], ARGV[2])
return 1" 1 order:1001:status pending paid

这里的返回值约定可以是:

  • 1:更新成功
  • 0:当前状态不匹配,拒绝更新

十、实践八:原子操作不等于业务绝对正确

这是非常值得强调的一点。

很多人学习 Redis 原子操作后,会有一种错觉:

只要我把命令写成原子执行,业务就一定没问题。

其实不一定。

比如扣库存脚本虽然原子,但你还要考虑:

  • 下单成功后数据库是否也同步更新
  • 失败重试是否会重复扣减
  • 超时补偿怎么做
  • 跨多个资源的一致性怎么保证

Redis 原子操作能解决的是:

  • 单个 key 或一组相关 Redis 操作的并发安全问题

它不能自动解决:

  • 跨 Redis 和数据库的分布式事务
  • 跨服务的一致性编排
  • 完整业务链路的幂等和补偿

因此原子操作是基础,但不是全部。


十一、如何判断该用单命令、事务还是 Lua

可以用一个简单决策思路:

1. 单条命令能完成吗?

例如:

  • 自增计数
  • 哈希字段加减
  • SET NX EX 加锁

如果能,优先用单命令。

2. 是否需要“先读再判断再写”?

如果需要,再考虑事务或 Lua。

3. 逻辑能否压缩成短小脚本?

如果可以,Lua 往往更简洁。

4. 是否允许冲突后重试?

如果允许,WATCH 也是可行方案。

简单总结:

  • 单命令:最优先
  • Lua:高频原子业务逻辑
  • 事务 + WATCH:客户端主导的乐观锁更新

十二、客户端伪代码示例:安全扣库存 + 幂等标记

下面给一个更贴近业务的伪代码:

import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

lua = """
if redis.call('SET', KEYS[2], ARGV[2], 'NX', 'EX', ARGV[3]) == false then
    return -2
end

local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
if stock < amount then
    redis.call('DEL', KEYS[2])
    return -1
end

return redis.call('DECRBY', KEYS[1], amount)
"""

# KEYS[1] = stock:item:1001
# KEYS[2] = idem:req:20260609:abc123
# ARGV[1] = 购买数量
# ARGV[2] = 占位值
# ARGV[3] = 幂等key过期秒数

这个例子说明一个很重要的工程思想:

  • 原子操作可以不只操作一个 key
  • 你可以把“幂等占位”和“库存扣减”放到同一段 Lua 中一起处理

当然,真实业务里还要继续考虑数据库落库和消息补偿,但 Redis 这一层的并发安全已经更稳了。


十三、实践建议

1. 先用原生命令,别急着上复杂方案

如果 INCRHINCRBYSET NX EX 已经能解决问题,不要为了“高级”而写 Lua。

2. 把返回值协议设计清楚

特别是 Lua 脚本,建议提前约定:

  • 1 成功
  • 0 条件不满足
  • -1 库存不足
  • -2 幂等冲突

这样客户端和运维排查都会轻松很多。

3. 不要在 Redis 脚本里堆复杂业务流程

Lua 最适合关键原子步骤,不适合承载长流程业务编排。

4. 对热点 key 做好冲突评估

高并发场景下,所有请求都打同一个 key,本身就可能成为热点。此时要进一步考虑:

  • 是否需要分片
  • 是否需要预扣减 + 异步确认
  • 是否要用队列削峰

5. 原子只是局部正确,系统还要有补偿机制

Redis 保证的是局部并发安全,不代表整个业务链路天然强一致。数据库、消息队列、下游服务的协同仍然需要专门设计。


十四、小结

这篇文章围绕 Redis 原子操作,讲了几个最常见也最实用的落地场景:

  1. 计数器直接用 INCR / HINCRBY 这类单命令原子更新
  2. 限流要关注“自增 + 过期”是否需要合并成一次原子步骤
  3. 分布式锁加锁用 SET NX EX,释放锁用 Lua 校验归属
  4. 库存扣减、状态流转、幂等控制,往往需要 Lua 或事务增强
  5. 原子操作解决的是 Redis 层并发安全,不等于整个业务链路自动一致

如果你把 Redis 学成“会写几个缓存命令”,其实只掌握了它的一部分价值;而当你开始能根据业务特点选择:

  • 哪些场景直接用原子单命令
  • 哪些场景使用 Lua
  • 哪些场景使用事务 + WATCH

你才真正进入了 Redis 工程实践的核心区域。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Redis进阶4.3:Pipeline 批量操作

下一篇

Redis架构篇5.1: 主从复制原理