Skip to content

Instantly share code, notes, and snippets.

@ideawu
Last active May 21, 2021 04:12
Show Gist options
  • Save ideawu/b3810a2fe34cdba9128a775a31d122e5 to your computer and use it in GitHub Desktop.
Save ideawu/b3810a2fe34cdba9128a775a31d122e5 to your computer and use it in GitHub Desktop.
数据库最常用的特性

根据我的从业经验, 用户对一个(分布式)数据库系统, 最看重这些特性:

  1. 多副本, 数据安全性
    • 能提供强一致性更好, 但基于性能考虑, 最终一致性(异步复制)有广泛的用户需求
  2. 节点容灾, 部分服务器故障, 不影响整体系统的运行
    • 方案选择, 倾向于增加无状态 proxy 层
  3. "伪装"成一个单机数据库实例, 底层细节对用户隐藏
    • 平滑扩容, 加机器不影响业务, 不需要业务在代码和架构上做改变
  4. 数据全球同步, 最终一致
    • 一个大区的日志序列是顺序 apply, 但多个大区的日志序列是乱序 apply
  5. TTL, 能设置数据的过期时间, 定期清理数据释放空间, 主要是运维需求, 节省用户的运维工作量
    • 不提供一致性, 及时性, 只有运维性
    • TTL是一种外部运维特性, 所以在存储系统之外向存储系统发送删除指令, 是运维操作, 不是内在特性
  6. 分页操作, 例如 MySQL 的 limit, Redis 的 zrange, 虽然技术上性能局限性比较突出, 但好用, 用户喜欢用
  7. 批量操作(性能), 例如 mget, mset, mdel, 主要是方便用户使用, 性能也有优势
    • Pipeline 也算批量操作
    • 批量操作如果不承诺原子性, 可以在客户端实现
  8. 可选持久化策略
    • auto: 只需要 write(), 不要求 fsync()
    • strong: 共识必须在磁盘 fsync() 之后
  9. 可选写一致性级别
    • 默认是单大区内(raft组)强一致性
    • 默认大区间是最终一致性(跨地域同步)
    • 提供 sync_write() 让用户等待同步进度, 在写数据之后调用
  10. 可选读一致性级别
    • 默认是单大区内(raft组)强一致性
    • 默认大区间是最终一致性(跨地域同步)
    • 用户可选 stale read: 读单机
    • 提供 sync_read() 记用户等待同步进度, 在读之前调用
  11. 提供事务能力(锁+快照), 多个写操作之间有关联和依赖
    • 悲观事务(读写, Transaction), 交互式 2PC, 有 commit point
      • begin() -> commit()
    • 原子写入/乐观事务(只写, AtomicWrite), 非交互式 2PC, 写操作之间无因果依赖关系
      • atomic() -> commit()
      • 若有参与者 prepare 失败(包括 CAS 冲突)则全部回滚
      • 相比悲观事务, 可以并发 prepare
    • 批量写入(只写, MultiWrite), 1PC, 忽略 CAS 冲突, 最终全部会被执行, LWW
      • multi() -> exec()
      • 真正执行时, 如果对象当操作的时间戳更新, 则直接丢弃操作(认为操作已经完成, 然后被覆盖)
      • hdel 操作的 CAS 比对的是元素的 mtime
      • hclear 操作的 CAS 比对的是容器的 ctime, 异步地遍历每一个元素检查 mtime 决定是否删除
    • 提供 lock 指令
    • 以事务启动时的时间戳作为所有参与者的 mtime
    • 读操作忽略未决资源(最终原子性, read committed, 不是 monotonic atomic view)
  12. 提供 binlog 和 redo log 订阅接口
  13. CAS 操作, CAS+原子性, 可以取代锁, 提供事务一致性保证
  14. MVCC 底层数据保留多个版本, 但带来的问题非常多, 例如容器计数也要保留多个版本

相关文章:

@ideawu
Copy link
Author

ideawu commented Feb 27, 2021

TTL 实现原理

TTL 功能做成外挂, 将数据的 version 和 ttl 保存到过期队列中, 按时间消费这个队列, 采用 CAS 接口删除数据. 不要把 ttl 和数据放到一起, 要分开放.

version 要注意被重置的问题. 例如, 一个 key 被删除后, 它的 version 会被重置为 0.

TTL 队列在 apply 的时候, 无论 leader 或者 follower 都维护, 但只在 leader 节点扫描处理.

状态机处理流程:

  1. server 收到 setex
  2. raft 共识
  3. apply 1, 将 index, ttl, version, 持久化到过期队列中
  4. apply 2, 将 index 和 value 原子性地持久化

过期扫描流程:

  1. ttl 过期
  2. 产生一条 del 指令, 达成 raft 共识
  3. apply 1, 原子性地保存 index 和从过期队列中删除 ttl
  4. apply 2, 原子性地保存 index 和删除 value

过期队列和数据是分开独立存储的, 队列也要保存 apply 进度. 先 apply 队列, 再 apply 数据.

