跳到主内容

从 3s 到 0:读写分离主从延迟全链路规避实战 ​

问题背景:支付成功后订单详情提示“订单不存在” ​

读写分离主从架构链路客户端支付后查订单应用集群回调写 / 详情读读写分离路由ShardingSphereProxySQL / 自研主库 Master支付回调更新订单从库 Replica订单详情查询写请求读请求binlog 复制存在延迟事故现象:主库事务已提交,从库尚未回放;详情页读到旧状态,甚至提示“订单不存在”

大促凌晨,告警群连续收到同一类客诉:用户在 App 上完成支付,收银台跳回订单详情,页面却提示“订单不存在”,手动刷新一次又恢复正常。排查链路后很快定位:支付回调在主库把 t_order.status 从 0 更新为 1,而订单详情查询被读写分离组件路由到了从库,从库尚未回放完这笔事务,于是读到了旧状态,甚至订单记录本身都还不存在。

典型一主多从架构下,请求链路是:客户端 → 应用集群 → 读写分离路由(ShardingSphere/ProxySQL/自研路由)→ 主库写、从库读。主库 dump 线程把 binlog 推送给从库 IO 线程写入 relay log,SQL 线程再按顺序回放。每多一个环节就多一段延迟:网络往返、relay log 落盘、SQL 线程回放速度。遇到大事务、从库索引缺失导致慢查询、行锁或元数据锁等待、从库负载过高,延迟会从毫秒级膨胀到秒级。

这就是经典的“读己之写”(Read-Your-Writes)一致性问题:同一用户写入后立刻读取,必须看到自己刚提交的结果。单纯依赖异步复制无法保证,必须在链路各层组合规避。

方案对比:五种主从延迟治理策略选型 ​

五种主从延迟治理策略选型围绕“读己之写”,按一致性、侵入性与成本组合使用强制主库关键读直接走主会话粘性写后短时路由主库GTID 等待确认事务已回放半同步复制至少一个从库确认缓存兜底写后先读缓存选型原则支付、订单确认等强一致路径:强制主库或 GTID 等待;普通用户写后短期读取:会话粘性。基础设施层用半同步降低窗口,热点与空窗查询用缓存兜底;不要只依赖异步复制保证读己之写。

方案一致性保证业务侵入主要成本适用场景
关键读强制走主库强低主库读压力增大支付、余额等核心链路
写后会话粘性(时间窗走主库)强(窗口内)中状态跨服务传递下单后跳转、修改资料
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 心跳延迟为准,同时监控从库慢查询、复制错误数和回放线程状态,延迟超阈值自动从负载均衡中摘除。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。