在分布式系统里,Redis经常被当作共享缓存和轻量级协调组件使用。当多个服务实例同时尝试修改同一个缓存键值,例如库存扣减或账户余额更新,如果不加控制,后到的写操作会覆盖先到的计算结果,产生超卖或数据不一致。悲观锁的悲观策略认为并发冲突一定会发生,所以任何读写前都必须先拿到锁,只有持有锁的线程能操作资源,其余请求排队或快速失败。

基于SETNX的基础悲观锁实现
最简单的Redis悲观锁利用SETNX命令,它的含义是set if not exists,只有当键不存在时才能设置成功,借此模拟加锁动作。为避免持有锁的进程崩溃导致锁永远不释放,通常要附带过期时间,但早期Redis版本中SETNX和EXPIRE不是原子操作,需要改用一条带参数的SET命令同时完成值与超时设置。
下面是一个使用Java与Jedis客户端的加锁示例,通过唯一标识区分锁的持有者,在释放时借助Lua脚本保证只删自己的锁。这种方式在单节点Redis中足够简单有效,但需要注意锁过期时间必须大于业务执行时间,否则会出现锁提前失效、多个线程同时进入临界区的问题。
// 尝试获取悲观锁,value为唯一客户端ID
String lockKey = "stock_lock";
String clientId = UUID.randomUUID().toString();
// 原子设置锁及过期时间,单位毫秒
String result = jedis.set(lockKey, clientId, "NX", "PX", 30000);
if ("OK".equals(result)) {
try {
// 执行缓存修改业务
int stock = Integer.parseInt(jedis.get("stock"));
jedis.set("stock", String.valueOf(stock - 1));
} finally {
// 释放锁的Lua脚本,避免误删其他客户端锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(clientId));
}
}
上述方案的缺陷在于,如果业务执行时间偶发超过30秒,锁自动释放后别的线程加锁成功,原线程在finally中通过Lua比对clientId发现不一致就不会删锁,但此时它已修改过缓存,造成锁失效期间的并发写。因此生产环境往往需要引入看门狗线程定期续期,或者采用Redisson等封装好的可重入锁。
Redlock算法与集群环境悲观策略
当Redis以主从或集群模式部署,单节点加锁在故障切换时可能丢失锁记录,因为从节点还未同步主节点的锁键就被提升为主。Redlock提出向多个独立的Redis主节点申请锁,只要大多数节点加锁成功且总耗时小于锁有效期,就认为获取了分布式悲观锁。这种策略牺牲了一定性能来换取更高的可靠性。
Redlock的实现流程是客户端依次向N个节点发送SET加锁命令,计算获取锁耗费的时间,若超过半数节点成功且剩余有效期充足,则持锁;释放时向所有节点发送释放指令。下表对比了单节点锁与Redlock的差异:
| 方案 | 节点要求 | 优点 | 缺点 |
|---|---|---|---|
| 单节点SETNX | 1个Redis实例 | 延迟低、实现简单 | 主从切换丢锁、单点故障 |
| Redlock | ≥3个独立主节点 | 容错性强、适合集群 | 网络开销大、时钟漂移敏感 |
在实际使用中,Redlock也受到过质疑,例如依赖系统时钟且如果出现STW暂停可能导致锁过期。因此对于强一致性要求的金融场景,有些团队会改用ZooKeeper或etcd的临时顺序节点做悲观锁,而Redis悲观策略更适合能容忍极小概率不一致、追求高吞吐的缓存层控制。
用Lua脚本强化悲观锁的原子边界
悲观策略不只是加锁解锁,更关键的是把“判断锁归属+修改缓存+释放锁”或“加锁+初始化缓存”等步骤封装为原子操作。Redis执行Lua脚本时单线程串行化,不会被其他命令插入,这正好弥补了多条命令之间可能被抢占的漏洞。我们可以在脚本里完成锁检测与业务写入,避免网络往返带来的竞态。
例如下面的Lua脚本实现了在锁内安全递增缓存计数器的逻辑,只有携带正确锁标识的调用才能修改,否则返回错误。这样即便客户端与Redis之间网络延迟,也不会出现锁过了有效期却被旧请求改数据的混乱。脚本中的redis.call直接操作键空间,比在应用层反复往返更可靠。
-- KEYS[1]为锁键,KEYS[2]为业务数据键
-- ARGV[1]为客户端标识,ARGV[2]为锁超时毫秒
local lock = redis.call('set', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])
if not lock then
-- 锁已被占用,返回0表示获取失败
return 0
end
-- 在锁保护下递增业务键
local val = redis.call('incr', KEYS[2])
-- 此处可加入更多缓存修改逻辑
return val
使用Lua封装后,应用端只需一次EVAL调用即可完成加锁与业务变更,降低锁持有时间,也减轻了对锁续期机制的依赖。但要注意Lua脚本不能执行过长,否则阻塞Redis主线程,影响其他请求。因此悲观锁内的逻辑应保持轻量,复杂计算放到客户端完成,只把关键状态变更留在脚本里。
悲观策略的常见误区与规避
不少开发者在写Redis悲观锁时,会把锁键和业务数据键混用,或者释放锁时直接DEL而不校验持有者,这在客户端崩溃重试时极易删掉别人刚申请的锁,让悲观策略形同虚设。正确做法是锁值带唯一标记,释放必须走比对删除的Lua。
另一个误区是认为加了锁就不需要幂等。实际上网络抖动可能让客户端以为加锁失败但其实成功了,重试又会拿一次锁,如果业务不是幂等,重复执行仍会污染缓存。因此在Redis悲观策略之上,业务层仍需设计幂等标识或版本号,双重保障缓存一致性。
import redis
import uuid
r = redis.Redis(host='127.0.0.1', port=6379, db=0)
lock_key = 'order_lock'
data_key = 'order_count'
cid = str(uuid.uuid4())
if r.set(lock_key, cid, nx=True, px=10000):
try:
current = int(r.get(data_key) or 0)
r.set(data_key, current + 1)
finally:
lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
r.eval(lua, 1, lock_key, cid)
上面Python示例完整演示了单节点悲观锁的获取、业务修改与安全释放。在真实项目中,建议把这些逻辑封装为上下文管理器,配合超时与重试上限,既保留悲观策略的严谨,也避免代码分散导致疏漏。当缓存并发量极高时,还可结合本地锁做两层防护,先拦住同进程线程,再争抢Redis锁,减少无效网络竞争。
Redis悲观锁pessimistic_lock修改时间:2026-08-17 14:16:40