外观
从 3s 到 0:读写分离主从延迟全链路规避实战
问题背景:支付成功后订单详情提示“订单不存在”
大促凌晨,告警群连续收到同一类客诉:用户在 App 上完成支付,收银台跳回订单详情,页面却提示“订单不存在”,手动刷新一次又恢复正常。排查链路后很快定位:支付回调在主库把 t_order.status 从 0 更新为 1,而订单详情查询被读写分离组件路由到了从库,从库尚未回放完这笔事务,于是读到了旧状态,甚至订单记录本身都还不存在。
典型一主多从架构下,请求链路是:客户端 → 应用集群 → 读写分离路由(ShardingSphere/ProxySQL/自研路由)→ 主库写、从库读。主库 dump 线程把 binlog 推送给从库 IO 线程写入 relay log,SQL 线程再按顺序回放。每多一个环节就多一段延迟:网络往返、relay log 落盘、SQL 线程回放速度。遇到大事务、从库索引缺失导致慢查询、行锁或元数据锁等待、从库负载过高,延迟会从毫秒级膨胀到秒级。
这就是经典的“读己之写”(Read-Your-Writes)一致性问题:同一用户写入后立刻读取,必须看到自己刚提交的结果。单纯依赖异步复制无法保证,必须在链路各层组合规避。
方案对比:五种主从延迟治理策略选型
| 方案 | 一致性保证 | 业务侵入 | 主要成本 | 适用场景 |
|---|---|---|---|---|
| 关键读强制走主库 | 强 | 低 | 主库读压力增大 | 支付、余额等核心链路 |
| 写后会话粘性(时间窗走主库) | 强(窗口内) | 中 | 状态跨服务传递 | 下单后跳转、修改资料 |
| GTID 精准等待 | 强 | 中 | 从库等待增加 RT | 读多写少、希望保护主库 |
| 半同步复制 | 从库收到 binlog 即确认 | 低 | 写入多一次网络 RTT | 金融级、可容忍写延迟上升 |
| 缓存兜底/写后读缓存 | 最终一致 | 中 | 缓存一致性复杂 | 高并发读、容忍短暂旧值 |
实战中很少单选,推荐组合拳:半同步复制压缩延迟窗口 + 写后会话粘性 + GTID 等待读从库 + 超时降级主库 + 心跳监控与自动摘流。
实操步骤:建表、GTID 等待与会话粘性落地
1. 建表与索引:先消除慢查询对延迟的放大
从库 SQL 线程回放慢、业务查询走从库也慢,都会让主从延迟问题雪上加霜。订单表主要按用户维度查询,建表时就要把联合索引建好:
sql
CREATE TABLE `t_order` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_no` varchar(32) NOT NULL COMMENT '订单号',
`user_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0待支付 1已支付 2已取消',
`amount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_status` (`user_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
-- 写入一笔订单
INSERT INTO t_order(order_no,user_id,status,amount)
VALUES ('NO20240520001',10086,0,99.00);
-- 订单详情查询:user_id 命中 idx_user_status,order_no 命中唯一索引
EXPLAIN SELECT id,order_no,status,amount,created_at
FROM t_order
WHERE user_id=10086 AND order_no='NO20240520001';2. 开启 GTID,在从库精准等待事务回放
主从节点配置 gtid_mode=ON、enforce_gtid_consistency=ON 后,每笔事务都有全局唯一 GTID。写入后拿到主库已执行的 GTID 集合,就能在从库上等待这笔事务回放完成,而不是粗暴地等待固定秒数:
sql
-- 主库:查看当前 GTID 执行集合
SELECT @@GLOBAL.gtid_executed AS gtid_executed;
-- 从库:查看复制状态(MySQL 8.0 写法,5.7 用 SHOW SLAVE STATUS)
SHOW REPLICA STATUS\G
-- 从库:等待指定 GTID 回放完成,超时 1 秒;成功返回 0,超时返回 1
SELECT WAIT_FOR_EXECUTED_GTID_SET(
'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:10086', 1
) AS wait_result;注意 WAIT_FOR_EXECUTED_GTID_SET 第二个参数是超时秒数,必须显式传入,否则默认无限等待。
3. 应用层:会话粘性 + GTID 等待 + 降级主库
单体应用可用 ThreadLocal 维护“写后时间窗”;微服务或线程池切换场景下,应把标记按 userId 放入 Redis:
java
// 写接口:下单成功后打上主库粘性标记,并记录 GTID
@Transactional
public Order createOrder(OrderCreateCmd cmd) {
Order order = orderMapper.insert(cmd);
// 时间窗粘性:3 秒内该用户的读请求一律走主库
stickyManager.markMaster(cmd.getUserId(), Duration.ofSeconds(3));
// 同时记录 GTID,供读从库前精准等待
String gtid = jdbc.queryForObject(
"SELECT @@GLOBAL.gtid_executed", String.class);
stickyManager.bindGtid(cmd.getUserId(), gtid);
return order;
}
// 读接口:先判粘性,再尝试 GTID 等待,失败降级主库
public Order queryOrder(Long userId, String orderNo) {
if (stickyManager.inMasterWindow(userId)) {
DynamicDataSource.routeMaster(); // 窗口内直接走主库
} else {
String gtid = stickyManager.getGtid(userId);
if (gtid != null) {
DynamicDataSource.routeSlave();
Integer ok = slaveJdbc.queryForObject(
"SELECT WAIT_FOR_EXECUTED_GTID_SET(?, 1)",
Integer.class, gtid);
if (ok == null || ok != 0) {
DynamicDataSource.routeMaster(); // 等待超时:降级主库
}
}
}
return orderMapper.selectByUserAndNo(userId, orderNo);
}使用 ShardingSphere-JDBC 时也可以直接用 Hint 强制主库:HintManager.getInstance().setMasterRouteOnly()。但 Hint 只在当前线程生效,跨线程池或 RPC 调用时必须手动透传。
4. 监控:不要只看 Seconds_Behind_Master
Seconds_Behind_Master 在 IO 线程中断时可能显示 NULL,大事务回放期间还会出现“延迟归零”的假象。更可靠的方式是对比主从 GTID 集合差值,或用 pt-heartbeat 心跳表测量真实延迟:
sql
-- 主库每秒更新一次心跳
CREATE TABLE `heartbeat` (
`id` int NOT NULL PRIMARY KEY,
`ts` datetime(3) NOT NULL,
KEY `idx_ts` (`ts`)
) ENGINE=InnoDB;
REPLACE INTO heartbeat(id,ts) VALUES (1,NOW(3));
-- 从库计算真实延迟(秒),持续大于阈值即告警并摘除该从库
SELECT TIMESTAMPDIFF(MICROSECOND, ts, NOW(3))/1000000 AS lag_seconds
FROM heartbeat WHERE id=1;避坑总结:主从延迟治理的 5 个实战注意事项
1. 会话粘性标记跨线程、跨服务丢失。 现象:下单接口标记了主库,订单详情查询经过异步线程池或另一个微服务,仍然读到从库旧数据。修正:不要只依赖 ThreadLocal,应把“用户写时间戳 + GTID”写入 Redis(TTL 3~10 秒),所有读入口统一拦截判断;WebFlux 响应式场景用 Reactor Context 透传。
2. 半同步复制等待点选错,延迟窗口依旧存在。 现象:明明开了半同步,支付后仍偶发读不到数据。排查发现 rpl_semi_sync_master_wait_point 为 AFTER_COMMIT:主库先提交再等从库 ACK,等待窗口内主库新数据对其他会话已可见,路由到从库却没有。修正:改为 AFTER_SYNC(无损半同步),主库收到 ACK 后才提交,保证从库数据先于主库可见;同时设置 rpl_semi_sync_master_timeout,避免从库故障时主库写请求被整体拖垮。
3. GTID 等待不设超时,连接池被打满。 现象:从库回放一个百万行 UPDATE 大事务,业务查询全部卡在 WAIT_FOR_EXECUTED_GTID_SET,连接池耗尽。修正:等待必须传入 1 秒级超时并限制等待并发,超时立即降级主库;根本上要拆分大事务、避开业务高峰执行 DDL,并监控从库元数据锁等待——MDL 锁会让 SQL 线程完全停摆。
4. 事务内 SELECT 被路由到从库。 现象:同一个事务方法里先 INSERT 后 SELECT,自研路由把第二条语句发到了从库,既读不到刚写入的数据,也破坏了事务一致性。修正:事务一旦开启(连接处于非 autocommit 状态),整条事务内的 SQL 必须固定在同一个主库连接上;ShardingSphere 默认遵循该规则,自研中间件务必在路由层显式判断。
5. 只监控 Seconds_Behind_Master 导致延迟漏报。 现象:监控曲线显示延迟为 0,用户却持续投诉数据陈旧。原因是该指标取自 SQL 线程正在执行事件的原始时间戳,IO 线程断连或大事务未提交时完全失真。修正:以 GTID Executed_Set 差值加 heartbeat 心跳延迟为准,同时监控从库慢查询、复制错误数和回放线程状态,延迟超阈值自动从负载均衡中摘除。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。