今天一早,就有客户来报说机器负载 100%。从日志中看到的现象是,Slave 向 Master 请求部分同步,Master 却要求 Slave 做个全套:全量同步,因为 Slave 发过来的 replication ID 太老了。Slave 说好吧,整个全的,然后消费成功。接着,Slave 还想来个部分同步,上述的过程连续发生 3 次。Master 有点强买强卖的意思了,这样一来两边的 CPU 不干了,这不是加班嘛!二者之间的网络带宽也是突增,从下图可以看出:

为啥 Master 这么不讲理呢, 简单来说:

我们发现客户端的请求包含很多超过 4MB 的对象。这些对象挤占了 Master 的 buffer。Master 会将 Slave 发来的 replication ID 都存在 buffer 里,现在却被挤出去了。这样,Slave 再发来 SYNC 请求,Master 在 buffer 里找不到对应的 replication ID,就毫不吝啬地开启全量同步。

Redis 的副本同步

除了 Redis Cluster 和 Redis Sentinel 提供的 HA 方案,Redis 还自带基本的主从备份。Slave 会自动连接 Master 并获取它的数据副本。

  1. Master 和 Slave 都运行良好的情况下,Master 会定期向 Slave 发送命令流,其中包括客户端写,key 过期或逐出等更新操作。
  2. 如果 Master 和 Slave 的连接中断,不管是网络故障还是超时的原因,Slave 在重连之后会尝试执行部分同步,这意味着它只要获取中断期间未接收的命令。
  3. 如果部分同步不可行的话,Slave 只能退而求其次,执行全量同步。这个过程略复杂,首先 Master 要创建一个数据快照,并将它发给 Slave,然后发送快照之后执行的更新命令。

处于性能的考虑,Redis 默认采用异步复制。Master 将命令发给 Slave 之后不会等待后者的回复,而是继续执行用户的请求。当然,Slave 在执行命令后,仍会给 Master 发送确认。这样的做法,可以无缝切换到同步复制。

关于 Redis 的副本,有一些几点需要说明:

  • 一个 Master 可以有多个 Slave。
  • Slave 也能有 Slave。
  • 在 Master 这一侧,副本是非阻塞的。而在 Slave 上,大部分情况下是非阻塞的。Slave 启动之后会执行一个初始同步,这个阶段是可以接收用户请求的,只不过数据集是老的。初始同步后,老的数据集要删除,再加载新的数据集,这个窗口内,Slave 是不接受用户命令的。

每个 Master 都有一个 replication ID,并维护着每个 Slave 的 offset。这个 offset 是递增的。(replication ID, offset) 元组标识了 Master 上唯一版本的数据集。Slave 使用 PSYNC 命令将已处理的 replication ID 和 offset 发送给 Master。Master 这边就可以偷点懒,只将该版本之后的增量部分发给 Slave 就完事了。重点来了,Master 是将 backlog 存在 buffer 里的,如果 buffer 空间不足,或者 Slave 发来的版本找不着,那就只能执行全量同步了。

到这里肯定有会问,为什么不把同步的 backlog 持久化呢?当然可以这么做,确实能避免开头提出的问题。这就是权衡的问题了,replication ID 和 offset 才多大,保存在内存中快速读取岂不更好。而且同步操作这么频繁,每次刷磁盘不也是一种损耗吗?

全量同步

  1. Master 会启动一个后台进程,用来生成 RDB 文件。
  2. 同时,Master 将客户端发来的请求都放在 buffer 里。
  3. RDB 生成后,Master 将其加载到内存,发送给 Slave。
  4. 最后 Master 将 buffer 里的命令也发给 Slave。

常见的复制模型

既然说了 Redis 的复制,那就不妨展开一点,说说分布式系统的复制模型。如果觉得下段说得不好,建议直接看 Designing Data-Intensive Applications 第5章。如果时间还有充裕,建议把这本书从头到尾仔细读完。

为什么要复制

Replication 可译为复制,意味着在多台互联的机器上保存同一份数据,带来的好处也是显而易见的:

  • 数据的局部性使得客户端访问更小延迟。
  • 某些节点宕机的时候,系统能够继续对外提供服务,即增加可用性。
  • 支持横向扩展,增加吞吐量。

比较流行的复制算法有:

  • 单个 Leader
  • 多个 Leader
  • 无 Leader

单个 Leader

