讨论 Redis 事务时,我们关注的是
讨论 RDBMS 时,事务是需要 ACID 的:
- Atomicity: 原子性,事务是不可分割的单元
- Consistency: 一致性,包括
- 提交成功的事务产生的效果要能被后续的所有事务读取到
- 不能破坏数据库约束
- 提交成功的事务里,所有操作都要成功执行
- Isolation: 隔离性,多个并发事务不能互相影响
- Durability: 持久性,提交成功的事务不能丢失
但对于 redis 来说,情况有些不同。首先是 durability 是不用想了,redis 肯定做不到。 Isolation, redis 没有并发,肯定没问题,也不需要考虑。我们只要考虑 atomicity 和 consistency.
Atomicity
原子性是一个很基本的要求。即使只是把 redis 当缓存来使用
HSET key1 field1 value2 field2 value2
EXPIRE key1 60
我们也不希望这两个操作中间被中断,EXPIRE
要是没执行成功,key1
占用的内存可能就永远没人去释放了。
Consistency
一致性的要求比较复杂,但 redis 是但线程的,又没有约束,所以前两点可以忽略。要关注的就是提交成功的事务里,所有操作是否能保证成功执行了。
Redis 的设计是:不行。例如,还是去 HSET
一个 key1, 但执行的时候,发现 key1 已经存在,但并不是 hash 类型,那这个命令就会执行失败。这如果是传统 RDBMS, 那整个事务都没办法提交,事务中的全部操作都不生效。但 redis 会忽略掉这个错误继续执行后面的操作,并能成功提交事务。
所以 Redis 不能满足一致性的要求,我们必须在写程序的时候自己保证,提交的命令都是不会出错的(或者出了错也没有什么影响的)。
小结
Redis 的事务,可以保证 atomicity 和 isolation, 但不能保证 consistency 和 durability. 并且,由于单线程的设计,原子操作一定是隔离的。所以我们后面就集中来看 aomticity 这点了。
Redis 中实现原子操作的 3 种方式
-
单个命令是原子的
如
HSET key1 field1 value1 field2 value2
, 能保证field1
和field2
一起设置上 -
通过事务命令
MULTI HSET key1 field1 value2 field2 value2 EXPIRE key1 60 EXEC
Redis 服务器缓存
MULTI
后的命令,直到EXEC
再一起执行。 -
通过 lua 脚本
local ret = redis.call('hset', KEYS[1], 'field1', ARGV[1], 'field2', ARGV[2]); redis.call('expire', KEYS[1], ARGV[3]); return ret;
EVAL <脚本> 1 key1 value1 value2 60
EVAL
是单独的一条命令,自然整个脚本的执行都是原子的。
Transaction 与 Lua 脚本对比
首先,可以看出,简单的操作,transaction 是要比 lua 简洁的。但 lua 能实现更加复杂的逻辑,例如:
local x = redis.call('get', KEYS[1]);
return redis.call('set', KEYS[2], x + 1);
这个操作用 MULTI-EXEC 是实现不了的,因为操作是原子的,在 EXEC
执行之前,前面的命令都无法执行,无法返回结果,自然也就无法计算 x + 1 等于什么。
如果一定要用 transaction, 这个操作可以用 WATCH-MULTI-EXEC 来做,但 optimsitic locking 可能会失败,需要反复重试,这里就是 lua 更简单高效了。
所以,我们可以在简单的命令里用 MULTI-EXEC, 复杂的逻辑用 lua 脚本。当然,用 MULTI-EXEC 时要配合 pipeline 使用,否则每发送一条命令都等待响应的 RTT 会严重影响性能。
关于 Pipeline
只使用 pipeline 不能保证原子性!!!
在协议上,pipeline 纯粹就是一个针对 RTT 的优化,可以批量发送命令:
-
没有 pipeline 时,每个命令都要等待响应
-
有 pipeline 时,可以发送多个命令后一起等待
这种方式并不能保证 command 1 和 command 2 是原子的,当有多个 client 同时和 server 通信时,完全可以变成:
这里,对于 client A 来说,它确实使用了 pipeline 操作,连续发送两个命令,然后一起等待响应;但 server 却把两个命令分开处理了。
这种情况在开发的时候可能不容易观察到。如果一个 pipeline 中的数据比较少(比如小于一个 TCP 包的大小),在客户端 redis 库、客户端内核的缓冲下,可能就是这个 pipeline 的命令都在一个 TCP 包中发了出去;server 也一次性地从内核缓冲区把整个 pipeline 请求读出来处理了。这种情况下,看起来就是原子的。
但如果一个 pipeline 的数据比较多,大小超过了 TCP 包的大小,那发送出去的多个包 server 就不一定能一次性收到了。可以说在负载比较高的时候,必定会出问题的。
Redis Cluster
在生产环境中一般会使用 redis cluster. Redis cluster 没有分布式事务,这会对我们能使用的原子操作产生限制。
简单地看一下 redis cluster 是如何实现的:
- redis 对 key 做 hash, 把 key 分配到 16384 个 hash slot 中,然后再把 hash slot 和集群节点绑定到一起
- hash slot 和节点的绑定关系可以通过通过命令改变,这可以用来从集群中增删节点,平衡数据。改变绑定关系会导致数据迁移。数据迁移不是原子的。
那么 redis cluster 的原子操作要根据 key 的异同分 3 种情况来看:
- 操作中所有的 keys 都相同
- 操作中所有的 keys 都属于同一个 hash slot
- 操作中的 keys 属于多个 hash slot
其中 3 是不用看了,无法支持原子操作。而属于同一个 hash slot 时,要分两种情况:
-
集群处于稳定状态:这时同一个 hash slot 的 keys 都在同一个节点上,可以实现原子操作
-
这个 hash slot 正在执行数据迁移:这时操作中的 keys 可能不在同一个节点上,操作可能产生部分失败的结果
迁移过程中,一个 slot 的一部分 keys 在源节点上,另一个部分 keys 在目的节点上。Client 根据 slot 映射,向源节点发送请求;这时如果操作的 key 在目的节点上,server 就会返回 ASK 重定向,操作失败。
这时如果操作的是都是同一个 key, 那么它要么在源节点上,要么在目的节点上,所有操作要么全部成功,要么全部返回重定向。全部返回重定向时,整个事务都没有产生任何效果,这时 atomicity 和 consistency 是可以保证的。
但如果操作的不是同一个 key, 那就要可能部分 keys 操作成功,另一部分返回了重定向。只有一部分命令成功,这时 consistency 就破坏了。
所以,如果要保证 atomicity 和 consistency, redis cluster 的一个事务只能操作一个 key.
总结
- 单个命令是原子的
- MULTI-EXEC 事务命令,和 lua 脚本都是原子的
- MULTI-EXEC 要配合 pipeline 使用,否则性能会比 lua 脚本差
- WATCH-MULTI-EXEC 不如 lua 脚本
- Redis cluster 上,不管用 MULTI-EXEC 还是 lua 脚本,只有操作单个 key 的事务才能保证 consistency. 如果操作了多个 key, 可能会因为数据迁移,产生部分失败的结果
后记: redis 文档里说,lua script 一般比 multi-exec 更快、更简单
A Redis script is transactional by definition, so everything you can do with a Redis transaction, you can also do with a script, and usually the script will be both simpler and faster.
最后修改于 2021-11-13