redis单元化

概述

什么是单元化

单元化是指异地多活架构,例如北京与深圳两个数据中心各部署一套Redis实例,两者数据保持同步。业务层保证只对本地单元的数据进行写操作,但可以读取其他单元的数据。

单元化主要有两个优势:

  1. 异地多活:两个数据中心同时提供服务,一个故障时另一个可以继续提供服务
  2. 低时延:读写操作都在本地数据中心完成,避免跨地域网络延迟

本文档不详细讨论单元化的优势与理念,主要讲解Redis单元化的具体实现方案。

方案总体介绍

单元化方案的核心设计:

  1. 通过一个同步工具将两个Redis主节点构成一条单元化链路,该同步工具可以单向或双向地同步两者的写操作
  2. 将对端单元传过来的数据加上单元归属信息(如unit_id),本单元不允许对其他单元的key做任何写操作(包括淘汰、过期等)

整体部署架构

下面展示了单元化系统的整体部署架构。同步工具是整个系统的核心组件,负责连接两个单元的Redis主节点,并协调数据同步。

整体部署架构

架构说明

  • 同步工具:单点部署,同时连接两个Redis master,负责在两个单元之间同步写操作
  • Redis Master:每个单元的主节点,负责处理本地写请求和同步过来的命令
  • 从节点:每个单元的从节点,通过常规主从复制与本地主节点同步
  • 数据流向:同步工具从源端获取binlog,经过处理后转发到目标端

** failover 机制**:同步工具同时监听两个单元的Sentinel,当检测到主从切换时自动重连到新的主节点。


同步工具详解

设计理念:无状态

同步工具采用无状态设计,这是整个系统可靠性的基础:

  • 所有binlog持久化到本地临时文件
  • 可随时重启,从上次位点继续同步
  • 不依赖外部状态,只需配置文件

这种设计使得同步工具可以任意切换,failover时只需重新连接即可。

内部架构

同步工具内部包含两组协程:一组负责与源端连接,另一组负责与目标端连接。

内部架构

源端协程职责

  1. 连接源端Redis
  2. 解析binlog
  3. 写入内存缓冲区
  4. 刷盘到临时文件

目标端协程职责

  1. 从临时文件读取binlog
  2. 发送到目标端
  3. 处理ACK确认
  4. 更新位点索引

数据流转(内存+临时文件交互)

同步工具需要处理binlog的持久化与传输,确保数据不丢失。

数据流转

  • 内存缓冲区:暂存待发送的binlog,达到阈值(约256KB-1MB)后刷盘
  • 临时文件:顺序写入,按binlog位点建立索引
  • 清理机制:目标端ACK确认后,删除已确认的binlog文件
  • 重启恢复:通过索引文件快速定位到上次同步位点

回环检测机制

同步工具通过以下方法防止命令回环——即一条命令不会经过同步工具两次:

  1. 每个真实命令前追加 UNIT_SYNC_BINLOG 命令(作为标记)
  2. 回环检测逻辑:如果先收到 UNIT_SYNC_BINLOG,则后续命令为回环命令,直接丢弃

回环检测机制


同步流程详解

连接建立

同步工具启动时,首先需要与两个Redis节点建立连接。

  1. 同步工具向目标端(r2)发起连接,通过自定义命令 UNIT_SYNC_NEW_SESSION,参数包括:源单元名、链路名称、session ID
  2. 从r2的响应中获取其单元化的repl_id和offset(这是该同步链路的当前同步位点)
  3. 建立与源端(r1)的连接,发起 KUNIT_PSYNC 命令,附带刚获取的 repl_idoffset

全量同步(RDB解析)

当源端判断需要全量同步时,会返回RDB数据。同步工具需要解析RDB并转发到目标端。

