文章目录
- 一、原子性到底是什么意思?
- [二、为什么 Redis 能做到这一点?](#二、为什么 Redis 能做到这一点?)
-
- [1. Redis 采用单线程事件循环](#1. Redis 采用单线程事件循环)
- 三、脚本内部多条命令为何不会"被插队"?
- 四、脚本执行期间禁止阻塞,原因也是原子性
- [五、Lua 脚本 vs 事务(MULTI/EXEC)](#五、Lua 脚本 vs 事务(MULTI/EXEC))
- [六、Lua 脚本应用:库存扣减 + 一人一单](#六、Lua 脚本应用:库存扣减 + 一人一单)
- [七、Lua 脚本是否"绝对原子"?边界在哪里?](#七、Lua 脚本是否“绝对原子”?边界在哪里?)
-
- [1. 脚本不能跨节点(Redis Cluster)](#1. 脚本不能跨节点(Redis Cluster))
- [2. 脚本执行时间过长会阻塞整个 Redis](#2. 脚本执行时间过长会阻塞整个 Redis)
- [3. 脚本无法取消](#3. 脚本无法取消)
- 八、总结
在 Redis 的使用场景中,我们经常听到一句话:"Lua 脚本在 Redis 中具有原子性"。但很多工程师的理解通常停留在表层:Redis 是单线程的,所以天然原子。
这句话没错,但仍然过于粗糙。为什么单线程能保证脚本原子?什么叫原子?脚本内部明明执行了多条 Redis 命令,为什么不会被其他客户端"插队"?Redis 又做了什么保证?
一、原子性到底是什么意思?
先明确一个概念:
在 Redis 中执行一段 Lua 脚本时,整个脚本被视为一条命令,执行过程不可被分割,不会被其他请求打断。
也就是说:
- 如果脚本里执行了 10 次
redis.call - 对 Redis 来说,它们仍然是一次不可分割的操作
- 中途不会执行其他客户端的命令
这就是 Redis 所说的"原子性"。
二、为什么 Redis 能做到这一点?
原子性的根本原因不是 Lua,而是 Redis 的单线程架构。
1. Redis 采用单线程事件循环
Redis 的核心线程负责:
- 处理客户端请求
- 执行命令
- 管理网络 IO
- 操作内部数据结构
在任何时刻,Redis 都只执行一个请求。
无论请求是 GET、SET,还是 EVAL(Lua 脚本),都必须排队顺序执行。
所以当 Lua 脚本被调起:
shell
EVAL "......" 1 key
Redis 做的事情如下:
- 解析脚本
- 将脚本加载入 Lua VM
- 整段脚本一次性执行完毕
- 返回结果
- 处理下一个客户端请求
在脚本执行的整个过程中:
Redis 不会执行其他客户端的命令,也不会中途挂起脚本。
因此脚本内的多个操作自然成为原子的整体。
三、脚本内部多条命令为何不会"被插队"?
例如脚本中这样写:
lua
local stock = redis.call('GET', KEYS[1])
if stock > 0 then
redis.call('DECR', KEYS[1])
end
表面上执行了两条命令(GET 和 DECR):
- 读取库存
- 扣减库存
如果由普通客户端发送两条命令:
GET stock
DECR stock
在高并发下中间可能插入:
- 其他客户端也 GET
- 其他客户端也 DECR
并发条件下可能出现库存被超卖的问题。
但使用 Lua 之后:
- GET + 判断 + DECR 变成一条"脚本命令"
- 不存在被其他命令插队的可能
Redis 的执行模型确保脚本中所有调用都是一个整体。
四、脚本执行期间禁止阻塞,原因也是原子性
Redis 明确禁止 Lua 脚本做任何阻塞操作:
- 不允许 sleep
- 不允许 I/O
- 不允许无限循环
- 不允许访问网络
如果脚本可以阻塞,就无法保证 Redis 的事件循环继续执行,也就无法保证原子性。
从架构层面:
Redis 牺牲了脚本的复杂度,换取了脚本的确定性与原子性。
五、Lua 脚本 vs 事务(MULTI/EXEC)
很多人误以为 Redis 事务也能做到完全原子,但事实并非如此。
| 特性 | Lua 脚本 | MULTI/EXEC |
|---|---|---|
| 原子性 | 完整原子 | 仅保证命令按顺序执行 |
| 并发干扰 | 不会被干扰 | 可能被其他客户端修改 key |
| WATCH 支持 | 不需要 | 需要使用 WATCH 做乐观锁 |
| 性能 | 高(一次网络往返) | 多次网络往返 |
因此:
Redis 官方推荐复杂业务逻辑全部写 Lua,不要用事务拼命凑。
六、Lua 脚本应用:库存扣减 + 一人一单
这是 Lua 原子操作在电商领域最典型的应用。
一个常见的原子脚本:
lua
-- KEYS[1] = stockKey
-- KEYS[2] = userKey
-- ARGV[1] = userId
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock <= 0 then
return -1 -- 无库存
end
local exists = redis.call('SISMEMBER', KEYS[2], ARGV[1])
if exists == 1 then
return -2 -- 已下单
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return 1
这个脚本具备:
- 检查库存
- 检查是否下单
- 扣库存
- 写入"一人一单"标记
全部原子完成。
无论多少并发量,都不会发生:
- 重复下单
- 超卖
- 并发状态覆盖
这就是 Lua 原子性在工程实战中的巨大价值。
七、Lua 脚本是否"绝对原子"?边界在哪里?
Lua 的原子性基于 Redis 单线程模型,但仍然有边界:
1. 脚本不能跨节点(Redis Cluster)
Cluster 下存在 key slot:
- 如果脚本操作的 key 不在同一 slot,会执行失败
- 因此 Cluster 环境的 Lua 只对"同 slot 的 key"原子
2. 脚本执行时间过长会阻塞整个 Redis
脚本执行时间长会导致:
- 其他客户端全部堵塞
- Redis 吞吐量瞬间下降
- 极端情况下可能卡死
Redis 为此设置了:
lua-time-limit
默认 5 秒。
3. 脚本无法取消
脚本开始执行后不能被中断,除非:
- 人为
SCRIPT KILL(仅限无写操作脚本) - 强制杀 Redis(丢数据)
因此脚本逻辑必须精简。
八、总结
Redis Lua 脚本具备原子性,根本原因有三点:
- Redis 单线程执行命令,不会并发处理多个请求。
- Lua 脚本作为单条命令执行,中途不会被切换。
- 脚本内部的所有 redis.call 调用在同一执行上下文中完成。
因此,即使脚本内部包含多条 Redis 命令,整体仍然是不可拆分的原子操作。
在实际工程中,Lua 脚本常用于:
- 秒杀/抢购库存扣减
- 一人一单
- 分布式锁验证
- 限流计数
- 复杂原子操作封装
它比 MULTI/EXEC 事务更强、性能更好,也是 Redis 官方推荐的复杂操作方案。