如果同时在每一个数据项的 meta 里保存 ttl. 相当于 ttl 信息冗余两份. 这样, 队列就可以异步 apply.

@ideawu
Copy link
Author

ideawu commented Mar 2, 2021

  • 对分布式数据库的需求分两种: 开发需求和运维需求
  • zrank 接口要限制 max, 也即排名大于 max 的, 返回 -1
  • 存储节点的所有接口, 都要返回 binlog 的最新序号, 以便 proxy 能根据序号实现线性读等功能, 这些序号不暴露给最终用户
  • DBAAS, database as a service

@ideawu
Copy link
Author

ideawu commented Mar 14, 2021

CAS - 版本号, 修改时间

这两项信息是每一个数据的关键属性.

许多操作依赖时钟, 所以, 维护一个永不重复且不回绕的时钟非常必要. 每一个 raft 组有一个时钟, 由 leader 维护, leader 获取系统时钟, 然后与 raft 时钟对比, 如果系统时钟比 raft 时钟大, 则将 raft 时钟设置为系统时钟, 否则, raft 时钟加 1 作为新的时钟.

这样, 就可以把版本号和修改时间统一为同一个属性.

组分裂时, 新组继承旧组的时钟. 组合并时, 新组继承较大的时钟.

删除容器, 清理过期数据, 重放 redo log, 事务, 都依赖 CAS.

容器是 ctime(create time), 容器中的元素是 mtime(modify time).

etime: expire time

@ideawu
Copy link
Author

ideawu commented Mar 15, 2021

一个存储节点接受这些指令:

  • 正常的用户指令, 产生 binlog, binlog apply 产生 redo log
  • 从其它节点同步过来的 redo log, 产生 binlog, binlog apply 不产生 redo log
  • ttl 处理器发来的指令, 与正常用户指令相同

@ideawu
Copy link
Author

ideawu commented Mar 15, 2021

binlog, redo log, 强一致性, 最终一致性

最终一致性的系统, 节点之间只能同步幂等指令.

hclear 清空容器指令本身不是幂等的, 但是, 如果 hclear 带着 mtime, 逐个删除元素, 那么便是幂等的. hclear/zclear 删除操作不保证是(不实现成)原子操作. hclear 指令将改变容器的 ctime 属性, 如果 hclear 执行得比 hset 早, 那么 hset 的时候可以比对 ctime.

incr 指令不是幂等的, 但是, 又不能把它转换成 set 指令再添加到 binlog(raft log), 所以, 只能在 apply 的时候, 转成 set 指令添加到 redo log 中, 可以使用和 binlog 相同的 index, 让 binlog 和 redo log 一一对应.

可以现场根据 binlog 即时生成 redo log, 例如, binlog: set a 1; del a;, 对应的 redo log 是: noop; del a;.

binlog 用于强一致性, redo log 用于最终一致性(更好的选择). binlog 必须严格按顺序 exactly once 重放, 而 redo log 不限顺序, 不限次数, 重放时比对旧数据的 mtime. redo log 不需要侵入系统, 使用外部工具进行重放, 系统本身不需要记录进度.

欧洲从亚洲同步过来 redo log 后, 使用正常的 client 请求欧洲的数据库节点, 执行 redo log. 欧洲节点针对这些 redo log, 不再产生 redo log.

@ideawu
Copy link
Author

ideawu commented Mar 16, 2021

强一致性是手段, 目的是为了高可用

有人认为, MySQL 异步复制的目的是为了高可用, 这是错误的. 异步复制的目的是为了高性能: 减少响应时间, 负载均衡. 异步复制根本无法达到高可用的目的. 当 Master 节点宕机之后, 整个系统立即不可用, 所谓的自动主从切换是严重错误的, 众所周知会"丢数据", 负责任的系统不可能去自动切换主从. 支付宝所谓的异地多活, 出现故障时照样必须停止分区的服务, 只有人工确认之后, 公司层层签字之后才能切流量. 一个分区完全宕机, 谈何高可用?

可用性的定义是有争论的. 通常, 所谓的"高可用"是指整体高可用, 某些分区可能完全不可用, 但任意时刻大部分分区是可用的. 给定某个对象, 除非使用同步复制, 否则永远无法做到高可用

还有人认为同步复制不能高可用, 因为 Slave 宕机之后系统不可用. 这也是错误的. Raft 就是同步复制, 也是高可用的.

强一致性的系统, 高性能是附带的, 因为实现强一致性的手段, 或者说一致性所要解决的问题, 就是数据复制. 正因为有数据复制, 我们才需要强一致性.

基于同步复制而实现的一致性, 是实现高可用的最有效手段. 同时, 也能达到"高性能". 因为同步复制, 数据能扩散到多个节点. 因为有一致性策略协调节点如何处理请求, 所以能放心地切换主从. 异步复制当然也能实现强一致性, 例如全部回源的 Master 节点, 例如 Read Index.

