Redis事务与乐观锁及Lua脚本
Redis事务通过MULTI/EXEC打包命令,保证顺序执行和隔离性。它不支持运行时回滚,但可使用WATCH实现乐观锁。对于复杂原子操作,推荐使用Lua脚本。
我们来详细、系统地讲解一下 Redis 的事务。
Redis 的事务(Transaction)是一组命令的集合。它允许将多个命令打包,然后一次性、按顺序地执行。在事务执行期间,Redis 不会处理来自其他客户端的请求,这确保了事务中的所有命令都会被连续执行,不会被其他命令插入打断。
Redis 事务的四大核心命令
Redis 事务主要通过以下四个命令来实现:
MULTI:标记一个事务块的开始。执行此命令后,客户端后续发送的命令都会被放入一个队列中,而不会立即执行。EXEC:执行所有在MULTI之后入队的命令。一旦执行EXEC,Redis 会顺序执行队列中的所有命令,并一次性返回所有命令的执行结果。DISCARD:取消事务,放弃执行事务队列中的所有命令。执行此命令后,事务状态会结束,所有已入队的命令都会被清空。WATCH:监视一个或多个 key。如果在事务执行(EXEC)之前,任何被WATCH的 key 被其他客户端修改了,那么整个事务将被取消,EXEC会返回一个 nil 回复。这是一种实现乐观锁(Optimistic Locking)的机制。
Redis 事务的特性 (与传统数据库ACID的对比)
很多人会拿 Redis 事务和关系型数据库的 ACID 事务作比较。需要特别注意的是,Redis 事务与我们通常理解的 ACID 有很大不同。
1. 原子性 (Atomicity)
Redis 的事务不完全满足原子性。它提供的是一种“部分原子性”。
- 命令入队阶段的原子性:如果在
MULTI和EXEC之间,客户端发送的某个命令有语法错误(比如命令名写错了),那么在执行EXEC时,Redis 会拒绝执行整个事务,所有命令都不会被执行。这一点上,它具备原子性。 - 命令执行阶段的非原子性:如果在
EXEC执行期间,某个命令发生了运行时错误(比如对一个字符串类型的 key 执行INCR操作),Redis 不会回滚已经执行成功的命令,而是会继续执行完后续的命令。
示例:执行时错误
127.0.0.1:6379> SET mykey "hello"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR mykey # 这条命令会失败,因为mykey是字符串
QUEUED
127.0.0.1:6379> SET anotherkey "world" # 这条命令是正确的
QUEUED
127.0.0.1:6379> EXEC
1) (error) ERR value is not an integer or out of range # 第一条命令执行失败
2) OK # 第二条命令执行成功
在这个例子中,即使 INCR mykey 失败了,SET anotherkey "world" 依然被成功执行了。Redis 事务没有回滚(Rollback)机制。
Redis 作者认为,命令执行失败通常是编程错误导致的,应该在开发阶段就被发现,而不是在生产环境中依赖事务回滚来解决。这种设计简化了 Redis,使其保持了高性能。
2. 一致性 (Consistency)
Redis 事务可以保证一致性。
- 入队错误:整个事务不执行,数据库状态不变。
- 执行错误:虽然不会回滚,但 Redis 会保证出错的命令不会对数据造成破坏(例如,
INCR字符串失败,原字符串的值不会改变)。 - 中断保证:事务执行期间,不会有其他客户端的命令插入,避免了数据状态的混乱。
- 持久化:如果开启了 RDB 或 AOF 持久化,Redis 会确保只有成功执行的事务才会被写入磁盘。
3. 隔离性 (Isolation)
Redis 事务具有完全的隔离性。
这是因为 Redis 是单线程执行命令的。当一个客户端执行 EXEC 时,Redis 会以独占的方式执行事务队列中的所有命令,直到全部执行完毕。在此期间,任何其他客户端的请求都不会被处理。这提供了最高级别的隔离——串行化(Serializable)。
4. 持久性 (Durability)
事务的持久性取决于 Redis 的持久化配置(AOF/RDB),与事务本身无关。
- 如果 Redis 没有配置持久化,那么服务重启后数据会丢失,事务也就不具备持久性。
- 如果配置了 AOF 并且
appendfsync策略是always,那么每个成功执行的命令都会被立即写入磁盘,持久性最高。 - 如果
appendfsync是everysec(默认),则最多可能丢失 1 秒的数据。
使用 WATCH 实现乐观锁
WATCH 是 Redis 事务中非常重要的一个功能,它用来解决并发场景下的“竞态条件”(Race Condition)。
场景:假设有一个商品库存 stock,值为 10。现在有两个用户(客户端A和B)同时购买。
不使用 WATCH 的错误流程:
- 客户端A 读取
stock为 10。 - 客户端B 也读取
stock为 10。 - 客户端A 在本地计算
10 - 1 = 9,然后SET stock 9。 - 客户端B 在本地计算
10 - 1 = 9,然后SET stock 9。
结果:卖出了两个商品,库存却只减了1,导致数据不一致。
使用 WATCH 的正确流程:
- 客户端A:
WATCH stock - 客户端B:
WATCH stock - 客户端A:
GET stock-> 得到 "10" - 客户端B:
GET stock-> 得到 "10" - 客户端A:
MULTI - 客户端A:
SET stock 9 - 客户端A:
EXEC-> 成功执行,stock变为 9。 - 客户端B:
MULTI - 客户端B:
SET stock 9 - 客户端B:
EXEC-> 此时EXEC会返回(nil),表示事务执行失败。因为在客户端B执行EXEC之前,它所WATCH的stock键已经被客户端A修改了。
客户端B在发现事务失败后,可以进行重试(重新 WATCH、GET、计算、EXEC),直到成功为止。这就是Check-And-Set (CAS) 行为。
WATCH 的工作原理:
WATCH命令会监视一个或多个 key 的版本。- 当任何一个被
WATCH的 key 被修改(如SET,INCR,HSET,LPUSH等),所有监视该 key 的客户端的“脏位”(dirty flag)都会被设置。 - 当客户端执行
EXEC时,Redis 会检查这个“脏位”。如果为真,则事务被取消。 EXEC或DISCARD命令执行后,之前的所有WATCH都会被取消。也可以手动使用UNWATCH取消。
Redis 事务的局限性
- 无运行时回滚:如前所述,执行期间的错误不会导致已执行命令的回滚。
- 不支持检查点:事务队列中的命令结果在
EXEC之前是不可见的。你不能在一个事务中,根据前一个命令的结果来决定下一个命令的操作。
# 错误的想法:不能这样做
MULTI
SADD myset "user1" # 希望得到这个命令的返回值(1或0)
IF (返回值 == 1) THEN # 事务中不支持这种逻辑判断
INCR user_count
END
EXEC
这种复杂的、带有逻辑判断的原子操作需求,应该使用 Lua 脚本来解决。
替代方案:Lua 脚本
对于需要复杂逻辑的原子操作,Redis 官方更推荐使用 Lua 脚本。
- 原子性:整个 Lua 脚本在 Redis 中是原子执行的,与事务的
EXEC类似,执行期间不会被其他命令打断。 - 灵活性:可以在脚本中编写复杂的逻辑,如
if-else判断、循环等。 - 性能:Lua 脚本在 Redis 服务器端执行,减少了客户端与服务器之间的网络往返次数。
示例:使用 Lua 脚本实现安全的库存扣减
-- a lua script, e.g., decrease_stock.lua
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
redis.call('DECR', KEYS[1])
return 1 -- 代表成功
else
return 0 -- 代表库存不足
end
在客户端执行:
EVAL "local stock = redis.call('GET', KEYS[1]) if tonumber(stock) > 0 then redis.call('DECR', KEYS[1]); return 1; else return 0; end" 1 stock
这个脚本实现了“读取-判断-写入”的完整原子操作,比 WATCH + MULTI + EXEC 的方式更简洁、更高效。
总结
| 特性 | Redis 事务 (MULTI/EXEC) |
传统关系型数据库事务 |
|---|---|---|
| 原子性 | 部分支持(入队错误则全部取消,执行错误不回滚) | 完全支持,出错会回滚 |
| 隔离性 | 完全支持(串行化) | 支持多种隔离级别(读未提交、读已提交、可重复读、串行化) |
| 回滚机制 | 无 | 有 |
| 乐观锁 | 通过 WATCH 命令实现 |
通常通过版本号或时间戳字段实现 |
| 复杂逻辑 | 不支持 | 支持 |
何时使用 Redis 事务?
当你需要确保一组命令能够连续执行而不被其他客户端打断时,Redis 事务是一个简单有效的工具。例如,SADD 一个元素后立即用 EXPIRE 为其设置过期时间。
何时使用 Lua 脚本?
当你需要执行一个包含条件判断、计算或循环的复合操作,并且要求这个操作是原子性的时候,Lua 脚本是更好的选择。例如,安全的库存扣减、限流操作等。