Redis 经常被当作高并发缓存层,很多团队在实现计数器、库存扣减、限流控制时,第一反应是引入分布式锁来保证并发安全。实际上,Redis 自身提供了一套更轻量的无锁方案。理解这些方案的关键,是搞清楚 Redis 的命令执行模型:服务端主线程通过事件循环逐个处理已经到达的命令,不会出现两个命令同时修改同一个键的情况。正是这种串行化执行,让单条命令天然具备原子语义。

一、单命令原子性:无锁操作的基础
Redis 的网络层虽然支持多客户端并发连接,但命令的真正执行是在一个主线程中完成的。主线程借助 epoll、kqueue、select 等多路复用机制监听套接字,当某个连接上有完整命令到达时,就把命令放入一个内部队列。随后主线程会按顺序取出命令、执行操作、写回响应,整个过程不会被其他命令打断。也就是说,不管客户端来自多少个进程,INCR、DECR、SETNX 这些单条命令在同一时刻只会有一条在执行。
这一特性直接决定了简单计数、库存增减等场景可以用无锁方式实现。比如用 INCR user:1001:visit_count 统计访问量,命令执行时会先读取旧值、加一、再写回,这三个步骤因为全部由主线程完成,中间不会插入其他写操作。即便有上千个客户端同时发出 INCR,最终结果也不会丢失更新。
下面这些命令是典型的无锁原子原语:INCR、DECR 用于增减计数,SETNX 用于抢占唯一锁,HSETNX 用于哈希字段不存在时写入,LPUSH 用于原子地往列表头部插入。它们都适合作为并发安全的积木来组合更复杂的逻辑。
SET user:1001:stock 100 DECR user:1001:stock INCR user:1001:visit_count SETNX lock:order:1001 1 HSETNX user:1001:profile nickname "tom"
但单命令原子性只能覆盖一步操作。一旦业务要求先判断再写入,例如库存足够才能扣减,或者只有拿到锁后才执行业务,就必须把多个命令合并成一个原子单位。下一节介绍的 Lua 脚本正是为此而生。
二、Lua脚本:把多步操作封装成服务端原子块
Redis 从 2.6 版本开始内置了 Lua 解释器,允许通过 EVAL 或 EVALSHA 把一段脚本提交到服务端执行。脚本运行期间,Redis 主线程不会处理其他客户端的命令,因此脚本内部的多次读改写天然是一个原子任务。对于库存扣减这样的典型需求,客户端不再需要先 GET 再 DECR,而是把判断和扣减都放进脚本。
-- KEYS[1] 为库存键
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return -1
end
redis.call('DECR', KEYS[1])
return stock - 1
脚本中 redis.call('GET', KEYS[1]) 会先读取库存,stock <= 0 判断是否还有余量,仅当有余量时才执行 DECR。由于整个脚本在服务端原子执行,两个并发扣减请求不会同时看到相同的旧库存值。脚本返回 -1 时可以提示前端库存不足,返回其他数值则代表扣减后的剩余量。
使用 Lua 脚本也需要注意成本。脚本执行期间其他命令全部等待,如果脚本内部包含耗时循环、大量 KEYS 遍历或阻塞命令,会造成整个 Redis 实例短暂不可用。因此脚本应保持短小,避免在脚本里做复杂业务计算。实际项目中可以用 SCRIPT LOAD 将脚本加载到缓存,再通过 EVALSHA 用摘要调用,减少网络传输和重复编译。
在 Redis Cluster 集群模式下,Lua 脚本访问的所有键必须位于同一个哈希槽,否则会报错。可以通过在键名中使用花括号指定 hash tag,例如 stock:{user1001},让相关键映射到同一节点。
三、WATCH/MULTI/EXEC:乐观锁实现CAS语义
如果业务逻辑必须放在客户端执行,同时又要保证多个命令的原子性,可以使用 Redis 的乐观锁事务。WATCH 命令用于监视一个或多个键,之后执行 MULTI 开启事务,将命令入队,最后通过 EXEC 提交。如果在 WATCH 之后、EXEC 之前,被监视的键被其他客户端修改过,那么 EXEC 会返回空结果,表示事务被放弃。客户端可以选择重试。
这个机制很像数据库的 CAS 或版本号乐观锁:不阻塞其他请求,只在提交时检查版本是否发生变化。下面是一段 Python 示例,演示如何用 redis-py 实现带重试的库存扣减。
import redis
r = redis.Redis(decode_responses=True)
key = 'stock:1001'
def decrease_stock(amount):
while True:
with r.pipeline() as pipe:
try:
pipe.watch(key)
stock = int(pipe.get(key) or 0)
if stock < amount:
pipe.unwatch()
return False
pipe.multi()
pipe.decrby(key, amount)
pipe.execute()
return True
except redis.WatchError:
continue
代码中 pipe.watch(key) 开始监视库存键,读取当前值后判断是否满足扣减条件。若满足则调用 pipe.multi() 开启事务并将 DECRBY 入队,pipe.execute() 提交时底层会执行 EXEC。如果监视期间库存被改动,redis-py 会抛出 WatchError,此时进入下一轮循环重新读取,直到成功或库存不足。
与 Lua 脚本相比,WATCH 的优势是业务判断可以留在客户端,便于调试和复用已有代码;缺点是在竞争激烈时可能频繁重试,浪费网络往返和 CPU。Lua 脚本则没有重试开销,但要求逻辑能在服务端表达。实际选择时可以遵循一个原则:如果逻辑能用少量命令描述,优先用 Lua;如果客户端需要访问外部服务或做复杂计算,再考虑 WATCH。
四、典型场景与避坑指南
库存扣减是 Redis 无锁原子操作最常见的使用场景。商品详情页的并发下单如果直接使用 GET 加 SET 会出现超卖,改成 Lua 脚本或 DECR 配合前置判断即可在单节点下保证正确性。限流计数器也常用 INCR,每来一个请求就对用户维度键加一,超过阈值则拒绝。固定窗口限流可以这样初始化键:
SET rate_limit:user:123 1 EX 60 NX INCR rate_limit:user:123
这里 SET 的 EX 60 NX 参数保证键不存在时才创建,并设置 60 秒过期时间,避免无限增长。如果只写 INCR 而没有过期策略,计数键会永久存在,这是限流实现中的高频坑点。
另一个常见误区是把 SETNX 当作完整的分布式锁方案。SETNX 只能保证只有一个客户端能设置成功,但无法安全处理锁超时和误删问题。更稳妥的做法是使用带 EX 和 NX 的 SET 命令,并在删除锁时通过 Lua 脚本校验持有者标识,防止删除别人的锁。无锁原子操作的本质不是完全不加锁,而是把锁的粒度缩小到 Redis 服务端的命令或脚本边界内,避免客户端之间的显式互斥。
最后还要注意集群环境。单机 Redis 的原子命令在 Cluster 模式下仍然有效,但涉及多个键的 Lua 脚本和事务必须保证键在同一 slot。键名可以使用 {hash_tag} 语法强制映射。此外,主从切换可能造成命令丢失,如果业务对强一致性要求很高,应结合 WAIT 命令或 Redlock 思路做额外确认,而不是单纯依赖无锁原子操作。

