读写分离和分库分表详解
读写分离靠 binlog 主从复制把读流量从主库卸下来,分库分表在单库容量见顶时把数据摊开。本文讲清复制原理与延迟对策、垂直与水平拆分的边界、分片键选择,以及拆分之后绕不开的分布式主键、跨片查询与扩容三大遗留问题。
它解决什么问题
数据库面对的是两种性质不同的压力。读并发压力:一主多从,把读请求分摊到从库,主库专心处理写——这是读写分离。容量与写入压力:单表行数、单库体积、写入 QPS 逼近上限,读侧优化无解,只能把数据拆开——这是分库分表。
两者经常组合出现,但边界必须清楚:读写分离不解决存储问题;分库分表是索引、慢 SQL、缓存等常规优化都扛不住之后的容量手段,不是数据库优化的第一步。
核心原理
主从复制:binlog 驱动的事件流
MySQL 主从复制完全建立在 binlog(记录全部 DDL 和 DML 变更的二进制日志)之上,三个角色配合:
- 主库把数据变更写入 binlog;
- 从库 I/O 线程连上主库,主库的 binlog dump 线程把 binlog 事件推过来,从库落盘为 relay log;
- 从库 applier(SQL)线程读 relay log 在本地重放——row 模式下是应用行变更事件。
默认是异步复制:主库写完 binlog 就向客户端返回成功,不等任何从库。这决定了读写分离的固有矛盾——从库永远可能落后主库。同一套机制也被 canal 这类工具复用:伪装成从库解析 binlog,把数据同步到 Elasticsearch、消息队列等其他数据源。
读写分离的两种落地
- 代理方式:应用与数据库之间加中间层(MySQL Router、ProxySQL、MaxScale),代理解析 SQL 类型决定路由到主还是从。对应用透明,代价是多一跳、多一个需要运维的组件。MySQL Router 8.2 起支持透明读写分离,但
FOR UPDATE、GET_LOCK()这类语句必须走可写实例,不能无脑全交给代理。 - 组件方式:ShardingSphere-JDBC 这类 SDK 嵌在应用进程里做路由。少一跳、没有独立组件,但绑定语言栈,路由规则随应用发布。
深入机制
主从延迟从哪来,怎么治
把复制拆成三个时刻:T1 主库提交并写 binlog、T2 从库收到并写 relay log、T3 从库重放完成。T2-T1 反映网络和 I/O 线程效率,T3-T2 反映重放速度。延迟的常见推手:从库机器配置低于主库、从库读流量过大抢占复制资源、大事务长时间占住重放线程、从库数量太多加重主库分发压力,以及早期 MySQL 的单线程重放(5.6 起按库并行、5.7 起按组提交并行、8.0.22 后用 replica_parallel_workers 开启多线程 applier)。
“写完立刻读却读不到”的解法,按可靠性排序:
- 强制读主:核心链路(如支付后查余额)通过 Hint 或路由规则指定主库,ShardingSphere 5.x 用
setWriteRouteOnly()。可靠但不能滥用——大量读回退到主库,读写分离就失去了意义。 - 等待位点:写成功后拿到本次事务的 GTID,读从库前先执行
WAIT_FOR_EXECUTED_GTID_SET等它追平。精确,但给读路径增加了延迟,只适合低频强一致场景。 - 半同步复制:
rpl_semi_sync_source_wait_point=AFTER_SYNC时,主库在提交前至少等一个从库确认收到 binlog。它保证的是主库宕机切换不丢数据,不保证从库已重放完成——所以半同步替代不了”读主”,治不了读旧数据。
工程上最常见的组合是:同一会话内或写后短时间内读主,其余读从,非核心链路容忍最终一致。另外注意事务边界:同一事务里先写后读,必须都钉在主库,否则读不到自己刚写的数据。
垂直拆分与水平拆分的边界
垂直拆分按业务和列来拆:用户库、订单库、商品库各管各的(垂直分库);宽表把大字段、冷字段挪到副表(垂直分表)。它解决的是业务耦合和单行过宽,本质是架构治理,系统演进早期就该做。
水平拆分按行拆:同一张逻辑表按规则摊到多个物理库表,解决单表数据量与写入吞吐。代价是引入路由、跨片查询、分布式事务、扩容迁移一整串问题。判断顺序永远是:先垂直拆清边界,常规优化做尽,最后才水平拆。单表到千万行不是拆分的硬指标,表结构、索引数量、硬件配置不同,可承载的行数差很多。
分片键:一次决定命运的选型
分片键要同时满足四个相互打架的要求:覆盖主要查询路径、离散均匀不倾斜、取值稳定不变、支持未来扩容。没有字段能全部满足,只能按最高频查询路径取舍。
订单系统是经典案例:买家订单列表是高频范围查询,按 buyer_id 分片;但按 order_id 点查就会迷路,补救办法是把买家路由位嵌进订单 ID,或维护 order_id -> 分片 的路由表。商家维度的查询通常冗余一张按 seller_id 分片的索引表,或干脆交给搜索引擎和宽表。另外别直接拿用户 ID 后几位当路由键——ID 有生成规律时低位不均匀,应先哈希再取模。
工程实践:拆分后的三大遗留问题
- 分布式主键:各分片自增 ID 必然撞车,需要 Snowflake、号段模式这类全局唯一且趋势递增的方案。进阶做法是把分片路由位编进 ID,查询时直接从 ID 解析目标分片,但要为二次扩容预留位数。
- 跨片查询:不带分片键的 SQL 只能广播到所有分片再归并;跨片深分页每个分片都要取到目标页再合并排序,代价随分片数线性放大。对策包括冗余查询维度的索引表、绑定表(同规则分片的表,带分片键的 join 落在同片执行)、后台检索走 ES 或报表系统。
- 扩容:直接修改
hash % N的 N 会让几乎全部数据的映射失效。稳妥做法是预设足够多的逻辑分片(如 1024 个 bucket),维护 bucket 到物理节点的映射,扩容只迁移部分 bucket 而非重哈希全量。数据迁移按”存量回灌 + binlog 增量同步 + 双读校验 + 灰度切流”推进,全程幂等、可回滚。
对比与权衡
| 维度 | 读写分离 | 垂直拆分 | 水平拆分 |
|---|---|---|---|
| 解决什么 | 读并发 | 业务耦合、行过宽 | 单表容量、写吞吐 |
| 数据分布 | 从库全量复制 | 按业务分置 | 同一表按行摊开 |
| 引入的复杂度 | 主从延迟、读写路由 | 低 | 路由、跨片查询、分布式事务、扩容迁移 |
| 实施时机 | 读压力大时较早引入 | 架构演进早期 | 常规优化穷尽之后 |
| 主从延迟解法 | 可靠性 | 代价 |
|---|---|---|
| 强制读主 | 高 | 主库读压力回升 |
| GTID 位点等待 | 高 | 读路径延迟增加 |
| 半同步复制 | 保切换不丢数 | 不解决读旧值 |
| 延迟 N 秒再读 | 低 | 只是概率性规避 |
面试视角
主从延迟怎么产生的,如何避免读到旧数据? 异步复制下主库提交不等从库,网络传输(T2-T1)和从库重放(T3-T2)都可能落后,大事务、从库高负载会放大差距。强一致场景读主库或等 GTID 位点;半同步只保证 binlog 到达从库,不保证重放完成,不能用来防读旧值。
读写分离架构里,事务内的读要注意什么? 同一事务先写后读必须都走主库,否则读不到自己刚写但尚未同步的数据。成熟代理会把事务内请求自动钉在主库,自研路由要自己守住这个边界。
分库分表后 join 和分页怎么办? 同规则分片的表配置绑定表后,带分片键条件的 join 落在同一分片执行;跨片 join 只能广播加归并,高频场景应靠数据冗余消灭 join。跨片分页每个分片都要取候选数据再归并排序,深翻页成本随分片数放大,后台检索交给搜索引擎。
分片键怎么选? 覆盖最高频查询路径优先,兼顾离散度与稳定性。订单表通常按买家 ID 分片而不是订单 ID,因为买家订单列表是高频范围查询;订单 ID 点查通过路由位嵌入 ID 或独立路由表解决。
小结
- 读写分离卸读压力、分库分表卸容量压力,解决的问题不同,可组合使用但别混为一谈。
- binlog 异步复制是读写分离的地基,也埋下了延迟的坑;读主、等位点、半同步各治一段,没有一招通吃。
- 拆分顺序:先垂直后水平;水平拆分前确认索引、慢 SQL、缓存优化已经穷尽。
- 分片键按最高频查询路径定,配套的分布式主键、跨片查询、逻辑分片扩容方案必须在拆分之前设计好,而不是拆分之后补。

