数据库架构设计
说明: 设计侧重于关于运维层面
设计理念:
- 稳定性要求高
- 数据安全性高: 不丢失数据, 数据可恢复
- 自调节化: 避免运行时人工干预, 基于 operator 方式主动状态维护
一些常见的需求方向
- 存算分离: 形式有多种, 核心是存储层来保障安全性和可用性, 计算层提供功能和性能;
- 缓存: 外置独立缓存 和 内置缓存 两种模式; 内置缓存一致性更高, 外置缓存灵活性更好;
- 异构数据同步: 例如基于 binlog or ETL 将数据同步到 es, clickHouse 等, 有效减少对核心原始库数据耦合;
- 数据强一致性: 直接使用 DB 来承载业务, 避开外部缓存;
- 数据预热: 一般指重启前 或 活动前, 将数据加载到缓存中;
- ratf: 现代数据存储大量使用raft来保障数据一致性
数据类型
- 结构化数据
- 半结构化数据: 行结构日志文件
- 非结构化数据: 图片, 视频, pdf 等
事件
主从一致性
mysql 原生为异步复制, 写主读从可能会因延迟造成读取的数据不一致
-
强制读主库: 简单, 可指定某些 sql 强制主库; 手动实现或者某些 sdk 支持标记路由;
-
半同步复制:
- 优点: 简单, 原生支持
- 缺点: 是降低了主库写性能, 且半同步还是有可能退回到异步模式的
-
全同步: 必须从库写成功后, 主库才响应;
-
引入中间件:
由中间件判断, 如果某个 key 最近一段时间(经验值)发生过写, 则读操作也路由到主库
缺点: 多一个组件, 也难控制 -
引入缓存: 和中间件机制类似, 即用一个外置锁来判断去主库还是从库
写数据时: 先在缓存内设置一个 ttl 为 2-5s 的 key, 再写主库数据
读数据时: 先读缓存, key 存在则明最近发生过写, 则读主库, 否则直接读从库
优点: cache 操作简单, 缺点: 读写都多了一次 cache 操作
异构数据同步和查询
场景 1: 业务库是按照订单 ID HASH 进行分库, 但是后台统计需要按渠道 ID 进行统计;
解决办法:
- 应用层处理: 多库查询, 复杂的 sql;
- 新库新模型: ETL 全量 + 增量将数据读取写入统计库, 并进行字段或属性调整;
缺点: 不好处理删除和更新的数据, 除非数据行内有加版本标识 - 应用双写: 简单, 但很难保证数据一致性
- 应用写 mq: 独立日志服务从 mq 读再写从库, 场景狭小, 且仍然存在一致性问题
- 基于 binlog 的数据分析, 将解析后的数据存到异构库中, 推荐;
这几种方式都需要定期或实时做数据一致性校验
场景 2: 冗余表, 即另一个统计类的多字段表
- 单机就采用事务方式写
- 跨实例就采用双写或场景 1 的办法
基于 binlog 的异构数据同步
原理: 模拟从库获取 binlog, 解析后直接处理变更数据
优点:
- 准实时性
- 不需要修改业务
- 记录了全部的增删改操作
推荐架构:
mysql -> binlog -> 程序格式化 binlog-> MQ(kafka) -> 消费后存入异构库
实现
-
canal(阿里巴巴开源项目, 纯 java 开发)
-
python-mysql-replication(python 开发)
https://github.com/noplay/python-mysql-replication -
https://github.com/danfengcao/binlog2sql.git
mysql 多节点写的一致性
即多主节点同时写或 update, 如何保障复制时数据一致不冲突和循环同步;
早期方案:
- 每个表的主键自增 id 区分单双数
- 引入集中式全局唯一 ID 生成器或分布式唯一 ID 生成器算法等来保障 ID 不冲突
- 过滤掉 DDL 同步, 由维护人员多端采用在线 DDL 工具进行表结构变更
- 使用 GTID 模式, 保障数据不会循环同步
外置同步机制:
不使用原生同步机制, 采用 binlog 解析后入 MQ, 远端再对 MQ 进行消费入本地库;
缺点很明显, 应用场景比较窄;
数据库秒级别平滑扩容
原架构: 应用程序通过 ID 模 2 来分库
- A–>B A 是主库
- C–>D C 是主库
需求: 2 主 2 从 扩容为 4 主 4 从
步骤:
- 应用层关闭对从库的读
- 由 2 主 2 从调整为 2 对主主结构 A>––<D
- 此时插入的数据还是走的 AC 对应用不影响
- 应用层由模 2 改为模 4
0 -> A
1 -> B
2 -> C
3 -> D
因为 A 和 B 的数据是同步的, 所以此时 0 和 1 可读写的数据和原来无差别 - 增加从节点
A B C D
| | | |
E F G H - 移除 ABCD 中的冗余数据
频繁的数据库字段变更
旧办法: 预留多个冗余字段 +DDL 增加或变更字段类型
新方法: json 字段
- 核心字段提出来
- 通过一个 version 来识别 ext 的格式版本, 格式上做前向兼容设计
|
|
连接池设计
频繁建立和销毁连接, 以及维护很多连接都比较耗资源
- 应用侧客户端实现
- 中间件实现
主要考虑问题
- 如果连接全部被占用, 是返回失败, 还是让上游等待
- 连接可用性检测
- 连接池应该接口友好, 方便更换底层数据源
- 负载均衡, 故障转移, 服务自动扩容都可以在这一层实现
分库机制
基于时间的分库或分表方案, 类似于 java 的分代 GC 机制
小时库:
- 特点: 写多, 读多, 修改多
- 频繁修改和写入会导致索引碎片严重, 但因为数据量小, 所以查询也快
- 每小时合并一次数据到日库中
日库
- 特点: 只读不写, 用于合并小时库的内容
- 索引碎片小, 排序紧密, 查询快
- 每日合并一次数据到全量库中
全量库
- 特点: 只读不写, 用于合并日库的内容
- 优点: 基本不更新, 于是索引的排序很规则, 索引碎片小, 查询速度快
当有查询请求发生时, 会同时查询各个级别的索引, 将结果合并, 得到最新的数据
分库分表之后的非 partition key 查询命中问题
场景: 已经按 UID 进行分库分表, 对于非分区 key 的查询, 例如直接使用 name 进行查询时如何确定目标库;
方法一: 二次查询法
建立一个存储有 uid 和 name 的索引表, 或外部缓存;
查询时先从索引表或缓存查询出 UID, 再去对应库查询实际数据;
缺点: 多一次查询, 数据空间占用
方法二: UID 生成规则
即UID的包含了部分字段的含义, 通过 name 大概推导出 UID 的范围, 以确定目标库
1. uid 格式 = name+ 原 UID
2. 或者根据 name 的哈希首字母, 确定 UID, 再通过 UID 分表
缺点: 多 1 次计算, 需求多个字段时不好办, 后续新增字段也不好办, 需要好的设计
推荐:
- 一开始设计时将最核心的1-2字段设置到uid里面去;
- 后续变更和扩展字段正则用索引表或缓存来做;
ETL 数据处理
ETL(extract 提取, transform 转换, load 加载)
- 数据提取
- 转换
- 加载
处理模式
- 流式批处理模式
- 全量数据导出方式
- 事件触发导出模式
数据清洗
- 数据补缺: 对空数据, 缺失数据进行数据补缺操作, 无法处理的做标记;
- 数据替换: 对无效数据进行数据的替换;
- 格式规范化: 将源数据抽取的数据格式转换成为便于进入仓库处理的目标数据格式;
- 主外键约束: 通过建立主外键约束, 对非法数据进行数据替换或导出到错误文件重新处理;
数据转换
- 数据合并: 多表关联, join 等
- 数据拆分
- 行列互换, 排序 / 修改序号, 去除重复记录
- 数据验证: loolup, sum, count
数据加载
- 全表删除插入方式
- 全表比对, 处理差异部分
- 时间戳方式: 在业务表中统一添加字段作为时间戳, 当 OLAP 系统更新修改业务数据时, 同时修改时间戳字段值;
- 日志表模式, 有业务变动时触发插入
异常处理
- 异常的数据单独输出日志, 避免 ETL 流程阻塞和中断, 后续再进行补全