前两篇我们学习了 MySQL 的事务、MVCC、锁和索引。
通过这些知识,已经能够理解一台 MySQL 如何完成数据存储、查询和并发控制。
但随着微服务系统不断发展,单台 MySQL 可能逐渐成为整个系统的瓶颈。
例如,一个电商系统最初只有几十万条订单,一台 MySQL 就能处理全部请求。
几年以后,订单数据增长到几亿条,同时每天有大量用户查询历史订单,后台服务还需要不断写入新的交易记录。
这时候,数据库可能出现:
- 查询越来越慢。
- 数据库 CPU 和 I/O 压力持续上升。
- 单台机器存储容量逐渐不足。
- 某台数据库服务器故障,影响整个系统。
- 大量读请求和写请求互相争夺数据库资源。
面对这些问题,常见的解决方案包括主从复制、读写分离和分库分表。
但它们解决的问题并不相同。
主从复制主要提供数据副本,读写分离利用副本分担读请求,而分库分表则可以把数据和访问压力分散到多个存储单元。
理解它们的区别,比直接记住几个架构名词更重要。
一、MySQL 出现瓶颈,就一定要分库分表吗?
假设我们的订单系统采用最初的架构:
Order Service | v MySQL | orders 表随着业务增长,查询开始变慢。
一种很直接的想法是:
一台 MySQL 不够用了,那就拆成多台 MySQL。
但实际设计时,不应该一开始就选择分库分表。
因为 MySQL 的性能问题可能有不同原因。
1. SQL 和索引设计不合理
例如:
SELECT*FROMordersWHEREuser_id=888ORDERBYcreated_atDESCLIMIT20;如果orders表数据量很大,同时缺少合适的索引,就可能需要扫描大量记录并进行额外排序。
这时,首先应该分析 SQL 执行计划。
例如根据真实查询负载考虑:
CREATEINDEXidx_user_timeONorders(user_id,created_at);如果通过合理的索引就能显著减少查询开销,那么没有必要立即引入分库分表。
2. 读请求太多
假设数据库每秒需要处理:
订单查询:8000 次 订单创建:500 次主要压力来自查询。
这时可以考虑通过缓存减少重复读取,或者通过 MySQL 副本分担读请求。
3. 写请求太多
如果系统每秒需要处理大量订单写入:
INSERT orders UPDATE orders UPDATE inventory而单台 MySQL 的写入能力已经成为瓶颈,那么增加只读副本通常不能直接解决这个问题。
因为在常见的单主复制架构中,写操作仍然集中在主库。
4. 数据容量太大
假设订单表增长到数亿条,数据库实例的存储容量、维护窗口和大表管理成本都越来越高。
这时就可能需要按照业务或数据范围拆分存储。
因此,数据库扩展前应该先判断问题属于哪一种:
| 问题 | 优先考虑的方向 |
|---|---|
| SQL 查询效率低 | 索引、SQL、执行计划 |
| 大量重复读取 | Redis 缓存 |
| 读请求压力过大 | 主从复制、读写分离 |
| 单点故障风险 | 副本、故障切换、备份 |
| 单主库写入能力不足 | 数据拆分、业务写入优化 |
| 单实例数据容量不足 | 归档、分区或分库分表 |
不是所有 MySQL 性能问题,都需要通过增加数据库服务器来解决。
二、主从复制:为什么一份数据库要保存多个副本?
假设系统最初只有一台数据库:
Order Service | v MySQL A如果 MySQL A 突然故障,订单服务就可能无法正常读取和写入数据库。
如果我们能在另一台服务器上保存相应的数据副本,就可以为故障恢复提供基础。
这就是 MySQL Replication(复制)的一个重要作用。
一种常见架构是:
MySQL Primary | +--------+--------+ | | v v Replica 1 Replica 2其中:
**Primary(主库)**负责处理主要写入。
**Replica(从库或副本)**通过复制机制获取主库产生的数据变化。
以前经常使用 Master / Slave 这样的名称,现在更常使用 Primary / Replica。
1. 主从复制是不是每次都复制整张表?
不是。
假设主库已经有:
orders 表 10001 | 888 | UNPAID 10002 | 889 | PAID现在 Order Service 执行:
UPDATEordersSETstatus='PAID'WHEREid=10001;主从复制不需要每次把整个orders表重新发送给从库。
MySQL 可以利用 Binary Log(Binlog,二进制日志)记录数据库的数据变更事件。
副本根据复制机制获取这些事件,并在本地应用相应的数据变化。
2. Binlog 和 Relay Log 分别负责什么?
可以通过下面的流程理解:
MySQL Primary | | 执行事务 v Binary Log | | 复制传输 v Replica I/O | v Relay Log | v Replica Applier | v Replica 数据Binlog:
记录主库产生的相关数据库变更事件,是 MySQL 复制的重要数据来源。
Relay Log:
副本接收来自主库的复制事件后,将它们保存到本地中继日志。
Applier:
副本上的应用线程负责执行接收到的复制事件,使副本数据逐步跟上主库。
需要注意,Binlog 可以有不同的记录格式。实际工程中,Row-Based Replication(基于行的复制)十分常见。
我们可以把复制过程简化理解为:
主库提交数据修改 | v 相关变化记录到 Binlog | v 从库接收复制事件 | v 保存到 Relay Log | v 应用事件,更新本地数据MySQL 事务提交与 Binlog、InnoDB 日志之间还涉及内部的一致性协调机制,但本篇重点先放在主从复制的基本流程上。
3. 主从复制是否等于强一致?
不等于。
尤其是在常见的异步复制模式下,主库提交成功,并不需要等待所有副本都完成相同的数据修改。
所以可能发生:
Primary: 订单已经更新为 PAID Replica: 订单仍然是 UNPAID这并不一定代表复制已经故障。
也可能只是副本还没有应用最新的变更事件。
这种现象就是我们接下来要讨论的复制延迟。
三、读写分离:为什么增加从库可以减轻读压力?
有了主从复制之后,我们就拥有了多份数据库数据。
最初所有请求都访问主库:
Order Service | v Primary现在可以采用:
Order Service | 读写路由 / \ / \ 写请求 读请求 | | v +-----+-----+ Primary | | v v Replica 1 Replica 2这就是常见的读写分离。
1. 写操作主要进入主库
例如:
INSERTINTOorders(id,user_id,status)VALUES(10001,888,'UNPAID');以及:
UPDATEordersSETstatus='PAID'WHEREid=10001;在典型的单主架构中,这些写操作由 Primary 负责。
随后,主库通过复制机制将变更传递给副本。
2. 部分读请求分配给从库
例如:
SELECT*FROMordersWHEREuser_id=888;或者:
SELECT*FROMordersWHEREstatus='PAID';如果业务允许读取略有延迟的数据,就可以将部分查询请求分配给 Replica。
假设:
原来: Primary 每秒处理 10000 次查询现在:
Primary 每秒处理 2000 次查询 Replica 1 每秒处理 4000 次查询 Replica 2 每秒处理 4000 次查询这只是用于说明流量分配的示例,不代表数据库请求一定能够平均分配,也不代表增加副本后吞吐量就会线性提升。
实际性能取决于 SQL、机器资源、缓存命中、复制应用负载和查询路由等因素。
但核心思想很清楚:
将部分读请求从主库转移到副本,使主库保留更多资源处理写入。
3. 谁来决定请求访问主库还是从库?
通常有两种实现方向。
一种是在应用层路由。
例如 Order Service 的数据访问层根据业务要求选择不同的数据源。
创建订单 | v Primary 查询普通历史订单列表 | v Replica另一种是通过数据库代理提供读写路由能力。
例如:
Order Service | v Database Proxy | +---- Primary | +---- Replica 1 | +---- Replica 2应用访问代理,代理根据相应规则把请求转发到目标数据库。
但无论采用哪一种方式,都不能简单认为:
SELECT 一律去 Replica INSERT / UPDATE 一律去 Primary因为某些 SELECT 对数据新鲜度有严格要求。
例如用户刚完成支付,马上查询订单状态。
这就涉及主从复制中最重要的一致性问题。
四、主从延迟:为什么刚刚更新成功,却查到了旧数据?
假设用户支付完成后,Order Service 修改订单状态:
UPDATEordersSETstatus='PAID'WHEREid=10001;该事务已经在 Primary 成功提交。
主库中:
order_id = 10001 status = PAID但是 Replica 还没有应用这条复制事件。
副本中暂时仍然是:
order_id = 10001 status = UNPAID此时用户马上查询订单:
GET /orders/10001如果请求被路由到 Replica,就可能返回:
{"order_id":10001,"status":"UNPAID"}用户明明已经支付成功,却看到订单仍然未支付。
这就是复制延迟导致的读取旧数据问题。
1. 为什么主库和从库之间会有延迟?
因为主库提交事务、复制传输和副本应用事件不是完全同步完成的一个操作。
可以分成三个阶段:
T1:Primary 提交事务 | v T2:Replica 接收到 Binlog 事件 | v T3:Replica 应用事务完成在 T1 与 T3 之间,副本可能还没有最新的数据状态。
造成延迟的原因包括:
- 主库写入量很大,副本需要处理大量复制事件。
- 副本磁盘或 CPU 资源不足。
- 某些复制事务执行时间较长。
- 网络传输出现延迟。
- 副本同时承担大量查询,影响复制应用性能。
因此:
主从复制保证的是数据变更能够按照相应复制机制传播,而不是每一次读取都天然看到主库刚提交的最新结果。
2. 如何保证用户写完以后能读到自己的结果?
我们之前学习过的一个直接方案是:
关键写操作完成后,将需要立即看到最新结果的读取路由到主库。
例如:
用户完成支付 | v Primary 更新订单 | v 用户立即查询订单 | v 仍然读取 Primary这能够避免因为副本尚未完成复制,而从副本读到旧状态。
不过,如果大量读请求都必须走主库,就会降低读写分离带来的收益。
所以另一种思路是只对需要读后写一致性的请求使用主库,普通列表、历史记录等允许短暂延迟的查询仍然可以走副本。
3. GTID 可以解决什么问题?
GTID(Global Transaction Identifier,全局事务标识)可以用于标识 MySQL 复制中的事务。
例如,可以理解成:
事务 T100 已经在 Primary 提交如果应用需要从 Replica 读取最新的业务结果,可以通过 GTID 相关机制判断或等待副本应用到指定的事务位置。
例如:
Primary 提交事务 T100 | v 获取相应事务标识 | v 等待 Replica 应用 T100 | v 从 Replica 查询这种机制能够帮助实现具有明确进度要求的副本读取。
但必须注意:
GTID 只是提供事务身份和复制进度协调基础,不是开启 GTID 后所有从库读取就自动变成强一致。
应用仍然需要选择目标副本、等待相应事务被应用,并处理等待超时的情况。
如果等待超时,可以根据业务要求回退读取主库,或者明确返回当前无法满足新鲜度要求。
4. 半同步复制是不是就没有延迟了?
不是。
MySQL 的 Semi-Synchronous Replication(半同步复制)主要改变主库等待副本确认的机制。
简化理解:
Primary | | 发送复制事件 v Replica | | 确认已接收并记录相关事件 v Primary 获得确认但是:
副本确认收到复制事件,不等于副本已经执行并应用该事务。
所以即使使用半同步复制,副本仍然可能存在读取延迟。
半同步复制可以在满足其确认条件时降低部分故障丢失窗口,但它并不自动提供所有副本读取的强一致性,也不意味着绝对不会丢数据。
五、主从复制能解决数据库高可用吗?
增加 Replica 后,系统拥有多份数据。
如果 Primary 出现故障,可以考虑将符合条件的 Replica 提升为新的 Primary。
例如:
故障前: Primary A | +---- Replica B | +---- Replica CPrimary A 故障:
Primary A × | v 选择合适的 Replica | v Replica B 成为新 Primary这样系统可以恢复写入服务。
但这里还有两个需要特别注意的问题。
1. 主从复制不等于自动故障切换
如果只是搭建了一个 Primary 和两个 Replica,并不代表 Primary 故障后系统就一定会自动完成切换。
还需要有相应的故障检测、主库选举或提升、访问路由更新等机制。
尤其要避免旧 Primary 恢复后继续接受写入,形成多个独立写入源。
否则可能出现数据分叉。
2. 异步复制可能丢失已提交但尚未复制的数据
假设:
T1:Primary 提交订单 10001 T2:应用收到提交成功响应 T3:Primary 发生故障 T4:Replica 尚未接收到该事务如果此时将落后的 Replica 提升为新 Primary,那么新主库可能没有订单 10001。
从用户角度看:
订单提交成功 | v 数据库发生故障 | v 切换后的数据库没有这条订单这就是异步复制在故障切换时存在的数据丢失窗口。
所以:
主从复制能够提供高可用的基础,但不能单独保证自动切换、零数据丢失和业务强一致。
另外,复制也不能替代备份。
例如某个错误的 SQL 把大量订单删除:
DELETEFROMorders;如果这个操作被提交并复制到 Replica,那么副本也可能执行相应删除。
因此,高可用、副本复制和数据备份解决的是不同问题。
六、为什么主从复制不能直接解决写入瓶颈?
这是区分读写分离与分库分表的关键。
假设原来一台 MySQL:
Primary 每秒: 5000 次读取 1000 次写入增加两台 Replica 后:
Primary: 1000 次写入 部分读取 Replica 1: 分担读取 Replica 2: 分担读取虽然读请求可以分散,但所有写入仍然主要集中在 Primary。
例如:
INSERTINTOorders(...);UPDATEinventorySETstock=stock-1WHEREproduct_id=1001;这些操作在单主架构下仍然需要由 Primary 处理。
而且副本也需要应用主库产生的写入变更。
因此,增加 Replica 并不是把一条写操作平均分配到三台机器执行。
如果真正的瓶颈来自大量写入,或者数据量已经超过单个数据库实例适合管理的范围,就需要考虑进一步拆分。
这就是分库分表的主要应用场景之一。
七、分库分表:到底是在拆什么?
“分库分表”经常被当成一个整体概念,但实际上包含不同的拆分方向。
我们可以先区分:
垂直拆分 水平拆分1. 垂直拆分:按照业务职责拆分
假设最初整个电商系统只有一个数据库:
ecommerce_db | +-- users | +-- orders | +-- order_items | +-- products | +-- inventory | +-- payments随着系统发展,我们可以按照业务能力划分数据库所有权。
例如:
User Service | v user_db | +-- usersOrder Service | v order_db | +-- orders +-- order_itemsInventory Service | v inventory_db | +-- inventory这种方式属于按照业务领域进行垂直拆分的典型形式。
它和第一篇讨论的服务拆分有直接联系。
核心目标是:
让不同业务能力拥有清晰的数据边界,并能够按照自己的需求独立维护。
例如:
Order Service应负责订单数据的写入与修改。
而:
Payment Service不应该随意绕过订单服务,直接修改orders表中的内部字段。
需要注意,垂直拆分不一定意味着每个业务服务都占有独立的物理数据库服务器。
逻辑数据库、数据库实例和物理服务器是不同层次的概念。
2. 水平拆分:把同一类数据分散到多个存储单元
假设orders表已经有数亿条记录。
即使整个 Order Service 只管理订单业务,单个数据库实例也可能面临容量和写入压力。
这时可以将同一种业务数据按某个规则划分到多个分片。
例如:
Order Service | v Sharding Router | +-- order_db_0 | +-- order_db_1 | +-- order_db_2 | +-- order_db_3四个数据库都用于保存订单数据,但每个数据库只承担其中一部分订单。
这就是水平分库的思路。
也可以在单个数据库中创建多张订单表:
orders_0 orders_1 orders_2 orders_3由应用根据分片规则选择目标表。
不过,把一张表拆成多张仍位于同一数据库实例中的表,不代表已经扩展了该实例的物理 CPU、I/O 或存储能力。
如果希望真正分散单实例的资源瓶颈,通常还需要让数据分布到不同的数据库实例或存储资源上。
垂直拆分和水平拆分怎么区分?
可以用一句话记忆:
垂直拆分主要回答“哪些业务数据应该分开管理”。
水平拆分主要回答“同一类业务数据太多时,如何分散存储和访问压力”。
例如:
垂直拆分: 用户数据 | 订单数据 | 支付数据水平拆分: 订单数据 1 | 订单数据 2 | 订单数据 3实际系统也可以结合两种方式,先按照业务能力划分数据边界,再对增长特别快的业务表进行水平拆分。
八、水平分库之后,Order Service 怎么知道数据在哪个库?
假设订单数据分布在四个数据库中:
order_db_0 order_db_1 order_db_2 order_db_3用户 888 查询自己的订单:
SELECT*FROMordersWHEREuser_id=888;这时候 Order Service 必须知道:
用户 888 的订单到底应该去哪个数据库查询?
这就是 Sharding Key(分片键)和分片路由需要解决的问题。
1. 根据 user_id 进行分片
假设我们决定使用:
user_id作为分片键。
一个简单的规则是:
shard_id = user_id % 4例如:
| user_id | 计算过程 | 目标数据库 |
|---|---|---|
| 888 | 888 % 4 = 0 | order_db_0 |
| 889 | 889 % 4 = 1 | order_db_1 |
| 890 | 890 % 4 = 2 | order_db_2 |
| 891 | 891 % 4 = 3 | order_db_3 |
那么用户 888 创建订单时:
Order Service | | user_id = 888 v Sharding Router | | 888 % 4 = 0 v order_db_0查询用户 888 的订单时,也执行相同的路由计算。
这样就不需要向四个数据库分别查询。
2. 为什么分片键很重要?
假设我们使用user_id分片。
那么:
SELECT*FROMordersWHEREuser_id=888;很容易定位到:
order_db_0但如果查询条件只有:
SELECT*FROMordersWHEREid=10001;问题就出现了。
因为仅凭订单 ID,我们不知道它最初属于哪个用户,也就无法直接使用user_id % 4计算目标分片。
这时候可能需要:
- 根据订单 ID 查询一张额外的路由映射表。
- 通过订单 ID 的生成规则推导分片位置。
- 根据业务上下文获取对应
user_id。 - 或者向多个分片发起查询。
这也是为什么分片键不能随意选择。
分片键决定了数据如何分布,也决定了以后哪些查询能够被直接路由到单个分片。
3. 为什么不能只考虑数据分布均不均匀?
例如我们选择:
user_id % 4如果大部分用户的订单量差不多,那么数据可能比较均匀。
但如果某个用户每天产生几百万条订单,而普通用户只产生几条订单,那么这个大客户可能让某个分片承受明显更高的写入压力。
这就是热点分片问题。
因此,分片键设计需要同时考虑:
数据分布 写入压力 查询条件 业务事务边界 未来扩容如果只考虑当前数据量分布,却忽略实际查询方式,可能会导致分库以后大量 SQL 都需要跨分片执行。
九、分库分表之后,为什么查询反而可能变慢?
分库分表可以把数据分散到多个存储单元,但也会改变原有的查询方式。
假设原来只有一张orders表:
SELECT*FROMordersWHEREstatus='PAID'ORDERBYcreated_atDESCLIMIT20;如果数据都在同一个数据库中,MySQL 可以根据索引和执行计划完成查询与排序。
但现在订单被分散到四个数据库。
order_db_0 order_db_1 order_db_2 order_db_3而查询条件只有:
status = PAID没有分片键user_id。
那么系统通常无法直接知道目标订单在哪一个分片。
可能需要:
Order Service | v Query Router | +--------------+--------------+ | | | v v v order_db_0 order_db_1 order_db_2 | | | +--------------+--------------+ | order_db_3 | v 合并查询结果 | v 全局排序实际执行可以并行访问四个分片,再合并它们返回的结果。
这种方式通常称为 Scatter-Gather(分散查询与汇总)。
1. 全局排序为什么更复杂?
每个数据库可以分别查出自己的最新订单。
例如:
Shard 0: 订单 A 订单 D 订单 GShard 1: 订单 B 订单 E 订单 HShard 2: 订单 C 订单 F 订单 I但用户想看到的是整个系统按created_at排序的最新 20 条订单。
所以应用或查询代理还需要合并多个分片中的结果,得到全局排序。
对于简单的 Top-N 查询,可以让各分片分别返回候选结果,再进行合并。
但对于复杂的过滤、聚合和深分页,执行成本可能更高。
2. COUNT 和 JOIN 也可能变复杂
例如:
SELECTCOUNT(*)FROMorders;如果订单分布在四个分片中,就需要汇总各分片的统计结果,或者维护相应的汇总数据。
再比如:
SELECT*FROMorders oJOINpayments pONo.id=p.order_id;如果订单与支付数据位于不同数据库,原来的一条本地 JOIN 就可能变成跨服务或跨分片的数据关联。
这样会带来更多网络请求、数据搬运和一致性问题。
因此:
分库分表不是让所有 SQL 自动变快,而是用更复杂的数据访问与路由机制,换取更大的整体存储和处理容量。
十、为什么分库分表后,事务也变复杂了?
前面我们学习 MySQL 事务时,可以使用:
STARTTRANSACTION;UPDATEinventorySETstock=stock-1WHEREproduct_id=1001ANDstock>0;INSERTINTOorders(...);COMMIT;只要这些操作位于同一个可参与事务的 MySQL 实例中,就可以通过本地事务协调原子提交。
但假设分库以后:
Order Service | v order_db_0而库存位于:
Inventory Service | v inventory_db那么一次下单涉及两个独立的数据存储单元。
创建订单 | v order_db_0 扣减库存 | v inventory_db此时:
order_db_0 COMMIT并不会自动让:
inventory_db COMMIT和它组成一个普通的本地原子事务。
例如:
order_db_0: 订单创建成功但:
inventory_db: 库存扣减失败就需要业务层处理跨系统状态协调。
这也解释了为什么前面学习分布式事务时,会接触:
2PC TCC Saga Transactional Outbox这些机制分别适用于不同的事务协调与可靠消息场景。
需要注意,分库分表后并不是所有事务都会变成分布式事务。
例如按照user_id分片后,如果一个用户的订单主记录与订单明细都在同一个数据库分片中,那么相关操作仍然可以在该分片内使用普通 MySQL 本地事务。
因此:
好的分片设计,不只是把数据均匀分散,还应该尽量让经常需要一起提交的业务数据保持在可管理的事务边界内。
当然,如果业务天然需要跨多个分片修改数据,就不能依靠一个简单的本地事务解决全部问题。
十一、为什么分片数量增加后,数据迁移会很麻烦?
前面我们使用:
shard_id = user_id % 4把数据分配到四个分片中。
假设几年后,四个分片容量都逐渐不足,需要扩容到八个分片。
一个很直接的想法是把规则改成:
shard_id = user_id % 8但问题马上出现。
例如:
user_id = 892原来:
892 % 4 = 0所以数据保存在:
order_db_0扩容后:
892 % 8 = 4按照新规则,应用会去:
order_db_4查询。
但老数据实际上还在:
order_db_0如果只是修改路由规则,却没有迁移数据,就可能出现:
数据实际存在 但是应用查不到的问题。
这就是简单取模分片在扩容时面临的一个典型难点。
1. 扩容不是简单增加四台数据库
扩容时可能需要考虑:
旧分片中的数据怎么迁移? 迁移过程中还能不能写入? 新旧路由规则如何兼容? 迁移期间新增的数据保存在哪里? 如何验证数据没有丢失或重复? 什么时候切换到新的分片规则?例如:
旧路由:user_id % 4 | v 数据迁移 | v 新路由:user_id % 8如果迁移期间用户仍然不断创建订单,就需要处理持续产生的新数据。
不能只复制一次旧表,然后直接切换。
2. 为什么有些系统不直接按数据库数量取模?
因为:
user_id % N直接把路由规则绑定到了分片数量 N。
一旦 N 变化,部分数据的目标位置也会变化。
为了降低扩容时的数据迁移范围,系统可以引入额外的逻辑分片层或路由映射关系。
例如:
user_id | v 逻辑分片编号 | v 物理数据库实例这样,业务数据先映射到相对稳定的逻辑分片,再由配置决定每个逻辑分片位于哪个物理数据库实例。
扩容时,可以将部分逻辑分片迁移到新的物理实例,而不是简单修改所有数据的取模规则。
不过,这会引入新的路由管理和迁移复杂度。
因此,分库分表不能只考虑第一次拆分是否简单,还应该考虑后续扩容和数据迁移的成本。
十二、主从复制和分库分表可以一起使用吗?
可以,而且这两种机制解决的问题不同。
例如,我们已经把订单数据分成两个分片:
order_db_0 order_db_1每个分片内部都可以再设置主从复制:
Order Service | Shard Router | +----------+----------+ | | v v order_db_0 order_db_1 | | +-----+-----+ +-----+-----+ | | | | v v v v Primary 0 Replica 0 Primary 1 Replica 1其中:
分片路由负责决定某条订单数据属于哪个分片。
Primary / Replica负责对应分片内部的数据复制与读取分担。
例如用户 888 查询订单:
user_id = 888 | v 定位 Shard 0 | v 选择 Primary 0 或 Replica 0这里实际上有两个独立的路由决策:
第一步,确定数据所在的分片。
第二步,确定这个分片中的读写目标实例。
这种架构可以同时扩展数据容量和部分读请求处理能力。
但它的复杂度也明显提高。
系统不再只有一个 Primary 和几个 Replica,而是可能有多个分片,每个分片各自维护复制关系、故障切换和监控。
所以:
水平分片提高的是整体可扩展上限,但也增加了需要管理的数据库实例数量与运维复杂度。
十三、一个完整例子:订单系统如何从单库逐步演进?
现在把前面所有知识放到同一个业务场景中。
假设我们正在开发一个电商订单系统。
阶段一:单台 MySQL
系统刚上线时:
Order Service | v MySQL所有订单读写都在一台数据库完成。
此时最重要的是:
- 设计合理的主键和联合索引。
- 正确使用事务和条件更新。
- 避免不必要的全表扫描。
- 建立备份和基本监控。
对于业务量不大的系统,这种方式可能已经足够。
阶段二:增加 Redis 缓存
随着商品详情和部分订单列表的查询频率增加,可以对适合缓存的数据使用 Redis。
Order Service | +---- Redis | +---- MySQL这样能够减少部分重复读取。
但关键订单状态、库存判断等仍然需要遵循对应的权威数据和一致性要求。
阶段三:增加 Replica,进行读写分离
当普通查询压力已经明显影响主库时:
Order Service | +---- Primary | +---- Replica 1 | +---- Replica 2将能够容忍短暂延迟的查询分配给 Replica。
同时,对于刚完成支付、刚修改订单状态等需要立即读取最新结果的请求,使用主库或者具有明确复制进度等待的读取策略。
这一步解决的是读压力分担,不是直接解决写入瓶颈。
阶段四:按照业务职责拆分数据所有权
随着业务与团队规模扩大:
User Service → user_db Order Service → order_db Payment Service → payment_db Inventory Service → inventory_db各业务服务拥有更加清晰的数据管理边界。
但跨服务查询和事务需要通过接口、事件或其他协调机制完成。
阶段五:对高增长数据进行水平分片
如果orders的数据量和写入压力仍然持续增长:
Order Service | v Sharding Router | +---- order_db_0 | +---- order_db_1 | +---- order_db_2 | +---- order_db_3这时需要认真设计分片键、路由、跨分片查询与扩容迁移。
不过,这五个阶段并不是所有公司都必须经历的固定流程。
有些系统只需要单库加索引优化就能长期稳定运行。
有些系统主要是读压力大,增加 Replica 即可。
还有一些系统可以通过历史数据归档、业务表优化或数据库实例升级延缓分片需求。
架构演进应该由真实瓶颈驱动,而不是根据用户量或表记录数机械套用某个方案。
十四、总结:数据库扩展的核心是分清读、写和数据边界
这一篇从单台 MySQL 的瓶颈出发,逐步介绍了主从复制、读写分离和分库分表。
它们可以概括为三个层次。
第一层:主从复制
Primary | +---- Replica 1 | +---- Replica 2核心价值是提供数据副本,为读扩展和故障恢复提供基础。
但异步复制存在复制延迟,故障切换也可能存在数据丢失窗口。
第二层:读写分离
写入 → Primary 部分读取 → Replica核心价值是分担读取压力。
但不能简单认为增加 Replica 就能提高单主库的写入吞吐量。
对于要求读后写一致性的业务,还需要考虑读取主库或者等待副本追上指定的复制进度。
第三层:分库分表
业务数据拆分 | +---- 垂直拆分 | +---- 水平拆分垂直拆分主要用于划分业务数据边界。
水平拆分主要用于将同类数据和访问压力分布到多个分片。
但分片会引入:
分片键设计 数据路由 跨分片查询 全局排序 跨库事务 数据迁移 故障恢复等额外复杂度。
最后用一张表总结三种方案:
| 对比 | 主从复制 | 读写分离 | 水平分库分表 |
|---|---|---|---|
| 核心目的 | 保留数据副本 | 分担读取压力 | 分散数据和写入压力 |
| 能否直接扩展单主写入能力 | 否 | 否 | 可以分散不同分片的写入 |
| 是否存在一致性问题 | 存在复制延迟 | 可能读取旧数据 | 存在跨分片一致性问题 |
| 是否改变数据路由 | 一般不改变分片归属 | 需要选择读写目标 | 需要确定数据所在分片 |
| 主要复杂度 | 复制、故障切换 | 读写路由、读后写一致性 | 路由、事务、查询、扩容迁移 |
其中最重要的结论是:
主从复制并不是分库分表,读写分离也不是把写请求分散到多个主库。
主从复制让系统拥有数据副本。
读写分离利用这些副本承担部分读取。
分库分表则进一步改变数据的存储和访问边界。
对于实际系统,应该先检查 SQL、索引与数据库负载,再根据读压力、写压力、容量和可用性要求选择相应方案。
数据库扩展不是单纯增加 MySQL 的数量,而是重新设计数据应该存在哪里、请求应该访问哪里,以及发生故障时怎样保持正确性。
到这里,MySQL 的三篇核心内容就已经完整了:
第八篇解释事务、MVCC 和锁,解决并发正确性问题。
第九篇解释 B+ 树、回表和联合索引,解决数据访问效率问题。
第十篇解释主从复制、读写分离和分库分表,解决数据库规模增长后的扩展问题。
下一篇将进入分布式事务,讨论为什么 MySQL 的本地事务在跨服务场景中不够用,以及 Saga 和 Transactional Outbox 为什么会成为常见的解决方案。