Redis 通过
MULTI,EXEC,DISCARD,WATCH.UNWATCH
来实现事务功能。
Redis 事务介绍
提到事务, 我们可能马上会想到传统的关系型数据库中的事务, 客户端首先向服务器发送 BEGIN
开启事务, 然后执行读写操作, 最后用户发送 COMMIT
或者 ROLLBACK
来提交或者回滚之前的操作。但是 Redis 中的事务与关系型数据库是不一样的,Redis 通过 MULTI
命令开始, 之后输入一连串的操作, 最终以 EXEC
结束, 在这之间输入的所有的命令都会在 EXEC
之后一起发给 Redis 执行, 所以在这之间用户无法通过读取到的结果做处理, 这与关系型数据库的事务是由很大的不同的。Redis 会在执行完成之后返回一组执行结果。Redis 中并没有回滚的操作, 这一点会在后面说到。
Redis 的这种延迟执行事务会有助于提升性能, 客户端会在收到 EXEC
命令之后再将这一系列的命令一起发给 Redis, 然后等待 Redis 的回复, 这种 一次性发送多条指令, 然后等待回复
的做法称为流水线(pipeline) 模式, 它可以通过减少客户端与服务器之间的网络通信次数来提高 Redis 执行多个命令的性能。
Redis 通过以下两点保证事务:
- 事务中的所有命令都被序列化并按顺序执行,在执行事务的过程中不会去执行其他客户端的命令,保证命令作为单个隔离操作进行
- 要么处理所有命令,要么不处理。保证原子性。如果开启了 AOF,Redis 会使用单个 write 命令将事务写入文件中,如果因为某些原因导致 AOF 写入被截断,在重启时 redis 会报错,使用
redis-check-aof
工具可以修复这个错误(删除掉这个事务相关的命令), 保证 Redis 能够重新启动
Redis 事务示例
下面我们来看一些示例:
MULTI EXEC
127.0.0.1:6379[2]> set foo 1
OK
127.0.0.1:6379[2]> set bar 1
OK
127.0.0.1:6379[2]> MULTI
OK
127.0.0.1:6379[2]> INCR foo
QUEUED
127.0.0.1:6379[2]> INCR bar
QUEUED
127.0.0.1:6379[2]> EXEC
1) (integer) 2
2) (integer) 2
127.0.0.1:6379[2]>
可以看到在执行 MULTI
之后会返回 OK
表示状态回复, 然后执行两个 INCR
操作, 会返回 QUEUED
表示已经进入到队列当中, 最后执行 EXEC
命令, 上述所有命令会一起发送到 Redis, 然后收到 Redis 的一组回复。
DISCARD
127.0.0.1:6379[2]> MULTI
OK
127.0.0.1:6379[2]> set test 09876
QUEUED
127.0.0.1:6379[2]> DISCARD
OK
127.0.0.1:6379[2]> get test
"1234"
127.0.0.1:6379[2]>
DICARD
可以取消事务
命令出现语法错误
下面来看以下如果这其中有语法错误的命令会怎么样:
127.0.0.1:6379[2]> MULTI
OK
127.0.0.1:6379[2]> set test 1234
QUEUED
127.0.0.1:6379[2]> lpush test 12345
QUEUED
127.0.0.1:6379[2]> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379[2]> get test
"1234"
可以看到, 最终返回结果是set
命令执行成功, 而 lpush
命令执行失败, 通过 get test
命令, 可以看到它的值是1234
。可以看到, 即使后续的命令出现了错误, 前面已经执行成功的命令也不会回滚, 同样也不会影响后续命令。
Redis 事务不支持回滚
Redis 认为只有 语法出现错误 时才会导致事务的失败, 并且 Redis 的速度够快,不需要回滚的能力。Redis 官方给出的解释是(我做了一下翻译):
如果你有关系型数据库的相关经验, 实际上 Redis 命令在事务期间可能会出现失败的情况, 但是 Redis 仍然执行了事务中剩余的命令而不是回滚, 在你看来这可能很荒谬。
但是对于这种操作有以下很好的见解:
- Redis 命令只有在出现语法错的情况下才会导致失败(这个问题没办法再入队列期间检测到), 或者这个 key 是错误的数据类型: 这意味着是编程错误造成的命令失败, 在开发过程中就应该检查到这种错误中, 而不是到生产中才发现
- Redis 内部简单而且速度很快, 不需要回滚的能力
一种反对 Redis 的观点是 bug 是会发生的, 但是通常回滚并不能解决编程错误所造成的结果. 例如, 如果查询一个 key 并递增了 2 而不是 1, 或者递增了错误的 key, 回滚机制将没办法提供帮助. 考虑到没有人解决编程错误, 而且 Redis 命令的失败并不太可能进入生产环境, 所以我们选择了不支持事务回滚的更快, 更简单的做法.
WATCH 命令的使用
Redis 使用WATCH
来解决 key 的竞争问题,类似于 CAS
操作,来保证多个客户端同时修改一个 key 的情况, 只能有一个客户端修改成功。
我用下面的示例演示一下 A,B 两个客户端竞争一个 Key 的情况:
Client A
127.0.0.1:6379[2]> GET count
"1"
127.0.0.1:6379[2]> WATCH count
OK
127.0.0.1:6379[2]> MULTI
OK
127.0.0.1:6379[2]> incr count
QUEUED
127.0.0.1:6379[2]> incr count
QUEUED
127.0.0.1:6379[2]> EXEC
(nil)
Client B
127.0.0.1:6379[2]> incr count
(integer) 2
在 A 客户端 WATCH count
之后, 如果 B 客户端执行了修改count
这个 key 的操作, 那么 A 客户端在 EXEC
之后会返回 nil
没有进行任何操作。
我们在来看一组没有竞争的情况:
127.0.0.1:6379[2]> get count
"3"
127.0.0.1:6379[2]> WATCH count
OK
127.0.0.1:6379[2]> MULTI
OK
127.0.0.1:6379[2]> INCR count
QUEUED
127.0.0.1:6379[2]> INCR count
QUEUED
127.0.0.1:6379[2]> EXEC
1) (integer) 4
2) (integer) 5
在没有多个客户端竞争的情况下, 事务正常执行。
Redis 并没有用典型的加锁功能来解决 key 的竞争问题, 主要原因是出于性能的考虑。回顾一下关系型数据库中的事务, 在访问以写入为目的的数据时, 数据库会对被访问的数据加锁, 直到提交或回滚之后才释放锁, 如果此时另一个客户端也这部分数据进行写入操作, 客户端将会被阻塞, 直到上一个事务结束。这种加锁的方式称为 悲观锁 , 它的缺点在于持有锁的客户端持有锁的时间越长, 其它客户端被阻塞的时间就越长。Redis 为了减少客户端等待的时间, 并不会在执行WATCH
命令后对数据进行加锁, 而是如果有其他客户端抢先修改了数据的情况下通知执行了 WATCH
的客户端, 这种做法叫做 乐观锁。我们只需在客户端执行事务失败之后进行重试的逻辑即可。
更多详细的资料参考:
Redis 事务官方文档
Redis 实战