Appearance
Redis 事务的实现机制
Redis 事务是通过一系列原子操作来实现的,但它的事务机制与传统关系型数据库的事务(如 MySQL 的 ACID 特性)有一些显著不同。Redis 的事务主要通过以下三个命令来实现:
MULTI:标记事务的开始,将之后的所有命令放入事务队列;EXEC:执行事务中队列中的所有命令;DISCARD:取消事务,清空队列中的所有命令;WATCH:实现乐观锁机制,用于监视一个或多个键,在事务执行前检查这些键是否被修改;
1. Redis 事务的工作流程
启动事务:
MULTI调用
MULTI命令后,Redis 会将所有后续的命令放入一个事务队列,而不是立即执行。添加命令到队列
在事务模式下,所有的 Redis 命令会被依次放入事务队列中,并返回
QUEUED,而不会立即执行。提交事务:
EXEC当调用
EXEC时,Redis 会按顺序执行队列中的所有命令,并返回每个命令的执行结果。如果在WATCH监视的键发生变化时,EXEC会失败,返回null,否则会成功执行所有命令。取消事务:
DISCARD在调用
EXEC之前,如果调用DISCARD,Redis 会清空事务队列并退出事务模式,所有排队的命令都不会被执行。
2. Redis 事务的基本特性
2.1. 不支持回滚
Redis 事务与传统的关系型数据库事务不同,不支持回滚机制。如果事务中的某条命令出错,错误的命令会被跳过,但其他命令仍然会继续执行。例如:
Bash
MULTI
SET key1 value1
INCR key1 # 错误:因为 key1 是字符串类型,无法递增
SET key2 value2
EXEC在这个例子中,INCR key1 会出错,但 SET key1 value1 和 SET key2 value2 仍然会执行。
2.2. 没有隔离级别
Redis 事务中的所有命令并不会真正实现隔离级别(如 READ COMMITTED 或 SERIALIZABLE)。它的隔离机制是通过队列化事务命令来实现的,所有命令会按顺序执行,但其他客户端在事务执行时仍然可以访问和修改相同的数据。
Note 读取未提交(Read Uncommitted):在事务中的修改只有在
EXEC之后才会生效,但事务中的读操作不会阻止其他客户端的写入。
2.3. 原子性
Redis 事务中的每个命令是原子执行的,但事务整体不是原子的。也就是说:
每条命令在执行时是原子的。
整个事务不是原子的,Redis 不会在所有命令都成功执行时才提交,事务中的某些命令可能成功,而某些命令可能失败。
3. Redis 事务的实现细节
Redis 的事务是通过单线程模式来实现的:
事务队列:当调用
MULTI后,Redis 会将所有后续的命令放入事务队列中。执行队列:当调用
EXEC时,Redis 会按队列顺序依次执行所有命令,执行过程中其他客户端的请求不会插入。乐观锁机制(
WATCH):在执行EXEC前,Redis 会检查被WATCH的键是否被修改过,如果被修改过,则事务会被中止。
4. WATCH:实现乐观锁
WATCH 是 Redis 中实现乐观锁的核心命令,用来监视一个或多个键的变化。在 EXEC 之前,如果被监视的键发生了变化,Redis 将取消事务的执行,从而避免并发冲突。
4.1. 示例:使用 WATCH
假设有一个账户余额更新操作:
Java
redisTemplate.execute((RedisOperations operations) -> {
// 监视 balance 键
operations.watch("balance");
// 获取当前余额
Integer balance = (Integer) operations.opsForValue().get("balance");
// 只有在余额足够时才进行扣款操作
if (balance != null && balance >= 50) {
operations.multi(); // 开启事务
operations.opsForValue().decrement("balance", 50); // 扣款操作
operations.exec(); // 提交事务
} else {
operations.unwatch(); // 取消监视
}
return null;
});1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
4.2. WATCH 工作原理
WATCH balance:Redis 开始监视balance键;- 如果
balance在事务提交前被其他客户端修改,则EXEC会失败,返回null; - 如果
balance没有变化,事务会正常提交;
5. 为什么 Redis 事务需要 WATCH
Redis 是一个单线程的服务器,它会依次处理每个客户端的请求,确保每个命令是原子执行的。
当一个客户端在执行事务(EXEC)时,Redis 不会在事务执行的过程中插入其他客户端的命令,也就是说,事务中的所有命令会按顺序连续执行,保证执行期间的独占性。
但是,问题并不在于事务的执行过程中,而在于事务的 “准备阶段”。WATCH 不是为了保护事务执行中的数据不被修改,而是为了保护事务执行之前的数据不被其他客户端修改。
5.1. 没有 WATCH 时的竞态条件
假设你有两个客户端(Client A 和 Client B),都要对同一个账户的余额进行扣款操作。
Client A 和 Client B 都要扣除 100 元,并且在开始时账户余额为 150 元。
- Client A 读取到余额为
150元。 - Client B 也读取到余额为
150元。 - Client A 和 Client B 都判断余额足够,并且都决定进行扣款。
- Client A 开启事务,执行
MULTI,然后将余额 -100放入事务队列。 - 在事务执行前,Client B 也在
MULTI中执行了相同的操作,将余额 -100放入事务队列。 - Client A 提交
EXEC,扣除100,余额变为50。 - Client B 提交
EXEC,也扣除100,导致余额变为负的 -50,这显然是不符合预期的。
5.1.1. 引入 WATCH 来解决竞态条件
为了避免这种竞态条件,需要引入 WATCH,用于监控余额在事务提交前是否被其他客户端修改:
- Client A 执行
WATCH balance,开始监视余额。 - Client B 也执行
WATCH balance,监视余额。 - Client A 读取到余额
150,判断余额足够,进入事务。 - Client B 也读取到余额
150,判断余额足够,进入事务。 - Client A 提交事务,扣除
100元,余额变为50。 - Client B 在提交事务时,Redis 发现
WATCH监视的balance键已经被修改(从150变成50),事务执行失败,返回null,从而避免了不正确的扣款操作。
6. 示例:Redis 事务中的基本操作
6.1. 事务执行成功示例:
Bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 "value1"
QUEUED
127.0.0.1:6379> SET key2 "value2"
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK6.2. 事务执行失败示例:
Bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 "value1"
QUEUED
127.0.0.1:6379> INCR key1 # 错误命令
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) ERR value is not an integer or out of range在这种情况下,虽然 INCR 命令失败,但 SET key1 "value1" 已经成功执行。