Redis 中的事务
Redis 中的事务是一种将多个命令打包,一次性按顺序执行的机制。这种机制只能减少网络往返时间,不能保证事务的原子性。事务执行期间即使一个语句发生错误,下一条语句也会执行,而不会结束事务并回滚
Redis 中的事务在执行期间会经历三个阶段:
- 开启事务
- 命令入队
- 执行事务/放弃事务
开启事务
通过 MULTI 命令开启事务。正常情况下,执行 MULTI 命令后客户端会返回 “OK” 结果,表示开启事务成功
需要注意,MULTI 命令不能嵌套使用,如果已经开启事务再执行 MULTI 命令,就会报错。虽然会报错,但这个行为并不会终止已经开启的事务
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> MULTI
(error) ERR MULTI calls can not be nested
命令入队
开启事务之后,就可以执行所有常规 Redis 命令(除去能影响 Redis 状态和只能在事务外执行的语句),输入命令后,此条命令就会入对,客户端会返回 “QUEUED” 表示入队成功
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> GET k1
QUEUED
在之后,Redis 会按输入顺序执行这些入队的命令
执行事务/放弃事务
执行事务的命令是 EXEC
在执行这个命令后,Redis 就会真正开始执行事务队列中的命令,并依次返回结果
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> GET k1
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> GET k2
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) "v1"
3) OK
4) "v2"
放弃事务的命令是 DISCARD
执行这个命令后,会清空事务队列中所以命令并退出事务模式
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> get k2
"v2"
127.0.0.1:6379> get *
(nil)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 v1
QUEUED
127.0.0.1:6379> SET key2 v2
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> get key1
(nil)
127.0.0.1:6379> get key2
(nil)
事务期间的错误
事务执行期间出现的错误有以下三类:
- 执行时错误
- 入队时错误,不会终止整个事务
- 入轨时错误,会终止整个事务
执行时错误
这种错误发生在 Redis 不执行命令就发现不了错误的情况
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET num 1
QUEUED
127.0.0.1:6379> SADD num 2
QUEUED
127.0.0.1:6379> SET num 3
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK
127.0.0.1:6379> get num
"3"
在上面示例中,set num 1,已经把键 num 的值设置为了字符串类型,sadd num 1 又把值设置为集合类型,所以会报错
但是即使是报错了,可以看到后面的 set num 3 这条语句依然执行了,这也说明了 Redis 中的事务并不能保证一致性和原子性
入队时错误,不会终止整个事务
这种错误只会发生在特定的命令中,比如 WATCH、MULTI
这些命令在入队时即使发生错误,也不会终止整个事务
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> MULTI
(error) ERR MULTI calls can not be nested
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK
入队时错误,会终止整个事务
这种错误会出现在命令不存在、参数个数不对等语法错误,在入队时 Redis 语法检测如果发现了错误,就会及时返回错误提示,并终止这个事务
127.0.0.1:6379> SET k1 v1
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET k1 v2
QUEUED
127.0.0.1:6379> SET k1
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> GET k1
"v1"
在事务开启之前,设置了 k1 v1 键值对,在开启事务后把更新语句放入事务队列中,但因为下面的 set k1 语句错误(参数个数不够)导致语法检测发现错误,之后执行 exec 执行事务时就会报错,事务队列中的语句也会全部清空不执行
WATCH 命令
WATCH 命令的实现类似于乐观锁,它用于在并发情况下,保证事务的原子性。通过 WATCH 命令,客户端可以监控一个或一组键,如果这些键在事务执行期间被其他客户端修改,那么整个事务就会终止执行
WATCH 命令基本用法为:
WATCH key [key...]
需要注意,WATCH 命令必须在开启事务之前执行。开启事务后执行会报错,但不会终止事务
在上面的例子中,客户端 1先设置了 k1 v1 键值对,随后通过 watch 命令监视 k1、开启事务,在命令入队期间,客户端 2更新了 k1 的值
在客户端 2执行完成后,客户端 1提交事务,会因为 k1 的值被修改而终止事务,set k1 v2 这条语句并没有生效
与 WATCH 命令对应的有 UNWATCH 命令,它用于清除之前监控的所有键值对
Go 操作 Reids
在 go-redis 中,TxPipeline 方法可以开启一个管道,类似于 Redis 中的 MULTI 命令,开启一个事务
Pipeliner.Exec()方法类似于 Redis 中的 EXEC 命令,表示执行事务,把事务队列中的命令拿出来一一执行
错误写法
假如现在有一个转账业务,在看这篇文章之前,你可能会这样写
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
)
func main() {
// 上下文对象 充当事务队列
ctx := context.Background()
// 创建 Redis 客户端
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379", // Redis 所在地址和端口号
Password: "", // 密码
DB: 0, // 选择数据库
})
// 现在 user1 要向 user2 转账 50
// Redis 中对应键值对为 user1:50 user2:0 表示 user1 的余额为 50 user2 的余额为 0
// 使用 MULTI 开启事务
pipe := client.TxPipeline()
// 此处省略逻辑判断,比如判断 user1 余额是否充足等
// 如果满足条件
pipe.IncrBy(ctx, "user1", -50) // 减去 user1 的余额
pipe.IncrBy(ctx, "user2", 50) // 加上 user2 的余额
// 执行事务
_, err := pipe.Exec(ctx)
if err != nil {
fmt.Println("事务执行失败:", err)
return
}
fmt.Println("事务执行成功")
}
把对 user1 和 user2 的余额操作放在同一个事务中,满足条件后一起执行。这样看起来没有什么问题,但是上面也说了,Reeids 中的事务机制只是逐个执行事务队列中的命令,并不能保证原子性
对于上面的代码,对 Redis 的操作就等同于
MULTI
INCRBY user1 -50
INCRBY user2 50
EXEC
由于不能保证原子性,在并发场景下,经过逻辑判断到执行完事务期间,可能造成多次增加余额和多次减去余额,造成数据不一致
所以这样写并没有什么意义,不能保证增加余额和减去余额同时成功或同时失败
正确写法
正确写法应该是使用 WATCH 命令来实现乐观锁,保证事务原子性
WATCH 命令可以监视一个或多个键,如果在事务执行之前这些键被其他客户端修改了,事务将被打断并返回一个错误
在并发场景下,当多个客户端需要对同一个数据进行操作时,为了避免数据不一致或者冲突,可以使用 WATCH 命令来监视这些数据。如果在事务执行之前有其他客户端修改了被监视的数据,事务就会失败,这时候可以根据业务逻辑选择重试事务或者执行其他操作
package main
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
)
func main() {
// 上下文对象 充当事务队列
ctx := context.Background()
// 创建 Redis 客户端
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379", // Redis 所在地址和端口号
Password: "", // 密码
DB: 0, // 选择数据库
})
// 现在 user1 要向 user2 转账 50
// Redis 中对应键值对为 user1:50 user2:0 表示 user1 的余额为 50 user2 的余额为 0
// 使用 WATCH 监视 user1 和 user2
watchKeys := []string{"user1", "user2"}
err := client.Watch(ctx, func(tx *redis.Tx) error {
// 使用 MULTI 开启事务
pipe := tx.Pipeline()
// 此处省略逻辑判断,比如判断 user1 余额是否充足等
// 如果满足条件
pipe.IncrBy(ctx, "user1", -50) // 减去 user1 的余额
pipe.IncrBy(ctx, "user2", 50) // 加上 user2 的余额
// 执行事务(一般需要设置重试次数,事务执行失败后重新执行直到成功)
_, err := pipe.Exec(ctx)
if err != nil {
fmt.Println("事务执行失败:", err)
return err
}
fmt.Println("事务执行成功")
return nil
}, watchKeys...)
if err != nil {
fmt.Println("事务执行失败:", err)
return
}
}
在这段代码中,使用 client.Watch 方法来监视 user1 和 user2 这两个键。在回调函数中开启事务并执行转账逻辑。如果在监视期间这两个键被其他客户端修改,事务将会失败。一般来说,在事务失败后,需要重新执行这个事务直到执行成功
这样使用 WATCH 命令后,保证了增加和减去余额的同时成功或失败,保证了事务的原子性
总结
Redis 中的事务只是一种顺序执行命令的机制,不能保证原子性,与 Redis 事务相关的命令有:
- multi:开启事务
- exec:执行事务
- discard:放弃事务
- watch:监控键
- unwatch:取消监控
在数据一致性要求高的场景,要使用 watch 命令保证事务的原子性