每个RDB的key-value对,会被包装成一个MULTI EXEC事务,包含三条命令:

  1. UNIT_SYNC_BINLOG:附带session_id、binlog_seq(递增)、repl_id、offset、源单元名和链路名
  2. UNIT_SYNC_PRE_RESTORE + key:告知目标端即将执行RESTORE
  3. RESTORE + 命令参数(REPLACEABSTTL

在所有RDB命令解析完成后,会增加一条 UNIT_SYNC_BINLOG,附带真正的 repl_idoffset,用于告知目标端同步位点。

增量同步(稳态)

稳态阶段,同步工具持续在源端和目标端之间同步增量命令。

增量同步流程

与源端(r1)的稳态交互

  1. 持续解析从r1收到的命令
    • 如果是unit命令,只更新offset,不持久化到文件
    • 否则包装成MULTI命令,持久化到本地文件中
  2. 每秒向r1回复一次 REPLCONF ACK offset

与目标端(r2)的稳态交互

  1. 持续从本地文件读取之前持久化的命令,发送给r2
  2. 将已发送但未收到回复的请求附带seq记录到队列 binlog_info_q
  3. 持续从r2收回复包,每收到一个命令的回复,删除队列中对应记录
  4. 定期根据队列记录的进度,删除目标端已收到的命令对应的文件

位点管理与持久化

Redis引入单元化同步的进度结构,相当于在常规的repl_offset基础上增加了unit_repl_offset:

  • 每个节点记录自身的unit_repl_id和unit_repl_offset,主要用于重连时让同步工具获取该节点单元化的同步位点
  • 对于单一节点,单元化写操作的binlog和普通命令产生的binlog共用缓冲区和repl_id/offset

Redis端实现

关键数据结构

KUnitSession

单元同步会话结构,维护跨单元同步状态:

1
2
3
4
5
6
7
8
9
10
11
12
struct KUnitSession {
sds unit_name; /* 源单元名称 */
sds rl_name; /* 同步链路名称 */
sds sid; /* 会话ID */
sds binlog_seq; /* 当前会话的binlog序列号 */

/* 对应源Redis的同步信息 */
sds repl_id; /* 复制ID */
int64_t offset; /* 位点 */

int64_t last_active_time_ms; /* 最后活跃时间,用于淘汰 */
};

每个Redis最多支持16个并发会话(KUNIT_SESSION_COUNT_MAX)。

robj.kunit

Redis对象中的单元归属信息字段:

1
2
3
4
struct {
uint32_t unit_id; /* 所属单元ID,0表示本单元 */
uint32_t touch_time_sec; /* 最后写操作时间(秒) */
} kunit;
  • unit_id = 0:表示该key属于本单元
  • unit_id > 0:表示该key属于其他单元

UnitIdPair

记录单元名称与ID的映射关系:

1
2
3
4
struct UnitIdPair {
sds unit_name; /* 单元名称,如 "beijing" */
uint32_t unit_id; /* 单元ID,从1开始递增 */
};

自定义命令

KUNIT_SYNC_NEW_SESSION

同步工具初始化时获取目标端的同步进度。

1
2
请求: KUNIT_SYNC_NEW_SESSION <UNIT_NAME> <RL_NAME> <SID>
响应: [<REPL_ID>, <OFFSET>]

KUNIT_SYNC_BINLOG

更新同步位点并设置后续命令的key的单元归属信息。

1
2
3
4
5
请求: MULTI
KUNIT_SYNC_BINLOG <SID> <BINLOG_SEQ> <REPL_ID> <OFFSET> <UNIT_NAME>
<实际命令>
EXEC
响应: +OK +QUEUED +QUEUED ... <EXEC响应>

KUNIT_SYNC_PRE_RESTORE

在RESTORE命令执行前做校验,检查key的单元归属信息是否匹配。

1
2
3
4
5
请求: MULTI
KUNIT_SYNC_BINLOG ...
KUNIT_SYNC_PRE_RESTORE <KEY>
RESTORE ...
EXEC

写命令处理逻辑

对于本地写命令,将单元归属信息置为 unit_id=0,表明该key属于本单元。

对于单元化同步过来的写命令(在MULTI中),处理流程如下:

  1. 在EXEC执行前,清空所有预置的单元归属信息
  2. 依次执行命令,执行到UNIT_SYNC_BINLOG时,设置后续key的单元归属信息
  3. dbAdd时,将该单元归属信息写入value对象

关键函数说明

keyBelongToThisKUnit()

判断key是否属于本单元:

1
2
3
4
5
6
7
8
int keyBelongToThisKUnit(redisDb *db, robj *key) {
dictEntry *de = dictFind(db->dict, key->ptr);
if (de == NULL) {
return 0;
}
robj *val = dictGetVal(de);
return val != NULL && val->kunit.unit_id == 0;
}
  • 返回 1:key属于本单元,可以被淘汰或过期
  • 返回 0:key不属于本单元,不进行淘汰或过期操作

lookupKeyWriteWithFlags()

写操作时更新unit_id:

1
2
3
4
5
6
7
8
9
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
robj *o = lookupKey(db, key, flags | LOOKUP_WRITE);
/* 任何带LOOKUP_NOTOUCH标志的写命令不应改变值的单元元信息 */
if (o != NULL && !(flags & LOOKUP_NOTOUCH)) {
o->kunit.unit_id = getKUnitBinlogTransUnitID();
o->kunit.touch_time_sec = time(NULL);
}
return o;
}
  • 本地写命令:unit_id 设置为0(属于本单元)
  • 同步过来的命令:unit_id 设置为对端单元ID

propagateDeletionForKunit()

单元内删除不传播到从节点:

1
2
3
void propagateDeletionForKunit(redisDb *db, robj *key, int lazy) {
propagateDeletionImpl(db, key, lazy, 1); /* 标记为kunit删除 */
}

只有当key属于本单元时,才向从节点传播删除操作。

关键配置项

allow-evict-non-local

是否允许淘汰非本单元key:

行为
no (默认) 只淘汰属于本单元的key (unit_id=0)
yes 允许淘汰所有key,不区分单元
1
2
# 默认配置 - 只淘汰本单元的key
allow-evict-non-local no

intra-unit-expiration-enable

是否允许单元内key过期:

行为
no (默认) 本单元不处理其他单元key的过期
yes 允许处理所有key的过期
1
2
# 默认配置 - 不处理非本单元key的过期
intra-unit-expiration-enable no

其他相关配置

  • unit-name:本单元名称,用于标识
  • unit-id:本单元ID(自动生成,也可手动指定)

Failover处理

同步工具本身是无状态的,可以任意更换。因此failover处理主要关注源端和目标端的主从切换场景。

同步工具同时监听源端和目标端Sentinel的+switch-master频道,检测主从切换事件。

源端master发生主从切换

当同步工具检测到源端failover事件时:

  1. 断开与源端的连接
  2. 建立与源端新主节点(原slave)的连接
  3. 使用原先保存的repl_id和offset尝试构建psync

增量复制判断:在实践经验中,由于同步延迟一般很低,基本都是增量复制。当主从切换时,Redis内核会将原先的repl_id变为repl_id2,并生成新的repl_id。当psync连进来时,若判断可以增量复制,Redis新主会回复”+continue repl_id”。

  1. 同步工具更新repl_id参数,并通过KUNIT_SYNC_BINLOG通知目标Redis也更新该repl_id

目标端master发生主从切换

目标端的所有主从节点都保存了本次单元同步链路的信息,包括unit_repl_id和unit_repl_offset等。

当检测到目标端failover时,同步工具只需:

  1. 使用KUNIT_SYNC_NEW_SESSION重新获取同步位点
  2. 从头开始同步(通常也是增量复制)

附录:请求同步流程图

请求同步