第 14 章 · 读写分离与分库分表入门
本章目标:理解 主从复制 与 读写分离 架构;掌握 垂直分库 与 水平分表 概念与路由;在 Python/SQLAlchemy 层实现 读写路由 概念验证;对照 architecture ch10 数据架构决策;为 shop-db 毕业项目标注扩展边界;认识 ProxySQL 与应用双 DSN 两种落地方式。
学时建议:4~5 小时(含 1 小时 architecture ch10 对照阅读)
前置:完成 ch13 EXPLAIN 优化;建议先读 architecture ch10 分库分表与读写分离。
14.1 何时需要扩展读能力
单库 MySQL 在 shop-db 教学规模(万级商品、十万级订单)通常足够。出现以下信号才考虑读写分离或分片:
| 信号 | 阈值(经验) | 优先动作 |
|---|---|---|
| 单表行数 | >3000~5000 万 | 水平分表 |
| 单库容量 | >500GB~1TB | 垂直分库 / 分片 |
| 写 TPS | >2000~3000 | 分片或异步化 |
| 读 P99 | 索引优化后仍 >100ms | 读写分离 |
| 连接数 | > max_connections×70% | 读库分担 |
决策顺序(与 architecture ch10 一致):
垂直分库 → 读写分离 → 冷数据归档 → 水平分片
本章以 概念 + db-demo 配置 为主,不要求本地搭完整 1 主 2 从集群;Docker Compose 提供 读写双 DSN 模拟 即可。
14.2 主从复制概念
14.2.1 拓扑
┌─────────────┐
写 ───────►│ Master │
│ (Primary) │
└──────┬──────┘
│ binlog 复制
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Slave 1 │ │ Slave 2 │ │ Slave 3 │
└─────────┘ └─────────┘ └─────────┘
▲ ▲
└──── 读 ────┘
| 角色 | 职责 |
|---|---|
| Master | 唯一写;产生 binlog |
| Slave | IO 线程拉 binlog → relay log → SQL 线程重放 |
| 应用 | 写走 Master;读走 Slave(只读) |
14.2.2 复制延迟
| 写 TPS | 典型延迟 | 业务风险 |
|---|---|---|
| 200 | 0.3s 内 | 低 |
| 800 | 2~5s | 下单后列表可能缺单 |
| 2000+ | 10s+ | 必须写后读主 |
监控指标:Seconds_Behind_Master;>5s 触发告警(architecture ch10)。
14.2.3 与 shop-db 的关系
shop-db 毕业项目默认 单库单表;文档中注明:
生产扩展路径见 architecture ch10;读写分离不改变 slug / is_published / price 分 契约。
14.3 读写分离规则
| 操作 | 路由 | 说明 |
|---|---|---|
| INSERT / UPDATE / DELETE | Master | 所有写 |
| SELECT 列表、统计 | Slave | 可接受秒级延迟 |
| 事务内 SELECT | Master | 同事务一致读 |
| 写后立即读(下单详情) | Master 或 Redis | 避免「丢单」错觉 |
| DDL / 迁移 | Master | Alembic 只连写库 |
用户下单 POST /orders
│
├─► INSERT orders ──► Master
│
└─► GET /orders/{id} ──► Master(写后读)
GET /orders?page=1 ──► Slave(列表可延迟)
对照 architecture ch10 §10.4:ProxySQL match_pattern 写走 hostgroup 10、读走 20。
14.4 垂直分库
按 业务域 拆库,与微服务边界对齐:
shop_user ── users, addresses
shop_catalog ── products, categories
shop_order ── orders, order_items
shop_payment ── payments, refunds
| 优点 | 缺点 |
|---|---|
| 故障隔离 | 跨库 JOIN 不可用 |
| 团队自治 | 分布式事务复杂度上升 |
| 备份粒度细 | 需应用层聚合 |
14.4.1 跨库场景正解(architecture ch10)
| 场景 | 错误 | 正解 |
|---|---|---|
| 订单页显示商品名 | 跨库 JOIN | 下单时 冗余 product_name 快照 |
| 用户订单列表 | 查 catalog 库 | order 表存 user_id + 必要快照 |
| 报表 GMV | 实时跨 4 库 | T+1 数仓宽表 |
14.4.2 shop-db 垂直拆边界(文档级)
毕业项目 ER 仍 单库;在 docs/SCALE.md 标注未来拆法:
## 垂直分库预案
- catalog.products → shop_catalog
- orders.* → shop_order
- 跨库字段:order_items.product_slug, product_name, price_cents(分)
price 分 冗余到订单行,避免拆库后跨库查价。
14.5 水平分表
单表过大时,按 分片键 拆多表或多库。
14.5.1 拓扑示例(architecture ch10)
4 库 × 16 表 = 64 物理表
db_index = user_id % 4 → shop_order_0 .. shop_order_3
table_index = user_id % 16 → t_order_00 .. t_order_15
14.5.2 路由手算例题
| 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 |
14.5.3 分片键选择
| 策略 | 适用 | 注意 |
|---|---|---|
| Hash 取模 | 均匀分布 | 扩容需迁移 |
| Range 时间 | 按月订单 | 当月热点 |
| user_id | 用户订单列表 | architecture 推荐 |
为何订单用 user_id 而非 order_id:用户查「我的订单」占查询 95%;按 order_id 分片会导致列表扫全片。
14.5.4 Python 路由函数
def route_order_table(user_id: int) -> tuple[str, str]:
db_suffix = user_id % 4
table_suffix = user_id % 16
return f"shop_order_{db_suffix}", f"t_order_{table_suffix:02d}"
# 示例
assert route_order_table(10086) == ("shop_order_2", "t_order_06")
14.6 全局 ID 与雪花
分片后 自增 ID 冲突;常用 雪花算法(architecture ch10 §10.5):
0 | 41bit 时间 | 10bit 机器 | 12bit 序列 → 64bit 整数
| 方案 | 评价 |
|---|---|
| 雪花 | 推荐,百万+/s |
| DB 号段 batch=1000 | 简单,中小流量 |
| UUID v4 | 索引碎片大 |
| 自增 | 分片后冲突 |
订单号展示:ORD + yyyyMMddHHmmss + shard(2) + seq(6)(与业务无关,教学虚构)。
14.7 应用层读写路由(db-demo)
14.7.1 双 Engine 配置
.env.example:
DATABASE_WRITE_URL=mysql+pymysql://shop:shop@127.0.0.1:3306/shop_db
DATABASE_READ_URL=mysql+pymysql://shop:shop@127.0.0.1:3307/shop_db
db-demo/db/session.py:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
write_engine = create_engine(
os.environ["DATABASE_WRITE_URL"],
pool_pre_ping=True,
)
read_engine = create_engine(
os.environ["DATABASE_READ_URL"],
pool_pre_ping=True,
)
WriteSession = sessionmaker(bind=write_engine)
ReadSession = sessionmaker(bind=read_engine)
def get_read_session() -> Session:
return ReadSession()
def get_write_session() -> Session:
return WriteSession()
14.7.2 路由装饰器(概念)
from functools import wraps
def use_master_after_write(func):
"""写后读主:线程局部标记,下一次读走 Master"""
@wraps(func)
def wrapper(*args, **kwargs):
# 简化教学:实际可用 contextvars
_write_flag.set(True)
try:
return func(*args, **kwargs)
finally:
_write_flag.set(False)
return wrapper
14.7.3 Repository 示例
class ProductRepository:
def get_by_slug(self, slug: str, *, master: bool = False):
session = get_write_session() if master else get_read_session()
try:
return session.scalar(
select(Product).where(Product.slug == slug)
)
finally:
session.close()
def create(self, data: dict):
session = get_write_session()
try:
p = Product(**data) # price 分, is_published, slug
session.add(p)
session.commit()
return p
finally:
session.close()
创建商品后点查:
repo.create({"slug": "new-item", "price": 9900, "is_published": False})
# 写后读必须 master=True
p = repo.get_by_slug("new-item", master=True)