dqlite 分布式 SQLite 教程 / 第 3 章:架构深度解析
第 3 章:架构深度解析
本章深入剖析 dqlite 的内部架构,包括 Raft 共识实现、日志复制流程、快照机制和成员变更协议,帮助你理解 dqlite 的工作原理。
3.1 整体架构
dqlite 的架构可以分为四层:
┌─────────────────────────────────────────────────┐
│ Application Layer │
│ (Your App / LXD / MicroK8s) │
├─────────────────────────────────────────────────┤
│ dqlite Client Layer │
│ (Connection Pool, Request Router) │
├─────────────────────────────────────────────────┤
│ dqlite Protocol Layer │
│ (Binary Protocol, MessagePack Encoding) │
├──────────────────────┬──────────────────────────┤
│ Raft Consensus │ SQLite Storage │
│ ┌──────────────┐ │ ┌─────────────────┐ │
│ │ Leader │ │ │ WAL Mode │ │
│ │ Election │ │ │ Page Cache │ │
│ │ Log Storage │ │ │ B-tree │ │
│ │ Snapshot │ │ │ Shared Cache │ │
│ └──────────────┘ │ └─────────────────┘ │
├──────────────────────┴──────────────────────────┤
│ Transport Layer │
│ (libuv, TCP, TLS) │
└─────────────────────────────────────────────────┘
3.1.1 各层职责
| 层次 | 组件 | 职责 |
|---|---|---|
| 应用层 | 用户程序 | 发起 SQL 查询和写入请求 |
| 客户端层 | 连接池 | 管理到集群节点的连接,路由请求到 Leader |
| 协议层 | dqlite 协议 | 二进制协议编解码(基于 MessagePack) |
| 共识层 | C-raft | Raft 共识、日志复制、Leader 选举 |
| 存储层 | SQLite | SQL 执行、数据持久化 |
| 传输层 | libuv | 异步网络 I/O、TLS 加密 |
3.2 Raft 共识实现
dqlite 使用内嵌的 C-raft 库(libraft)实现 Raft 共识协议。
3.2.1 Raft 角色状态机
每个 dqlite 节点在任意时刻处于以下三种角色之一:
┌──────────────┐
超时 │ │ 收到多数票
┌──────────▶│ Candidate │─────────────────┐
│ │ │ │
│ └──────────────┘ ▼
┌──────────────┐ ┌──────────────┐
│ │ 发现更高任期 │ │
│ Follower │◀───────────────────────────│ Leader │
│ │ │ │
└──────────────┘ └──────────────┘
▲ │
│ ┌──────────────┐ │
│ │ │ │
└───────────│ 候选人超时 │◀──────────────┘
发现新 │ 或发现新 │ 发现更高任期
Leader │ Leader │
└──────────────┘
| 角色 | 职责 | 触发条件 |
|---|---|---|
| Follower | 被动接收日志和心跳 | 启动时默认角色 |
| Candidate | 发起选举请求 | 选举超时未收到心跳 |
| Leader | 协调所有写入、发送心跳 | 赢得多数票选举 |
3.2.2 Leader 选举流程
时间线:
t0: Follower 选举超时(150-300ms 随机)
t1: 转为 Candidate,任期 +1,投自己一票
t2: 向所有节点发送 RequestVote RPC
t3: 收到多数票响应 → 成为 Leader
t4: 开始发送心跳(AppendEntries 空日志)
dqlite 中的选举参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
| 选举超时 | 150-300ms | 随机化以避免选票分裂 |
| 心跳间隔 | 50ms | Leader 发送心跳的间隔 |
| 最大日志滞后 | 0 | Candidate 日志不能落后于投票者 |
注意: 在高延迟网络(如跨数据中心)中,可能需要调整选举超时参数以避免频繁的 Leader 切换。
3.2.3 Quorum(法定人数)
Raft 要求多数节点确认才能提交日志:
| 集群节点数 | Quorum(多数派) | 可容忍故障数 |
|---|---|---|
| 1 | 1 | 0(无冗余) |
| 2 | 2 | 0(不推荐) |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
| 6 | 4 | 2 |
| 7 | 4 | 3 |
最佳实践: 始终使用奇数节点(3、5、7)。偶数节点不会增加容错能力,反而增加了 Quorum 开销。
3.3 日志复制(Log Replication)
3.3.1 日志条目结构
每个写操作在 dqlite 中被封装为一个 Raft 日志条目:
┌────────────────────────────────────────┐
│ Raft Log Entry │
├──────────────┬─────────────────────────┤
│ Term (8B) │ Leader 的任期号 │
│ Index (8B) │ 日志条目的全局唯一索引 │
│ Type (1B) │ 条目类型 │
│ Data (var) │ SQL 命令(MessagePack) │
└──────────────┴─────────────────────────┘
| 字段 | 类型 | 说明 |
|---|---|---|
| Term | uint64 | 创建此条目时的 Leader 任期 |
| Index | uint64 | 单调递增的日志序号 |
| Type | uint8 | 条目类型(命令、配置变更等) |
| Data | bytes | 序列化的 SQL 操作 |
3.3.2 写入流程详解
一次完整的写入操作经历以下步骤:
Client Leader Follower 1 Follower 2
│ │ │ │
│─── Open ──────▶│ │ │
│◀── OK ────────│ │ │
│ │ │ │
│─── Exec ──────▶│ │ │
│ "INSERT ..." │ │ │
│ │ │ │
│ │── AppendEntries ──▶│ │
│ │── AppendEntries ────────────── ▶│
│ │ │ │
│ │◀─ Success ─────│ │
│ │◀─ Success ─────────────────────│
│ │ │ │
│ │ [Quorum 达成] │ │
│ │ │ │
│ │── Apply to ──▶│ │
│ │ SQLite │ │
│ │ │ │
│◀── Result ────│ │ │
│ │ │ │
步骤分解:
- 客户端发送写请求 → Leader 节点
- Leader 创建日志条目 → 追加到本地日志
- Leader 广播 AppendEntries → 所有 Follower
- Follower 验证并存储日志 → 返回确认
- 多数节点确认 → 标记为已提交(Committed)
- 应用到 SQLite → 所有节点执行 SQL
- 返回结果 → 客户端
3.3.3 AppendEntries RPC
| 字段 | 说明 |
|---|---|
| term | Leader 当前任期 |
| leaderId | Leader 的节点 ID |
| prevLogIndex | 前一条日志的索引(用于一致性检查) |
| prevLogTerm | 前一条日志的任期 |
| entries[] | 待复制的日志条目 |
| leaderCommit | Leader 已提交的最高日志索引 |
Follower 接收 AppendEntries 的处理逻辑:
收到 AppendEntries:
1. 如果 term < 本地 term → 拒绝
2. 如果 prevLogIndex 处的条目不匹配 → 拒绝(日志不一致)
3. 追加/覆盖日志条目
4. 更新 commitIndex = min(leaderCommit, 最新条目索引)
5. 对已提交的条目执行 Apply(应用到 SQLite)
3.3.4 日志压缩
随着写入的增加,Raft 日志会不断增长。dqlite 通过 快照(Snapshot)机制压缩日志:
日志状态:
Index: 1 2 3 4 5 6 7 8 9
▲ ▲
│ │
lastSnapshotIndex lastLogIndex
│ │
[已快照,可删除] [活跃日志,保留]
快照后:
Index: 5 6 7 8 9
Snapshot: [包含 1-4 的完整状态]
▲
│
新的 lastSnapshotIndex
3.4 快照机制(Snapshot)
3.4.1 何时触发快照
dqlite 在以下条件下触发快照:
| 条件 | 默认阈值 | 说明 |
|---|---|---|
| 日志条目数量 | 1024 | 自上次快照后的日志条目数 |
| 日志大小 | 无硬限制 | 与数据库大小相关 |
配置示例(通过 C API):
/* 设置快照阈值 */
struct raft_configuration config;
config.trailing_entries = 1024; /* 保留的最近日志条目数 */
3.4.2 快照创建流程
Leader 创建快照流程:
1. 暂停 Apply(停止应用新日志)
2. 调用 SQLite checkpoint(将 WAL 写入主数据库)
3. 复制 SQLite 数据库文件作为快照
4. 记录快照对应的 lastLogIndex 和 lastLogTerm
5. 删除 lastSnapshotIndex 之前的日志条目
6. 恢复 Apply
Follower 接收快照流程:
1. 收到 InstallSnapshot RPC
2. 保存快照数据到临时文件
3. 替换本地 SQLite 数据库
4. 丢弃快照之前的所有日志
5. 更新 lastSnapshotIndex
6. 恢复正常日志接收
3.4.3 InstallSnapshot RPC
| 字段 | 说明 |
|---|---|
| term | Leader 当前任期 |
| leaderId | Leader 的节点 ID |
| lastSnapshotIndex | 快照包含的最后一个日志索引 |
| lastSnapshotTerm | 该索引对应的任期 |
| data | 快照数据(SQLite 数据库文件内容) |
| offset | 数据偏移量(支持分片传输) |
| done | 是否为最后一个分片 |
3.4.4 快照与 WAL 的关系
dqlite 使用 SQLite 的 WAL(Write-Ahead Logging)模式,快照过程中需要处理 WAL 文件:
快照前:
db.sqlite (主数据库文件)
db.sqlite-wal (WAL 文件,包含未合并的变更)
db.sqlite-shm (共享内存文件)
SQLite Checkpoint:
WAL 内容 → 合并到 db.sqlite
WAL 文件 → 清空或截断
快照内容:
db.sqlite (包含所有已提交数据)
(WAL 通常为空或只有最近的小量变更)
注意: 快照操作会导致短暂的 I/O 峰值和可能的写入暂停。在生产环境中,建议监控快照频率和持续时间。
3.5 成员变更(Membership Changes)
集群运行过程中可能需要增加或移除节点。dqlite 支持 单步成员变更(Single-Step Membership Change)。
3.5.1 成员变更类型
| 操作 | 说明 | 风险 |
|---|---|---|
| 添加节点 | 新节点加入集群 | 低 |
| 移除节点 | 节点离开集群 | 中(确保 Quorum) |
| 替换节点 | 旧节点被新节点替代 | 高(需谨慎) |
3.5.2 添加节点流程
添加节点 Node 4 到 {1, 2, 3} 集群:
1. 管理员发起 AddNode(4) 请求 → Leader
2. Leader 创建配置变更日志条目
3. 复制到所有现有节点并提交
4. Leader 开始向 Node 4 发送日志
5. Node 4 从头开始接收日志(或接收快照)
6. Node 4 完成同步后正式成为集群成员
配置序列:
C_old: {1, 2, 3} Quorum: 2
C_new: {1, 2, 3, 4} Quorum: 3
3.5.3 移除节点流程
从 {1, 2, 3, 4} 集群中移除 Node 4:
1. 管理员发起 RemoveNode(4) 请求 → Leader
2. Leader 创建配置变更日志条目
3. 复制到所有节点(包括 Node 4)并提交
4. Leader 停止向 Node 4 发送日志
5. Node 4 转为独立节点(或关闭)
配置序列:
C_old: {1, 2, 3, 4} Quorum: 3
C_new: {1, 2, 3} Quorum: 2
警告: 不要同时进行多个成员变更。确保每次变更完成且稳定后,再进行下一次。同时变更可能导致 Quorum 不可达,造成集群不可用。
3.5.4 安全成员变更的最佳实践
| 规则 | 说明 |
|---|---|
| 一次只变更一个节点 | 避免 Quorum 混乱 |
| 新节点先完成同步 | 确认新节点日志最新后再变更 |
| 不要移除 Leader | 先将 Leader 转移到其他节点 |
| 奇数节点原则 | 保持 3、5、7 个节点 |
| 变更期间避免写入 | 降低不一致风险 |
3.5.5 节点替换场景
当一个节点永久失效需要替换时:
# 场景:Node 2 永久失效,需要用 Node 5 替换
# 步骤 1:移除旧节点
dqlite-remove-node --id 2
# 步骤 2:准备新节点
# 在新机器上启动 Node 5(数据目录为空)
# 步骤 3:添加新节点
dqlite-add-node --id 5 --address "192.168.1.105:9001"
# 步骤 4:等待同步
# Node 5 会通过 InstallSnapshot 或日志重放完成数据同步
3.6 二进制协议(dqlite Wire Protocol)
dqlite 使用自定义二进制协议进行节点间和客户端-节点通信。
3.6.1 协议概述
| 特性 | 说明 |
|---|---|
| 编码格式 | MessagePack |
| 传输层 | TCP |
| 默认端口 | 9001 |
| 连接模型 | 每客户端一个连接 |
| 认证 | 可选 TLS + 客户端证书 |
3.6.2 消息格式
┌──────────────────────────────────────────┐
│ Message Frame │
├──────────┬──────────┬────────────────────┤
│ Type (1) │ Words (4)│ Body (variable) │
├──────────┼──────────┼────────────────────┤
│ uint8 │ uint32 │ MessagePack 编码 │
└──────────┴──────────┴────────────────────┘
3.6.3 请求类型
| 类型代码 | 名称 | 说明 |
|---|---|---|
| 0x01 | Open | 打开数据库 |
| 0x02 | Exec | 执行 SQL 语句 |
| 0x03 | Query | 执行查询 |
| 0x04 | ExecSQL | 执行 SQL(简版) |
| 0x05 | QuerySQL | 执行查询(简版) |
| 0x06 | Interrupt | 中断当前操作 |
| 0x07 | Add | 添加节点 |
| 0x08 | Assign | 分配角色 |
| 0x09 | Remove | 移除节点 |
| 0x0a | Dump | 导出数据库 |
| 0x0b | Cluster | 获取集群信息 |
| 0x0c | Transfer | Leader 转移 |
3.6.4 连接生命周期
Client dqlite Node
│ │
│──── TCP Connect ─────────────── ▶│
│ │
│──── Handshake (client ID) ───── ▶│
│◀──── Handshake (server ID) ─────│
│ │
│──── Open (database name) ────── ▶│
│◀──── OK (database ID) ──────────│
│ │
│──── Exec (SQL) ─────────────── ▶│
│◀──── Result ────────────────────│
│ │
│──── Query (SQL) ────────────── ▶│
│◀──── Rows ──────────────────────│
│ │
│──── Close ───────────────────── ▶│
│ │
3.7 共享缓存模式
dqlite 使用 SQLite 的 共享缓存(Shared Cache)模式来优化多连接场景下的内存使用。
3.7.1 共享缓存 vs 普通模式
| 特性 | 普通模式 | 共享缓存模式 |
|---|---|---|
| 缓存共享 | 每连接独立缓存 | 所有连接共享缓存 |
| 内存使用 | 高(每个连接一份) | 低(共享一份) |
| 锁粒度 | 表级锁 | 页级锁(WAL 模式) |
| 并发读 | 支持 | 支持 |
| 并发写 | 串行 | 串行(单 Writer) |
3.7.2 dqlite 的内部连接管理
┌─────────────────────────────────────┐
│ dqlite 节点 │
│ ┌───────────────────────────────┐ │
│ │ 连接池 (Connection Pool) │ │
│ │ ┌────────┐ ┌────────┐ │ │
│ │ │Conn 1 │ │Conn 2 │ ... │ │
│ │ └───┬────┘ └───┬────┘ │ │
│ │ │ │ │ │
│ │ ┌───▼──────────▼──────────┐ │ │
│ │ │ Shared Cache │ │ │
│ │ │ (SQLite Shared Cache) │ │ │
│ │ └───────────┬─────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────▼─────────────┐ │ │
│ │ │ SQLite Storage │ │ │
│ │ │ (WAL Mode) │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
3.8 数据持久化
3.8.1 文件结构
dqlite 数据目录中的文件:
data/
├── 1 # Raft 日志数据库(SQLite 格式)
├── 1-wal # Raft 日志的 WAL 文件
├── <db-name> # 用户数据库文件
├── <db-name>-wal # 用户数据库的 WAL 文件
└── <db-name>-shm # 共享内存文件
| 文件 | 说明 |
|---|---|
1 | Raft 元数据和日志存储(SQLite 格式) |
<db-name> | 用户创建的数据库(如 test.db) |
-wal | Write-Ahead Log 文件 |
-shm | 共享内存索引文件 |
3.8.2 数据持久化保证
| 保证 | 说明 |
|---|---|
| 已提交数据不丢失 | 日志被多数节点持久化后才提交 |
| 崩溃恢复 | SQLite WAL 支持自动恢复 |
| 快照一致性 | 快照是某个时刻的完整数据库副本 |
注意: 必须确保数据目录所在的文件系统支持
fsync。不建议将 dqlite 数据放在 tmpfs 或网络文件系统(NFS)上。
3.9 故障模型
3.9.1 dqlite 能处理的故障
| 故障类型 | 影响 | 处理方式 |
|---|---|---|
| Follower 崩溃 | 短暂不可用 | Leader 继续服务,节点恢复后自动同步 |
| Leader 崩溃 | 短暂不可写 | Follower 触发选举,新 Leader 接管 |
| 网络分区 | 少数派不可用 | 多数派继续服务 |
| 节点慢 | 写入延迟增加 | 自动降级为 Follower |
3.9.2 dqlite 不能处理的故障
| 故障类型 | 影响 | 解决方案 |
|---|---|---|
| 多数节点同时故障 | 集群不可用 | 等待节点恢复 |
| 数据文件损坏 | 需要从备份恢复 | 定期备份 |
| 拜占庭故障 | 不保证正确性 | Raft 是 CFT 算法,非 BFT |
本章小结
| 要点 | 说明 |
|---|---|
| 架构层次 | 应用层 → 客户端层 → 协议层 → 共识层 + 存储层 → 传输层 |
| Raft 角色 | Follower、Candidate、Leader |
| 写入流程 | Client → Leader → 日志复制 → Quorum 确认 → Apply |
| 日志压缩 | 通过快照机制,定期清除旧日志 |
| 成员变更 | 单步变更,一次只改一个节点 |
| 通信协议 | 自定义二进制协议,基于 MessagePack |
下一章
→ 第 4 章:基本操作 — 学习如何创建数据库、执行 SQL 操作和管理连接。