在并发场景下操作共享数据时,最直接的思路是加锁,但锁会带来性能开销和死锁风险。Redis从2.2版本开始提供了另一种选择:通过WATCH命令实现乐观锁,配合事务(MULTI/EXEC)达到类似CAS(Compare And Swap)的效果。这种方式不会阻塞其他客户端,只在提交时检测数据是否被别人改过,特别适合读多写少、冲突概率低的场景。本文将从原理、代码实战到方案对比,完整拆解这套机制。

一、乐观锁与悲观锁的本质区别
要理解Redis的Watch机制,先要弄清楚乐观锁和悲观锁的差异。悲观锁假设冲突一定会发生,所以在操作数据前先把资源锁住,其他客户端必须等待锁释放。Redis的SETNX配合过期时间实现分布式锁就是典型的悲观锁思路,它的问题是高并发下大量请求被阻塞,吞吐量下降明显。
乐观锁则相反,它假设冲突很少发生,操作前不加任何锁,只在数据提交那一刻检查数据是否被修改过。如果数据版本与自己读取时一致,说明没有冲突,执行成功;如果不一致,说明期间有别的客户端改过数据,本次操作作废,由客户端决定是否重试。这种机制在冲突率低的环境下性能非常好,因为省去了获取锁和释放锁的开销。
CAS是乐观锁在CPU指令层面的经典实现,包含三个要素:内存位置、预期旧值、新值。只有当内存位置的值等于预期旧值时,才把它更新为新值,否则什么都不做。Redis的Watch事务本质上就是应用层的CAS模拟:Watch记录读取时的值作为预期旧值,EXEC提交时比对当前值,一致则执行,不一致则返回nil。
二、Watch配合事务的执行流程
Watch的使用有一个固定套路:先WATCH一个或多个键,接着读取数据并在本地计算新值,然后MULTI开启事务,把写命令入队,最后EXEC提交。整个流程的关键在于:如果在WATCH之后、EXEC之前,任何一个被监视的键被其他客户端修改(包括过期删除),事务就会被打断,EXEC返回nil,队列中的所有命令都不会执行。
用Redis-cli演示一个完整的会话:
127.0.0.1:6379> SET stock 100 OK 127.0.0.1:6379> WATCH stock OK 127.0.0.1:6379> GET stock "100" 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> DECR stock QUEUED 127.0.0.1:6379> EXEC 1) (integer) 99
如果在EXEC之前另一个客户端执行了SET stock 50,那么上面的EXEC会返回(nil),DECR不会执行。这就避免了"先读后写"过程中的脏写问题。需要注意几个细节:第一,WATCH必须在MULTI之前调用,事务内调用会报错;第二,EXEC执行后无论成败,客户端的所有Watch都会自动取消;第三,可以通过UNWATCH手动取消监视。
还有一个容易被忽视的坑:事务被打断时Redis不会报错,只是EXEC返回空回复。客户端代码必须检查这个返回值,否则你以为扣减了库存,实际上什么都没发生,造成业务数据不一致。
三、Java实战:库存扣减的乐观锁代码
下面用Jedis演示一个带重试的库存扣减逻辑。由于乐观锁失败后需要重试,代码通常包在一个循环里,设定最大重试次数防止无限循环:
public boolean deductStock(Jedis jedis, String key, int retryMax) {
for (int i = 0; i < retryMax; i++) {
// 监视库存键
jedis.watch(key);
String val = jedis.get(key);
if (val == null) {
jedis.unwatch();
return false;
}
int stock = Integer.parseInt(val);
if (stock <= 0) {
// 库存不足,取消监视后退出
jedis.unwatch();
return false;
}
Transaction tx = jedis.multi();
tx.set(key, String.valueOf(stock - 1));
List<Object> result = tx.exec();
if (result != null && !result.isEmpty()) {
return true; // 执行成功
}
// exec返回null说明事务被打断,进入下一轮重试
}
return false;
}
代码里有两处必须强调:一是库存不足的分支里调用了unwatch(),否则Watch会残留到下一次命令,可能带来意外行为;二是tx.exec()返回null表示键被其他客户端动过,此时事务已自动失效,直接进入下一轮循环重新WATCH、重新读值即可。
Spring Data Redis的写法类似,用SessionCallback保证多个操作在同一个连接上执行,因为WATCH和后续的MULTI必须来自同一个客户端连接:
public Boolean deductWithTemplate(RedisTemplate<String, String> template, String key) {
return template.execute(new SessionCallback<Boolean>() {
@Override
public Boolean execute(RedisOperations ops) throws DataAccessException {
ops.watch(key);
String val = (String) ops.opsForValue().get(key);
if (val == null || Integer.parseInt(val) <= 0) {
ops.unwatch();
return false;
}
ops.multi();
ops.opsForValue().set(key, String.valueOf(Integer.parseInt(val) - 1));
List<Object> rs = ops.exec();
return rs != null && !rs.isEmpty();
}
});
}
四、乐观锁的适用边界与替代方案
乐观锁并非万能。它的性能与冲突率直接相关:并发越高、同一键的争抢越激烈,事务被打断的概率越大,客户端不得不反复重试,CPU空转,吞吐量反而不如悲观锁。以秒杀场景为例,上万个请求同时抢一个库存键,几乎每个事务都会被打断,重试风暴会让系统雪崩。
针对高冲突场景,更稳妥的方案有两个。第一个是改用原子命令,比如直接用DECR或DECRBY,它本身是单命令原子操作,不需要事务也不需要Watch,判断返回值是否小于零再补回即可:
127.0.0.1:6379> DECR stock (integer) -1 127.0.0.1:6379> INCR stock (integer) 0
第二个方案是Lua脚本。Redis执行脚本是串行的,脚本内读取、判断、写入一气呵成,天然没有并发冲突,也就不需要重试逻辑:
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
return 0
end
redis.call('DECR', KEYS[1])
return 1
简单总结一下选型思路:读多写少、冲突概率低、业务允许客户端重试的场景,用Watch乐观锁最合适,实现简单且不阻塞;热点键高并发写,优先考虑原子命令或Lua脚本;需要复杂互斥逻辑(比如锁内调用外部服务)时,再考虑SETNX分布式锁。理解了Watch的CAS本质,你就能在各种并发方案之间做出准确的取舍。