在 Redis 的面试中,关于事务与并发控制的问题层出不穷,其中“WATCH 命令如何实现乐观锁”是一个高频考点。很多开发者对 Redis 事务的理解停留在 MULTI/EXEC 的层面,却对 WATCH 的机制和它在并发场景下的价值一知半解。本文将从乐观锁的基本概念出发,深入剖析 WATCH 命令的实现原理,并结合实际场景说明如何利用它构建可靠的并发控制逻辑。
一、为什么需要乐观锁?
在并发编程中,多个客户端可能同时读写同一份数据。如果不加控制,就会出现更新丢失、脏读等问题。常见的解决方案有两种:
- 悲观锁:假设冲突一定会发生,因此在操作数据前先加锁,阻止其他客户端访问。比如数据库中的
SELECT ... FOR UPDATE。 - 乐观锁:假设冲突很少发生,因此不加锁,而是在提交更新时检查数据是否被其他客户端修改过。如果被修改过,则放弃或重试。
Redis 本身是单线程处理命令的,但这并不意味着多个客户端之间不会产生竞态条件。例如,客户端 A 读取了某个 key 的值,准备基于这个值做计算后再写回;与此同时客户端 B 也读取了同一个值并写回。如果两个客户端都直接写回,就会导致其中一个的更新被覆盖。WATCH 命令正是为了解决这类问题而设计的乐观锁机制。
二、WATCH 命令的基本用法
WATCH 命令的语法非常简单:
WATCH key [key ...]
它通常与 MULTI/EXEC 事务配合使用。基本流程如下:
- 客户端使用
WATCH监视一个或多个 key。 - 客户端读取被监视 key 的当前值,进行业务计算。
- 客户端开启事务
MULTI,将写操作命令入队。 - 客户端执行
EXEC提交事务。
如果在 WATCH 之后、EXEC 之前,任何一个被监视的 key 被其他客户端修改过,那么 EXEC 会执行失败,返回 nil,事务中的所有命令都不会被执行。
一个典型的例子是原子性地递增一个值,但要求只有当值未超过上限时才递增:
WATCH counter
value = GET counter
if value < 100:
MULTI
INCR counter
EXEC
else:
UNWATCH
如果在这个过程期间有其他客户端修改了 counter,EXEC 就会失败,客户端可以选择重试整个流程。
三、WATCH 的实现原理
要理解 WATCH 如何实现乐观锁,需要从 Redis 的内部数据结构说起。
1. 监视的存储:watched_keys 字典
每个 Redis 数据库(redisDb)中都维护了一个 watched_keys 字典。这个字典的键是被监视的 key,值是一个链表,链表中保存了所有监视该 key 的客户端。也就是说,Redis 能够快速地根据一个 key 找到所有正在监视它的客户端。
2. 客户端标志:CLIENT_DIRTY_CAS
每个 Redis 客户端(client)都有一个标志位 CLIENT_DIRTY_CAS。当被监视的 key 被修改时,Redis 会将该 key 对应的所有客户端的这个标志位置为 1,表示“客户端的 CAS(Check-And-Set)条件已被破坏”。
3. 修改触发:touchWatchedKey 函数
每当一个 key 被修改(包括被删除、过期等),Redis 都会调用 touchWatchedKey 函数。这个函数会查找 watched_keys 字典,找到所有监视该 key 的客户端,并将它们的 CLIENT_DIRTY_CAS 标志置位。注意,这里并不关心修改操作是否在事务中,也不关心修改后的值是什么,只要 key 被触碰过,就视为已修改。
4. EXEC 时的检查
当客户端执行 EXEC 时,Redis 会检查该客户端的 CLIENT_DIRTY_CAS 标志:
- 如果标志为 1,说明至少有一个被监视的 key 在 WATCH 之后被修改过,此时 Redis 会放弃执行事务,返回
nil。 - 如果标志为 0,说明所有被监视的 key 都没有被修改过,Redis 会依次执行事务队列中的所有命令。
5. UNWATCH 与 EXEC 后的清理
无论事务是否执行成功,EXEC 执行后都会清除客户端所有的监视信息,包括从 watched_keys 字典中移除该客户端,并重置 CLIENT_DIRTY_CAS 标志。此外,客户端也可以主动调用 UNWATCH 来取消所有监视。
四、WATCH 的乐观锁特性分析
从上述实现可以看出,WATCH 提供的乐观锁具有以下特点:
- 非阻塞:WATCH 本身不会阻塞任何操作,其他客户端可以自由修改被监视的 key,只是修改会导致监视者的 CAS 失败。
- 一次性:WATCH 的监视效果只在当前连接的下一次 EXEC 之前有效。一旦 EXEC 执行(无论成功与否),所有监视都会被清除。
- 粗粒度:只要 key 被修改过,无论修改成什么值,都会导致 CAS 失败。即使其他客户端把值改成了相同的值,也会被视为冲突。
- 不保证原子性:WATCH + MULTI + EXEC 本身并不保证“读取-计算-写入”的原子性,它只是保证在 EXEC 时如果数据被修改过就放弃执行。因此,客户端通常需要配合重试逻辑。
五、常见误区与面试要点
在面试中,关于 WATCH 的常见问题包括:
误区一:WATCH 可以替代分布式锁。
WATCH 只能用于乐观锁场景,它不提供互斥性。多个客户端可以同时读取同一个 key,只有在提交时才会检查冲突。如果需要强互斥,应该使用 SETNX 或 Redlock 等分布式锁方案。
误区二:WATCH 监视的 key 过期也会触发 CAS 失败。
这是正确的。key 过期本质上是一种删除操作,会触发 touchWatchedKey,因此也会导致 CAS 失败。这一点在面试中经常被问到。
误区三:EXEC 失败后事务中的命令部分执行。
这是错误的。EXEC 失败时,事务队列中的所有命令都不会被执行,Redis 保证要么全部执行,要么全部不执行。
误区四:WATCH 可以在 MULTI 之后使用。
这是错误的。WATCH 必须在 MULTI 之前执行。如果在 MULTI 之后执行 WATCH,Redis 会返回错误。
六、实战建议
在实际开发中,使用 WATCH 实现乐观锁时,建议遵循以下模式:
- 使用
WATCH监视关键 key。 - 读取 key 的当前值并进行业务判断。
- 如果条件满足,开启
MULTI,将写命令入队,然后EXEC。 - 如果
EXEC返回nil,说明发生冲突,可以选择重试(通常配合循环和最大重试次数)。 - 如果条件不满足,调用
UNWATCH释放监视,避免不必要的冲突检测。
需要注意的是,WATCH 的重试逻辑应该在应用层实现,并且要控制重试次数,避免在高并发场景下出现活锁。
七、总结
WATCH 命令是 Redis 实现乐观锁的核心机制。它通过 watched_keys 字典和 CLIENT_DIRTY_CAS 标志,在不阻塞其他客户端的前提下,检测被监视 key 是否在事务提交前被修改。如果发生修改,EXEC 会放弃执行事务,从而避免更新丢失。理解 WATCH 的实现原理,不仅有助于回答面试题,更能帮助我们在实际项目中正确使用 Redis 事务,构建可靠的并发控制逻辑。
未经允许不得转载:任鹏个人博客 » Redis 的 WATCH 命令如何实现乐观锁