同步复制必然受到光速(也即地理距离)的限制, 所以, 距离较远的两地, 不可能采用同步复制的强一致性策略, 只可能是异步复制的最终一致性.

但一个应用, 只有少部分功能需要涉及到资源抢占(也即锁), 这时才需要强一致性. 大部分情况下, 我们只需要最终一致性.

总结:

  • 数据复制是为了安全和高性能
  • 强一致性(同步复制)是为了高可用(容灾)
  • 最终一致性(异步复制)是为了高性能

@ideawu
Copy link
Author

ideawu commented Mar 19, 2021

系统架构

进程模块

  • node-server: 集群的存储节点
  • meta-server: 主动地从区域内的所有 node-server 采集状态信息, 同时持久化存储一些配置信息
  • proxy-server: (无状态)用户通过任意一个 proxy-server 访问区域内的 node-server. 依赖 meta-server
  • relay-server: 在集群的所有大区间同步 redo log, 然后连接对应组的 leader 节点重放
  • web-ui: 管理控制台

每个 node-server 包含多个 replica, 每个 replica 属于一个 shard(group).

@ideawu
Copy link
Author

ideawu commented Mar 19, 2021

node-server(ssdb-node)

代码模块

  • 存储引擎
  • raft 模块(binlog 模块)
  • redolog 模块
  • ttl 扫描清理模块
  • 事务(批量操作)执行模块

说明

raft 的配置通过 raft 广播, 例如节点的 ip:port. 节点自身的配置, 如内存配置, 不走 raft.

redolog 模块在有订阅者的时候生成 redo log, 没有订阅者则不生成, redo log 不做持久化.(也许binlog和redolog可以合并)

node-server 不需要知道 meta-server, proxy-server 的存在, 它是被动接受控制的.

meta-server(ssdb-meta)

meta server 定期主动地去查询所有 node server, 更新路由总表. 这样可以简化 node server 的设计.

proxy-server(ssdb-proxy)

代码模块

  • proxy 功能
  • 事务执行者

@ideawu
Copy link
Author

ideawu commented Apr 9, 2021

  • 总结问题和常见的应对方案
  • 把应对方案固化为功能选项或者接口
  • 编写用户开发指导手册

@ideawu
Copy link
Author

ideawu commented Apr 20, 2021

CAS 在多大区(多主)场景下, 会造成数据不一致. 所以, 需要将 CAS 操作转换成 redo log, 再复制到其它区域. 看来, redo log 还是必须的. incr 操作也要转换成 set 操作.

@ideawu
Copy link
Author

ideawu commented Apr 20, 2021

其实, apply 的过程可以理解为 redo log 的创建过程. redo log 中记录 apply 进度, 不把 redo log 看作是 db 的一部分, 就像我们不把 binlog 看作是 db 的一部分.

redo log 持久化之后, 再通知 db 持久化(仅通知 db 可以持久化, 由 db 自己决定是否持久化).

所以, db 对外暴露 AddCheckPoint() 接口

binlog 和 redolog 是一体两面.

@ideawu
Copy link
Author

ideawu commented Apr 21, 2021

节点结构图

a

外部请求产生 binlog, binlog 经过共识后, apply 到 storage, apply 的同时, 产生 redolog

progress 记录 last_index, last_seq, progress 是独立的模块

storage 记录 last_seq

Recover 流程

节点重启时, 执行 recover 流程:

  1. 读取 binlog.last_index, progress.last_index + progress.last_seq, storage.last_seq
  2. 取 progress.last_seq 和 storage.last_seq 的最小者作为 last_seq, 从 progress 中查找对应的 last_index
  3. 根据 last_seq 回滚 progress 或者 storage
  4. 从 last_index 位置, 继续 apply binlog

关于 Raft Apply Exactly Once 的讨论

为了保证 binlog apply 是 exactly once 的, 有两种解决方法:

  1. 提供操作原子性保证, 在 apply 的同时, 保存 apply 进度
  2. 单独保存 apply 进度, 重启时回滚

如果 apply 操作和保证进度两个操作有先后顺序, 那么先执行者需要提供回滚接口, 如果是并发的(不区分先后), 那么两者都要提供回滚接口.

代码上的先后, 不代表持久化的先后, 因为模块内部可能有缓冲, 会异步进行持久化.

@ideawu
Copy link
Author

ideawu commented Apr 28, 2021

Overview
    Cluster
    Machine
    Zone
    Node
    Proxy
Cluster
    Shard
    Machine
Machine
    CPU
    Memory
    Disk
    Cluster
    Node
    Proxy
Zone
    Machine
    Cluster

@ideawu
Copy link
Author

ideawu commented May 17, 2021

全球分布式数据库通过同步原语可以在各个区域提供强一致性读, 但是, 强一致性写操作(也即锁)则必须回源.

@ideawu
Copy link
Author

ideawu commented May 20, 2021

快照, 回滚.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment