4.2 存储扩容:先走读写分离
场景
引入 RocketMQ 削峰之后,写入的尖刺被抹平了,但存储的容量瓶颈没有消失——消息表每天增长两千万行,半年就是三十六亿行,单实例的磁盘与 IO 都吃紧。更直接的压力在读侧:用户翻历史聊天记录、搜索入口的聚合查询、客服后台的导出,这些重查询与在线业务的轻查询混在一个实例上,晚高峰的慢查询偶尔会把在线拉取接口拖进泥潭。扩容被提上日程,但怎么扩是有讲究的:一上来就分库分表是大工程,迁移周期以月计,中间出一次错账就是事故。
团队当时的争论主要在两条路线:直接上分库分表把热数据拆开,还是先走读写分离把读压力分走。前者的收益大但风险高,后者的收益有限但几乎零改造。我们最终选了读写分离先行,分库分表延后,理由是当时的画像里写其实还没到单实例极限,瓶颈八成在读。这个判断靠的不是直觉,而是先把读写两类查询分别压测出各自的 QPS 与 IO 占比,数据说读占七成才拍板。
还有一个工程层面的考量:分库分表的迁移需要双写与对账,至少两三个月的重保期,当时团队正在赶群功能与导出需求,投入不起。读写分离的改造成本集中在路由层一处,灰度周期两周,失败可回退。扩容路线图的时间排序就是这样定出来的:先读写分离(一周见效),再分库分表(留给下一个季度),中间穿插慢查询治理与缓存前置这类低成本干预。
方案
读写分离的拓扑:主实例承担写入与强一致读,两个只读实例承担历史拉取、列表查询、导出等可容忍延迟的读。数据同步走 TDSQL-C 的物理复制,延迟在毫秒级。业务侧的改造只有一处:查询入口按用途打标,路由层按标分流。
| 查询类型 | 走哪个实例 | 一致性要求 | 延迟容忍 | 备注 |
|---|---|---|---|---|
| 发送落库 | 主 | 强 | 无 | 写永远在主 |
| 首屏拉取 | 主 | 强 | 无 | 用户刚发的要立即可见 |
| 翻历史页 | 只读 | 弱 | 一秒内 | 冷数据查询 |
| 会话列表预览 | 只读 | 弱 | 一秒内 | 未读数另有缓存 |
| 客服导出 | 只读 | 弱 | 分钟级 | 走专用只读 |
| 搜索聚合 | 只读 | 弱 | 秒级 | 独立检索链路 |
路由层实现里最容易出问题的是故障切换:只读实例挂了查询不能跟着挂。我们的路由层在只读异常时自动降级到主,同时打开限流开关防止全量读压回主实例。主实例故障则触发只读升主,这个流程在云端控制台是托管的,我们要做的只是把连接配置的切换做成配置中心热更新,而不是写死在启动文件里。只读实例的监控要覆盖连接数、慢查询与复制延迟三件套。
只读实例的规格配置也值得一说。主实例十六核六十四 G 跑写入加强一致读,两个只读八核三十二 G 跑历史与列表查询,又申请了一个四核八 G 的微型只读专门跑客服导出。这样分配的原因是导出类查询动辄扫几十万行,混在业务只读上会污染缓存与 IO,独立小实例慢归慢但隔离干净。实例间的同步方式选物理复制而非逻辑同步,物理复制没有 SQL 解析开销,延迟稳定在两百毫秒以内,对 IM 的弱一致读场景完全够用。
配置与代码
路由层的核心逻辑,按查询用途选数据源:
type Router struct {
master *DB
reads []*DB // 只读池,轮询使用
cfg *RouteConfig
}
func (r *Router) Pick(kind QueryKind) *DB {
switch kind {
case QueryWrite:
return r.master
case QueryHistory, QueryList:
if db := r.healthyRead(); db != nil {
return db
}
return r.master // 只读全挂时降级,配合限流
}
return r.master
}
func (r *Router) healthyRead() *DB {
for _, db := range r.reads {
if db.Alive() && db.Lag() < time.Second {
return db // 复制延迟超阈值不路由过去
}
}
return nil
}
健康检查每五秒跑一次:一个 ping 加一次延迟探测(对比主从的位点差)。延迟超阈值的只读实例会被暂时摘掉,恢复后自动加回。摘掉期间发给主的降级流量打开限流,阈值按主实例平时的余量算,这部分配额要在压测时就预留好,否则降级的那一刻就是新的过载起点。
延迟探测的具体做法值得一聊。我们没看数据库自带的延迟指标,而是在主库维护一张心跳表,每秒写一行时间戳,只读实例读这张表最新一行的时间差就是真实延迟。好处是它与业务使用同一个通道,读到的延迟就是用户能感知到的延迟,坏处是心跳表本身要定期清理,我们留七天数据顺手做监控图表。TDSQL-C 的读写分离与监控配置文档入口 cloud.tencent.com/document/product/1003。
踩坑
第一个坑是复制延迟导致用户看到自己消息消失。用户发了消息立刻翻历史,查询走到只读实例,延迟一秒时这条消息还没同步过去,用户一看:我发的消息呢?修复是刚刚写入的消息在短时间内强制走主——服务端记下每个会话最近两秒的最大 Seq,查询的 Seq 落在这个窗口内就走主,窗口外走只读。这个窗口不需要太大,覆盖复制延迟即可。
第二个坑是只读实例的慢查询拖垮复制。历史查询里偶尔有深翻几十万行的扫描,只读实例处理慢导致消费位点积压,复制延迟越拉越大。修复有两层:只读实例参数专门为 OLAP 类查询调优(排序缓存调大),业务侧深翻加分页强制走 Seq 范围扫描而不是偏移量跳过。后者是 SQL 层面的规范:禁止 limit 加 offset 超过一万行的写法,要用 where Seq 大于上次末尾的游标翻页。
第三个坑是事务跨实例。有一段旧代码在事务里先写后读,读写分离后事务里的读被路由到只读,结果读到的是旧数据,逻辑直接错乱。Go 这边我们统一封装了数据访问层:事务内的读强制绑定主,应用代码无感知。规范层面明确:事务里不允许出现跨实例操作,检查规则写进了代码扫描的流水线。
第四个坑是连接数。加了两个只读实例后,每个实例的连接池各自维护,应用实例数乘池大小再乘实例数轻松破千。压测时只读的连接被打满,查询开始排队。修复是连接池按路由层统一管理,只读池共享一组复用连接,并引入最大排队等待超时,宁可请求失败也不要拖死实例。连接数的测算方法见 单机压测:先摸清单台的底。
第五个坑是只读实例的成本。只读实例按完整规格计费,两个只读加起来快赶上一个主实例。复盘时我们把只读降配为主实例的一半规格,深查询场景的慢查询多了两成但仍在可接受范围,弹性扩容在高峰期临时升配。成本测算的完整口径见 成本复盘:连接与存储怎么算钱。
第六个坑是路由层自身的单点。所有流量都经过 Router 选库,Router 挂了等于全挂。第一版 Router 就是个进程内组件,挂的概率不高但因为所有实例共享配置,一次配置错误(比如把客服导出标记错填成 QueryWrite)会让所有实例同时把重查询压到主实例。修复有两层:配置变更走灰度发布先在一台实例验证(见 灰度发布:按用户分桶放量),以及 Router 的路由决策全部带单元测试——每个 QueryKind 的路由结果都有断言,改路由逻辑必须过测试关。
小结
读写分离是扩容的第一级阶梯,不是终点。它用很低的改造成本买到了读侧的扩展性,代价是最终一致性与路由复杂度。判断要不要走的依据是压测出的读写比例,而不是迷之自信;判断要不要继续往上走(分库分表)的依据是写侧压测的余量,同样要用数据说话。给同路人的顺序建议:先把读写分离吃到极致(索引优化、慢查询治理、缓存前置都做完),再谈分库分表;过早分片会把精力耗在分布式事务与跨库查询上,而那些并不解决眼下的瓶颈。等写也撑不住时,下一级阶梯是按 ConvID 哈希的分库分表,那时要面对的是跨库JOIN、分布式事务与迁移工程,难度完全不同。扩散模式相关的存储压力见 群消息:读扩散与写扩散怎么选。
读写分离还有一个绕不开的暗坑:写完立刻读。用户发完一条消息随手就刷新会话列表,此时这条写入刚落主库,复制还没同步到只读实例,读回来的列表里没有刚发的消息,用户会以为发送失败再发一遍。经典的修复是在连接上下文里打一个最近写入时间戳,凡是写操作落库后的 N 秒内,同一个会话的读请求强制路由主库,这个窗口设为两秒足够覆盖大部分复制延迟,代价是这两秒主库多吃一点读流量,可以忽略。比用心跳表探测复制延迟更主动——心跳表告诉你延迟多少,而时间戳直接把可能读到旧数据的窗口堵死。
另一个要写进值班手册的现象是周期性慢查询会伪装成复制延迟。只读实例上跑着报表类查询吃满 CPU 时,从监控上看延迟曲线上去了,其实数据同步是好的,这时候扩主库是误操作,正确动作是给只读实例加只读副本分散报表压力。判断依据是看主机维度的 CPU 与 IO 队列,而不是只盯着复制延迟一个数字。
