第 15 章 · 读写分离与分库分表入门
本章目标:理解 主从复制 与 读写分离 架构;掌握 垂直分库 与 水平分表 概念与路由;在 Go/GORM 层认识 读写路由(AbstractRoutingDataSource 概念);对照 architecture ch10 数据架构决策;为 shop-db 毕业项目标注扩展边界;认识 ProxySQL 与应用双 DSN 两种落地方式。
学时建议:4~5 小时(含 1 小时 architecture ch10 对照阅读)
前置:完成 ch14 EXPLAIN 优化;建议先读 architecture ch10 分库分表与读写分离。
15.1 何时需要扩展读能力
单库 MySQL 在 shop-db 教学规模(万级商品、十万级订单)通常足够。出现以下信号才考虑读写分离或分片:
| 信号 | 阈值(经验) | 优先动作 |
|---|---|---|
| 单表行数 | >3000~5000 万 | 水平分表 |
| 单库容量 | >500GB~1TB | 垂直分库 / 分片 |
| 写 TPS | >2000~3000 | 分片或异步化 |
| 读 P99 | 索引优化后仍 >100ms | 读写分离 |
| 连接数 | > max_connections×70% | 读库分担 |
决策顺序(与 architecture ch10 §10.1 一致):
垂直分库 → 读写分离 → 冷数据归档 → 水平分片
本章以 概念 + shop-db 文档 为主,不要求本地搭完整 1 主 2 从集群;Docker Compose 提供 读写双 DSN 模拟 即可。
15.2 主从复制概念
15.2.1 拓扑
┌─────────────┐
写 ───────►│ Master │
│ (Primary) │
└──────┬──────┘
│ binlog 复制
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Slave 1 │ │ Slave 2 │ │ Slave 3 │
└─────────┘ └─────────┘ └─────────┘
▲ ▲
└──── 读 ────┘
| 角色 | 职责 |
|---|---|
| Master | 唯一写;产生 binlog |
| Slave | IO 线程拉 binlog → relay log → SQL 线程重放 |
| 应用 | 写走 Master;读走 Slave(只读) |
15.2.2 复制延迟
| 写 TPS | 典型延迟 | 业务风险 |
|---|---|---|
| 200 | 0.3s 内 | 低 |
| 800 | 2~5s | 下单后列表可能缺单 |
| 2000+ | 10s+ | 必须写后读主 |
监控指标:Seconds_Behind_Master;>5s 触发告警(architecture ch10 §10.5)。
15.2.3 与 shop-db 的关系
shop-db 毕业项目默认 单库单表;文档中注明:
生产扩展路径见 architecture ch10;读写分离不改变 slug / is_published / price 分 契约。
15.3 读写分离规则
| 操作 | 路由 | 说明 |
|---|---|---|
| INSERT / UPDATE / DELETE | Master | 所有写 |
| SELECT 列表、统计 | Slave | 可接受秒级延迟 |
| 事务内 SELECT | Master | 同事务一致读 |
| 写后立即读(下单详情) | Master 或 Redis | 避免「丢单」错觉 |
| DDL / golang-migrate 迁移 | Master | 只连写库 |
用户下单 POST /orders
│
├─► INSERT orders ──► Master
│
└─► GET /orders/{id} ──► Master(写后读)
GET /orders?page=1 ──► Slave(列表可延迟)
对照 architecture ch10 §10.4:ProxySQL match_pattern 写走 hostgroup 10、读走 20。
15.3.1 商品域读路径
| API | 路由 | 理由 |
|---|---|---|
GET /products/{slug} | Slave + 缓存 | 可延迟 |
POST /products 上架 | Master | 写 |
| 改 price(分)后立刻 GET | Master | 防延迟读到旧价 |
| 运营报表 database/sql | Slave | 重查询 |
price 分 在复制链路上仍为 BIGINT,无精度问题;延迟表现为 读到旧价格整数。
15.4 垂直分库
按 业务域 拆库,与微服务边界对齐(architecture ch10 §10.2):
shop_user ── users, addresses
shop_catalog ── products, categories
shop_order ── orders, order_items
shop_payment ── payments, refunds
| 优点 | 缺点 |
|---|---|
| 故障隔离 | 跨库 JOIN 不可用 |
| 团队自治 | 分布式事务复杂度上升 |
| 备份粒度细 | 需应用层聚合 |
15.4.1 跨库场景正解(architecture ch10)
| 场景 | 错误 | 正解 |
|---|---|---|
| 订单页显示商品名 | 跨库 JOIN | 下单时 冗余 product_name 快照 |
| 用户订单列表 | 跨库查 users | orders 存 user_slug |
| 报表 GMV | 跨库聚合 | T+1 数仓宽表 |
shop-db 的 order_items.product_slug、product_name、price(分)正是为拆库后 零 JOIN 报表 设计(ch07/ch17)。
15.4.2 Go 多数据源配置概念
# 虚构域名,勿写生产
shop:
datasource:
user:
jdbc-url: mysql DSNmysql://db-user.example.com:3306/shop_user
catalog:
jdbc-url: mysql DSNmysql://db-catalog.example.com:3306/shop_catalog
order:
jdbc-url: mysql DSNmysql://db-order.example.com:3306/shop_order
每个库独立 golang-migrate 迁移目录:db/migration/user/、catalog/、order/。
15.5 水平分表与路由
architecture ch10 §10.3 拓扑:4 库 × 16 表 = 64 物理表
db_index = user_id % 4 → shop_order_0..3
table_index = user_id % 16 → t_order_00..15
路由例题
| user_id | 计算 | 落点 |
|---|---|---|
| 10086 | 10086%4=2, %16=6 | shop_order_2.t_order_06 |
| 88888801 | %4=1, %16=1 | shop_order_1.t_order_01 |
分片键选择:订单表用 user_id(或 user_slug 哈希),因「我的订单列表」占查询 95%。
15.5.1 Go 路由伪代码
public record OrderShard(int dbIndex, int tableIndex, String tableName) {}
public OrderShard route(long userId) {
int db = (int) (userId % 4);
int tbl = (int) (userId % 16);
String name = "t_order_%02d".formatted(tbl);
return new OrderShard(db, tbl, name);
}
public String orderInsertSql(long userId) {
OrderShard s = route(userId);
return "INSERT INTO shop_order_" + s.dbIndex() + "." + s.tableName() + " (...) VALUES (...)";
}
shop-db 毕业项目:逻辑表名仍为 orders;分表仅在 docs/SCALE.md 描述。
15.5.2 全局 ID
| 方案 | 说明 |
|---|---|
| 雪花算法 | 趋势递增、无中心 |
| DB 号段 | 批量取 id |
| UUID | 无序,索引局部性差 |
订单号 order_no 与主键 id 可分离:order_no 对外展示,id 仅内部。
15.6 应用层读写路由(Spring 概念)
gin-web ch17 与 architecture ch10 均提及 AbstractRoutingDataSource:
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "read" : "write";
}
}
@Configuration
public class DataSourceConfig {
@Bean
public DataSource routingDataSource(
@Qualifier("masterDs") DataSource master,
@Qualifier("slaveDs") DataSource slave) {
ReadWriteRoutingDataSource ds = new ReadWriteRoutingDataSource();
Map<Object, Object> map = new HashMap<>();
map.put("write", master);
map.put("read", slave);
ds.setTargetDataSources(map);
ds.setDefaultTargetDataSource(master);
return ds;
}
}
| 注解 | 路由 |
|---|---|
@Transactional(readOnly = true) | read → Slave |
@Transactional 写 | write → Master |
shop-db 纯 Go 阶段:可用两个 sqlx 连接池 DataSource,报表脚本显式连 slave DSN。