适用版本: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/DECRINCRBYHINCRBYSET key value NX EX secondsSETNXGETSET(旧命令风格,仍可理解)LPUSH/RPOP等单次结构修改命令
这些命令本身就是 Redis 的基本原子单位。
2. 事务原子提交能力
通过:
MULTIEXECWATCH
适合处理“先读再写”“提交前检查是否被改过”的场景。
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
为什么危险?
可能发生这样的顺序:
- 客户端 A 获取锁
- A 执行太久,锁过期
- 客户端 B 获取了同名新锁
- 客户端 A 结束时执行
DEL lock:order:1001 - 结果把 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
思路:
WATCH stock:item:1001- 读取当前库存
- 客户端判断是否足够
MULTIDECRBYEXEC- 冲突则重试
这种方式可行,但高并发下会有重试成本。
方案 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,说明第一次处理,可以继续;如果设置失败,说明已经处理过或正在处理。
进一步优化:区分处理中与处理完成
也可以用更明确的状态值:
processingdone
例如先占位:
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. 先用原生命令,别急着上复杂方案
如果 INCR、HINCRBY、SET NX EX 已经能解决问题,不要为了“高级”而写 Lua。
2. 把返回值协议设计清楚
特别是 Lua 脚本,建议提前约定:
1成功0条件不满足-1库存不足-2幂等冲突
这样客户端和运维排查都会轻松很多。
3. 不要在 Redis 脚本里堆复杂业务流程
Lua 最适合关键原子步骤,不适合承载长流程业务编排。
4. 对热点 key 做好冲突评估
高并发场景下,所有请求都打同一个 key,本身就可能成为热点。此时要进一步考虑:
- 是否需要分片
- 是否需要预扣减 + 异步确认
- 是否要用队列削峰
5. 原子只是局部正确,系统还要有补偿机制
Redis 保证的是局部并发安全,不代表整个业务链路天然强一致。数据库、消息队列、下游服务的协同仍然需要专门设计。
十四、小结
这篇文章围绕 Redis 原子操作,讲了几个最常见也最实用的落地场景:
- 计数器直接用
INCR/HINCRBY这类单命令原子更新 - 限流要关注“自增 + 过期”是否需要合并成一次原子步骤
- 分布式锁加锁用
SET NX EX,释放锁用 Lua 校验归属 - 库存扣减、状态流转、幂等控制,往往需要 Lua 或事务增强
- 原子操作解决的是 Redis 层并发安全,不等于整个业务链路自动一致
如果你把 Redis 学成“会写几个缓存命令”,其实只掌握了它的一部分价值;而当你开始能根据业务特点选择:
- 哪些场景直接用原子单命令
- 哪些场景使用 Lua
- 哪些场景使用事务 +
WATCH
你才真正进入了 Redis 工程实践的核心区域。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!