用户在社交平台上发布了一条评论,刷新页面后评论消失了。再刷新一次,评论又出现了。用户困惑:我的评论到底保存成功了没有?
这不是偶发的bug。在生产环境中,这类问题每天都在发生。根因往往指向同一个架构决策:读写分离。
读写分离是扩展数据库读能力的标准手段——主库处理写入,多个从库分担读取压力。这套架构看起来完美,直到你发现它破坏了一个用户最基本的预期:写入成功后立即读取,应该能看到刚才写入的数据。这个看似简单的预期,在异步复制的架构下却变得异常复杂。
异步复制的必然代价:复制延迟
要理解问题的根源,需要先理解MySQL主从复制是如何工作的。
MySQL复制是一个pull-based架构。从库主动连接主库,请求二进制日志(binlog)中的事件。这个过程涉及三个关键组件:
主库的Binlog Dump线程:当从库连接时,主库为每个从库创建一个独立的dump线程,负责读取binlog并发送事件。
从库的I/O线程:连接主库,接收binlog事件,写入本地的relay log。
从库的SQL线程:读取relay log,将事件应用到本地数据库。
这个设计决定了复制必然存在延迟。主库提交事务后,事件需要经过网络传输、写入relay log、再由SQL线程执行——每一步都消耗时间。在理想状态下,这个延迟可能在毫秒级;但在高负载、大事务或网络抖动时,延迟可能飙升到秒级甚至分钟级。
SHOW REPLICA STATUS 中的 Seconds_Behind_Master 字段试图量化这个延迟,但它的准确性存在争议。这个值是通过比较当前执行的relay log事件时间戳与最近接收到的事件时间戳计算得出的,无法反映从库与主库之间的真实时间差。Percona工具包中的 pt-heartbeat 提供了更准确的测量方式:在主库定期写入心跳记录,从库读取该记录计算实际延迟。
延迟从何而来
复制延迟不是单一因素造成的,而是多个层面问题的叠加:
主库层面:高并发写入产生大量binlog,dump线程来不及发送。大事务(如批量更新百万行)会长时间占用binlog,阻塞后续事件的发送。
网络层面:跨机房复制的网络延迟、带宽瓶颈、丢包重传都会拖慢事件传输。
从库层面:这是最常见的瓶颈。在单线程复制模式下,SQL线程串行执行所有事件——即使主库并发执行了100个事务,从库也只能一个个应用。MySQL 5.7引入了基于逻辑时钟的并行复制,MySQL 8.0进一步支持WRITESET并行化,但配置不当或数据冲突仍会导致延迟。
资源竞争:从库既要接收事件,又要应用事件,还要处理读请求。当读请求占用大量资源时,复制线程可能被"饿死"。
表结构问题:没有主键的表在row-based复制模式下性能极差,因为每次更新都需要全表扫描来定位行。
理解了延迟的来源,才能理解为什么"写后读"会出问题。
写后读不一致:四种典型场景
当应用执行写入后立即读取,而读请求被路由到尚未同步的从库,问题就出现了。根据不一致的表现形式,可以归纳为四种场景:
场景一:读不到刚写入的数据
用户插入一条记录,立即查询,结果为空。因为从库还没收到这条记录的binlog事件。
这是最常见的写后读问题。在社交平台、电商系统、内容管理系统等场景中,用户创建内容后刷新页面,发现内容"消失"了。
场景二:读到旧数据
用户更新一条记录,立即查询,看到的仍是旧值。比如修改个人简介后,页面显示的还是旧简介。
这种情况比"读不到"更隐蔽,用户可能不会立即发现问题,而是产生困惑:系统是否真的保存了我的修改?
场景三:数据"闪烁"
用户反复刷新页面,数据在新旧之间来回切换。这是因为负载均衡将读请求分发到不同的从库,而这些从库的延迟各不相同。
Shopify的技术博客详细描述了这个问题:当多个查询组成一个逻辑单元时,如果被路由到延迟不同的从库,可能产生荒谬的结果——第一个查询返回某条记录,第二个关联查询却找不到它。
场景四:跨设备不一致
用户在手机上发布内容,切换到电脑查看却找不到。这是因为两个设备的读请求可能被路由到不同机房的不同从库,延迟差异更大。
解决方案的全景图谱
解决写后读一致性问题,本质上是在性能与一致性之间寻找平衡。不存在完美的方案,只有适合特定场景的权衡。
方案一:强制走主库
最直接的方案:对于需要写后读一致性的操作,所有读请求都路由到主库。
实现方式:
- 在应用层标记哪些读操作必须走主库
- 通过数据库代理(如ProxySQL、MaxScale)配置路由规则
- 在ORM层面区分读写数据源
优点:实现简单,保证一致性。
缺点:削弱了读写分离的价值。如果大量读操作都走主库,主库成为瓶颈,扩展性受损。
适用场景:读请求占比低、对一致性要求极高、写入后短时间内必读的业务。比如支付系统、订单创建后的状态查询。
方案二:用户粘滞(Pinning User to Master)
更精细的策略:只有最近写入过的用户的读请求才走主库,其他用户的读请求仍走从库。
实现逻辑:
- 用户执行写入操作时,记录用户ID和写入时间
- 后续的读请求先检查该用户是否在"粘滞期"内
- 如果在粘滞期内,路由到主库;否则路由到从库
粘滞期的长度需要根据典型复制延迟来设定。如果复制延迟通常在500ms以内,粘滞期设为1-2秒较为合理。
优化变体:关键数据粘滞
不是所有读操作都需要强一致性。可以对数据进行分类:
- 用户自己的数据:写入后需要立即读到,走主库
- 公共数据、历史数据:可以容忍短暂不一致,走从库
这种方式减少了主库压力,但增加了业务代码的复杂度。
方案三:GTID因果一致性
GTID(Global Transaction Identifier)为每个事务分配全局唯一标识。MySQL 5.7.5之后,可以通过GTID实现精确的写后读一致性。
原理:
- 主库在事务提交后,将GTID返回给客户端
- 客户端在后续读请求中携带这个GTID
- 从库执行
WAIT_FOR_EXECUTED_GTID_SET()等待该GTID对应的事务应用完成 - 然后执行实际的查询
-- 主库写入后获取GTID
SET @@session.session_track_gtids = 'OWN_GTID';
INSERT INTO comments (content) VALUES ('Hello');
-- 客户端从OK包中提取GTID: 3E11FA47-71CA-11E1-9E33-C80AA9429562:23
-- 从库等待该事务
SELECT WAIT_FOR_EXECUTED_GTID_SET('3E11FA47-71CA-11E1-9E33-C80AA9429562:23', 5);
-- 然后执行查询
SELECT * FROM comments WHERE content = 'Hello';
ProxySQL的自动化实现:
手动管理GTID在生产中不可行。ProxySQL 2.0提供了自动化的GTID因果一致性:
- ProxySQL作为MySQL客户端,通过
session_track_gtids追踪每个连接的最后写入GTID - 通过Binlog Reader组件实时监控所有MySQL服务器的GTID执行位置
- 当收到需要一致性的读请求时,自动路由到已执行该GTID的服务器
优点:精确保证写后读一致性,不增加主库压力。
缺点:需要MySQL 5.7.5+、开启GTID、使用ROW格式binlog。实现复杂度高,需要中间件支持。
方案四:半同步复制
默认的异步复制无法保证数据安全到达从库。半同步复制要求至少一个从库确认收到事务后,主库才向客户端返回成功。
工作流程:
- 客户端提交事务
- 主库写入binlog
- 主库等待至少一个从库的ACK(确认已写入relay log)
- 主库提交存储引擎事务,返回成功
-- 启用半同步复制
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 10000; -- 10秒超时
关键配置:
rpl_semi_sync_master_wait_for_slave_count:需要确认的从库数量。默认为1,可以设置为更高以获得更强持久性。
rpl_semi_sync_master_timeout:等待从库ACK的超时时间。超时后降级为异步复制。设为极大值可禁用降级。
PlanetScale的分析指出:半同步复制能保证持久性(数据不会因主库崩溃而丢失),但不保证读一致性。从库收到binlog并写入relay log就会ACK,但此时事务可能还未应用。因此,写后立即读从库仍可能读到旧数据。
要实现真正的写后读一致性,需要 rpl_semi_sync_master_wait_for_slave_count 配合从库的 rpl_semi_sync_slave_apply 设置,等待从库实际应用事务后才ACK。但这会显著增加延迟。
适用场景:对数据持久性要求高(不能接受主库崩溃导致数据丢失),但对写后读一致性要求不严格的场景。
方案五:单调读一致性
Shopify在实践中采用了一种更轻量的方案:单调读一致性。
不追求读到最新数据,但保证同一个用户的连续读请求看到的数据是时间递增的——不会先看到新数据,刷新后反而看到旧数据。
实现方式:
ProxySQL支持通过 consistent_read_id 实现会话粘滞:
/* consistent_read_id:550e8400-e29b-41d4-a716-446655440000 */
SELECT * FROM comments WHERE user_id = 123;
相同的 consistent_read_id 会通过哈希算法路由到同一个从库,保证同一系列请求看到一致的数据视图。
优点:实现简单,开销低,避免了数据"闪烁"问题。
缺点:不能保证读到最新数据,只能保证不回退。适合一致性要求不那么严格的场景。
PostgreSQL的方案:synchronous_commit
PostgreSQL提供了更细粒度的控制参数 synchronous_commit,允许在事务级别选择一致性强度:
| 设置值 | 行为 | 性能影响 |
|---|---|---|
off |
事务提交立即返回,不等待WAL刷盘 | 最快,可能丢失最近1秒的数据 |
local |
等待本地WAL刷盘,不关心从库 | 中等 |
remote_write |
等待至少一个同步从库收到WAL并写入OS缓存 | 较慢 |
on |
等待至少一个同步从库收到WAL并刷盘(默认) | 慢 |
remote_apply |
等待至少一个同步从库应用事务 | 最慢,保证写后读一致性 |
Cybertec的测试数据显示,在同步复制模式下,remote_apply 比 on 慢约8%,但能保证从库读到最新数据。
-- 对关键事务使用最强一致性
SET LOCAL synchronous_commit = remote_apply;
INSERT INTO orders (user_id, amount) VALUES (123, 99.99);
-- 对非关键事务使用宽松设置
SET LOCAL synchronous_commit = local;
INSERT INTO logs (message) VALUES ('User action recorded');
MongoDB的因果一致性
MongoDB 3.6+原生支持因果一致性会话,通过以下机制实现:
// 开启因果一致性会话
const session = client.startSession({
causalConsistency: true
});
// 写入操作自动记录操作时间
session.startTransaction();
await collection.insertOne({ _id: 1, name: 'test' }, { session });
await session.commitTransaction();
// 后续读取会自动等待前面的写入
const result = await collection.findOne({ _id: 1 }, {
session,
readConcern: 'majority'
});
MongoDB驱动会自动在会话中传递逻辑时间戳,确保读操作等待之前的写操作在多数节点上生效。
四个保证:
- Read Your Writes:读操作反映之前的写入
- Monotonic Reads:读操作不会看到更旧的数据
- Monotonic Writes:写操作按顺序生效
- Writes Follow Reads:写操作在之前读操作之后生效
实践决策框架
选择合适的方案,需要回答几个问题:
问题一:一致性要求有多严格?
如果业务允许短暂不一致(如社交媒体的内容更新),可以接受最终一致性。如果业务要求严格一致(如支付、订单状态),必须采用更强的保证。
问题二:读请求中有多少比例需要写后读一致性?
如果比例很低(比如只有5%的读操作需要一致性),简单的"强制走主库"可能就够用。如果比例很高,需要考虑更精细的方案。
问题三:复制延迟通常是多少?
延迟越低,“粘滞用户"方案的窗口期越短,主库压力越小。如果延迟经常超过秒级,粘滞方案的效果会大打打扣。
问题四:架构是否允许引入中间件?
如果团队有能力运维ProxySQL等中间件,GTID因果一致性是优雅的解决方案。如果只能控制应用代码,需要在应用层实现粘滞逻辑。
问题五:能否接受写入延迟增加?
半同步复制和强一致性的同步复制会增加写入延迟。如果写入性能是瓶颈,这些方案可能不适合。
一个实际案例的演进
假设一个电商平台,用户下单后跳转到订单详情页。最初的架构是简单的读写分离,用户反馈"刚下的订单点进去显示不存在”。
第一阶段:快速修复
对订单详情页的查询强制走主库。问题解决了,但主库CPU使用率上升。
第二阶段:精细优化
分析发现,只有用户自己的订单查询需要强一致性。实现用户粘滞:下单后5秒内,该用户的订单查询走主库。主库压力下降60%。
第三阶段:架构升级
引入ProxySQL,实现GTID因果一致性。用户下单后,后续查询自动等待事务同步到从库,然后从从库读取。主库压力进一步下降,且不牺牲一致性。
这个演进过程说明:解决写后读一致性问题,不是一次性选择,而是根据实际情况持续优化。
写在最后
读写分离是扩展数据库读能力的有效手段,但异步复制的本质决定了写后读一致性问题不可避免。解决这个问题没有万全之策,只有权衡。
从最简单的"强制走主库",到精细的"用户粘滞",再到基于GTID的因果一致性,每种方案都有其适用场景。关键在于理解业务的一致性需求,测量实际的复制延迟特性,然后选择合适的方案——或者组合使用多种方案。
一致性问题的本质,是在分布式系统中做出取舍。了解每种方案的代价和收益,才能做出明智的决策。
参考资料
- Shopify Engineering. “Read Consistency with Database Replicas” - https://shopify.engineering/read-consistency-database-replicas
- Arpit Bhayani. “Read-Your-Writes Consistency” - https://arpitbhayani.me/blogs/read-your-write-consistency/
- ProxySQL. “GTID consistent reads” - https://proxysql.com/blog/proxysql-gtid-causal-reads/
- PlanetScale. “MySQL semi-sync replication: durability consistency and split brains” - https://planetscale.com/blog/mysql-semi-sync-replication-durability-consistency-and-split-brains
- Kristian Nielsen. “Using MASTER_GTID_WAIT() to avoid stale reads from slaves in replication” - https://kristiannielsen.livejournal.com/18308.html
- Cybertec PostgreSQL. “The synchronous_commit parameter and streaming replication” - https://www.cybertec-postgresql.com/en/the-synchronous_commit-parameter/
- MongoDB Documentation. “Causal Consistency and Read and Write Concerns” - https://www.mongodb.com/docs/manual/core/causal-consistency-read-write-concerns/
- MySQL Reference Manual. “Semisynchronous Replication” - https://dev.mysql.com/doc/refman/en/replication-semisync.html
- Percona. “MySQL High Availability: Stale Reads and How to Fix Them” - https://www.percona.com/blog/mysql-high-availability-stale-reads-and-how-to-fix-them/
- Martin Kleppmann. “Designing Data-Intensive Applications”, Chapter 5: Replication
- Leslie Lamport. “Time, Clocks, and the Ordering of Events in a Distributed System” - Communications of the ACM, 1978
- Douglas B. Terry et al. “Session Guarantees for Weakly Consistent Replicated Data” - PDIS 1994
- Amazon Aurora Documentation. “Read consistency for write forwarding” - https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-mysql-write-forwarding-consistency.html
- PostgreSQL Documentation. “Synchronous Commit” - https://www.postgresql.org/docs/current/runtime-config-wal.html