Redis 中的原子操作:事务与 Lua 脚本
在 Redis 中,事务和 lua 脚本都能实现原子操作。事务比较简单,而 lua 脚本功能强大,能实现一些事务实现不了的操作。在用 redis cluster 时,事务和 lua 脚本都会受到很多限制,在开发应用时要注意这些限制。

讨论 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 种方式

  1. 单个命令是原子的

    HSET key1 field1 value1 field2 value2, 能保证 field1field2 一起设置上

  2. 通过事务命令

    MULTI
    HSET key1 field1 value2 field2 value2
    EXPIRE key1 60
    EXEC
    

    Redis 服务器缓存 MULTI 后的命令,直到 EXEC 再一起执行。

  3. 通过 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 时,每个命令都要等待响应

    without pipeline

  • 有 pipeline 时,可以发送多个命令后一起等待

    with pipeline

这种方式并不能保证 command 1 和 command 2 是原子的,当有多个 client 同时和 server 通信时,完全可以变成:

pipeline is not atomic

这里,对于 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 种情况来看:

  1. 操作中所有的 keys 都相同
  2. 操作中所有的 keys 都属于同一个 hash slot
  3. 操作中的 keys 属于多个 hash slot

其中 3 是不用看了,无法支持原子操作。而属于同一个 hash slot 时,要分两种情况:

  1. 集群处于稳定状态:这时同一个 hash slot 的 keys 都在同一个节点上,可以实现原子操作

  2. 这个 hash slot 正在执行数据迁移:这时操作中的 keys 可能不在同一个节点上,操作可能产生部分失败的结果

    迁移过程中,一个 slot 的一部分 keys 在源节点上,另一个部分 keys 在目的节点上。Client 根据 slot 映射,向源节点发送请求;这时如果操作的 key 在目的节点上,server 就会返回 ASK 重定向,操作失败。

    这时如果操作的是都是同一个 key, 那么它要么在源节点上,要么在目的节点上,所有操作要么全部成功,要么全部返回重定向。全部返回重定向时,整个事务都没有产生任何效果,这时 atomicity 和 consistency 是可以保证的。

    但如果操作的不是同一个 key, 那就要可能部分 keys 操作成功,另一部分返回了重定向。只有一部分命令成功,这时 consistency 就破坏了。

所以,如果要保证 atomicity 和 consistency, redis cluster 的一个事务只能操作一个 key.

总结

  1. 单个命令是原子的
  2. MULTI-EXEC 事务命令,和 lua 脚本都是原子的
  3. MULTI-EXEC 要配合 pipeline 使用,否则性能会比 lua 脚本差
  4. WATCH-MULTI-EXEC 不如 lua 脚本
  5. 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