系统中有多个副本带来的一个问题:怎么保证最后的副本是相同的呢?要保证相同,每个更新操作必须到达所有副本并被执行。单个 Leader 模型,又叫 active-passive 或者 master-slave 模型,是这么干的:

  1. 指定一个副本为 leader(master/primary),接收客户端的写请求。
  2. 其他的副本作为 follower(slave/secondary/hot standby)。当 Leader 接收并执行更新请求后,把 changeset 发给 follower。每个 follower 拿到这个 changeset 将其更新到自己的本地副本里。
  3. Leader 和 follower 都能执行读命令,但写只能由 leader 来做。

用这种方式的典型代表包括:

  • MySQL,PostgresSQL
  • MongoDB,Espresso
  • Kafka,RabbitMQ

这种复制方式面临着同步还是异步的选择。同步的好处是 follower 和 leader 时刻保持一致。要是 leader 挂了,follower 中的数据仍然是最新的。同步的问题也很明显:性能。Leader 要 block 住用户的写操作,直到它确认 follow 运行正常。

异步的好处就是非阻塞,但短板就是如果 Leader 挂了,新的 leader 会丢失部分数据。

实践中常见的做法是,一个 follower 使用同步复制,其他的采用异步。这样保证至少两个节点的数据时最新的。这种方式可称为版同步。

加入一个新的 follower 是不用停服的:

  1. Leader 会建一个快照。
  2. 把快照复制给新的 follower。
  3. Follower 会联系 leader, 请求快照之后它接收到的请求。这一步需要快照和 replication log 中的位置建立映射关系。
  4. 当 follower 处理完了 leader 发来的 backlog 之后,它就追上了 Leader。

这个过程就是上面提到的 Redis 的全量同步。

如果 follower 不行挂了怎么恢复呢?答案很简单:日志。从日志中,follower 会知道它最后一次执行的操作。接着,它会请求 leader 将该操作之后的变化发过来。

Leader 挂了有点麻烦,会启动我们常说的 failover 过程:

  1. 察觉 leader 宕机。这个没有万全之法,大部分的系统都是用超时来判断,这需要节点之间维护彼此的心跳。
  2. 选择一个新 leader,这时候需要引入共识算法。
  3. 重配置系统使得新 leader 进入角色。客户端将写请求发给新的 leader。

复制日志的实现

Leader 发给 follower 的日志,实现的方式也多种多样。

一种是基于执行语句(statement)。Leader 记录了每个写请求的 statement,然后发给 follower。比如对一个关系型数据库来说,可能就是一些 INSERT, UPDATE, DELETE 之类的。这个方法不少弊端。首先就是类似 NOW() 这种非确定性的函数,会引发歧义。其次,一些表达式会有副作用,比如 trigger、存储过程、自定义函数等。

另一种方式是 WAL(Write-ahead Log),这种日志就是二进制的字节序列,包含了所有的写入。它的限制只能在尾部 append。目前 PostgreSQL、Oracle 等数据库都采用这种方式。这种日志非常 low level,与存储引擎极度耦合。

还有一种是逻辑日志,又称为 row-based 日志。它记录了以行为粒度的所有写操作:

  1. 如果是插入行,日志中包含了所有列的值。
  2. 如果是删除行,日志中包含了该行的唯一 ID。
  3. 如果是修改行,日志中包含该行的 ID 和所有列的最新值。

最后提到一种基于触发器的做法。借助触发器,客户端可以注册一些函数到服务端,如果数据发生变化,触发器就可以将变化写到另一张表里。

多个 Leader

多个 Leader 的情况常见跨机房,每个机房一个 Leader。每个 Leader 都能接收读写请求。每个机房里,还是和单 Leader 的情况相同。

多个 Leader 可能会引起写冲突,怎么办呢?

最直接的做法是同步检测冲突,每个写操作传播到其他 Leader 之后才宣告写成功。这种做法就牺牲了多 Leader 带来的好处。

另一种较简单的做法,是客户端做处理。由客户端来保证每条记录的操只会发给某个特定的 Leader。

无 Leader

无 Leader 的系统中,用户可以将写操作发给任一个副本。假设现在有 n 个副本,每个写操作需要得到 w 个副本的确认,每个读请求需要至少 r 个节点的相应。一旦 w + r > n,我们就能读到至少一份最新的数据。

这种 Quorum 的做法也并非十全十美:

  • 两个并发写,如果无法区分先后,唯一安全的做法就是合并它们。
  • 如果写操作没有在 w 个节点上成功执行,没有办法 roll back。
  • 如果带有最新数据的节点挂了重启,它的数据被某个带有旧值的副本覆盖,这时候就打破了 w 这个警戒。

写到这里,我都差点忘了是什么原因促成了这篇博客,就到这里吧。

References