【Mysql】「主从延迟 + 连接池雪崩」的真实事故复盘

结合之前配置的 master / slave / report 三数据源 + HikariCP + @DS 的技术栈,还原事故的根因、传导链、止损过程、长期治理方案完整。

📌 事故原型:某电商订单服务,主从延迟触发从库读不到刚写入的数据 → 业务层重试 → 从库压力激增 → 主库也被拖垮 → HikariCP 连接池在 15 秒内雪崩


一、事故背景

项目 内容
架构 Spring Boot + dynamic-datasource + HikariCP + MyBatis-Plus
数据源 master(写)/ slave(读)/ report(报表)
业务 订单创建后,立即查订单详情返回前端
配置 HikariCP maximum-pool-size: 50connection-timeout: 5s
故障时长 约 48 分钟
影响 下单、支付、退款全部受阻,约 2300 笔订单失败

二、故障时间线(15 秒雪崩)

🔴 T+0s:主库一笔大事务锁表

运维执行了一条离线脚本:

sql 复制代码
UPDATE t_order SET status = 4 
WHERE create_time BETWEEN '2026-01-01' AND '2026-03-31' 
  AND status IN (0,1,2,3);
-- 影响 280 万行,执行 30 分钟未提交 

行锁升级,主库 Threads_running 从 20 飙到 500(打满 max_connections) 。

🔴 T+3s:主从延迟开始累积

从库的 relay log 里卡着这个 30 分钟的大事务,Seconds_Behind_Master 从 0 秒涨到 8 秒以上

🔴 T+5s:业务代码触发"读己之写"

java 复制代码
@DS("master")
@Transactional(rollbackFor = Exception.class)
public Long createOrder(Order order) {
    orderMapper.insert(order);              // 写入主库 ✅
    Long id = order.getId();
    
    // 写入后立即查询订单详情返回前端
    Order detail = getOrderDetail(id);       // ❌ 实际走 slave
    return detail.getId();
}

@DS("slave")
public Order getOrderDetail(Long id) {
    return orderMapper.selectById(id);       // 从库延迟 8s,查不到!返回 null
}

从库延迟 8 秒,刚写入主库的订单在从库查不到 → 返回 null → 业务抛出 OrderNotFoundException

🔴 T+8s:重试风暴引爆连接池

前端 SDK 内置 3 次重试机制 ,每一个真实请求在服务端放大为 3 个请求。每个请求都会:

  1. 尝试从 HikariCP(slave) 获取连接
  2. 从库本身延迟 8s+,连接被长时间占用
  3. 5 秒后 connection-timeout 触发,但连接仍被 DB 侧占用直到 SQL 执行完

雪崩核心:slave 的 HikariCP 50 个连接瞬间被占满 → 新请求阻塞 → Tomcat 线程池也被拖满 → 连带 master 连接池也被迫等待(因为事务开启要先拿 master 连接)。不是 master 连接池"不够用",而是 Tomcat 的工作线程被 slave 卡住后,根本没有机会再去拿 master 的连接。

🔴 T+15s:全链路雪崩

复制代码
从库延迟 8s
   ↓
slave HikariCP 连接耗尽(50/50)
   ↓
@DS("slave") 查询全部超时
   ↓
业务抛异常 → 前端 3 次重试 → QPS 放大 3 倍
   ↓
master HikariCP 也被拖垮(事务要开连接)
   ↓
订单服务完全不可用 

三、根因分析

🎯 直接根因

主从延迟 + 读写分离架构下的"读己之写"问题 ------ 写入主库后立即从从库读,从库数据尚未同步。

🎯 放大根因(这才是雪崩的真正推手)

层级 问题
业务层 没有"写后强制读主"的策略,盲目走从库
重试层 客户端 3 次重试无退避,QPS 放大 3 倍
连接池层 maximum-pool-size: 50 过大,连接被慢查询长期占用无法快速失败
熔断层 没有 Sentinel/Resilience4j 熔断,从库故障直接传导到主库
隔离层 报表查询和业务查询共用同一个应用实例,资源争抢

🎯 配置层根因

对照你之前的 HikariCP 配置,几个关键参数在这场事故中是"火上浇油":

yaml 复制代码
# ❌ 事故配置
slave:
  hikari:
    maximum-pool-size: 60        # 太大!60 个连接全被慢查询占满
    connection-timeout: 2000     # 2 秒就超时,但连接仍被 DB 占用
    minimum-idle: 20             # 即使空闲也维持 20 个连接,加剧资源消耗
    # 缺:leak-detection-threshold
    # 缺:max-lifetime < MySQL wait_timeout 的保护

四、应急响应时间线

复制代码
21:05  升级 P0,确认订单服务不可用
21:08  定位根因:Grafana 上 seconds_behind_master = 8s 
21:10  网关层熔断:关闭非核心功能(推荐、积分),只保留下单主链路 
21:12  读写分离强制回退:Nacos 动态配置,所有读流量切到 master 
21:15  Redis 缓存预热:redis-cli --pipe 批量导入库存快照 
21:18  服务逐步恢复,P99 从 12s 降到 2s
21:25  从库追平,恢复读写分离

关键决策 :21:12 的"读写强制回退主库"看似矛盾(主库已经压力大),但实际上切断了"主从不一致 → 业务异常 → 重试放大"的恶性循环,比继续等待从库追数据更有效 。


五、长期治理方案

✅ 1. 业务层:解决"读己之写"

java 复制代码
// 方案 A:写入后强制读主
@DS("master")
@Transactional(rollbackFor = Exception.class)
public Order createOrderWithDetail(Order order) {
    orderMapper.insert(order);
    return orderMapper.selectById(order.getId());  // 强制走 master
}

// 方案 B:通过 context 传递"本次会话读主"标记
public class DataSourceContext {
    private static final ThreadLocal<Boolean> FORCE_MASTER = new ThreadLocal<>();
    
    public static void forceMaster() { FORCE_MASTER.set(true); }
    public static boolean isForceMaster() { return Boolean.TRUE.equals(FORCE_MASTER.get()); }
    public static void clear() { FORCE_MASTER.remove(); }
}

// 自定义 DS 路由逻辑
@Component
public class CustomDSProcessor implements DsProcessor {
    @Override
    public boolean matches(String key) {
        if (DataSourceContext.isForceMaster()) return true;  // 强制走主
        return false;
    }
}

✅ 2. 熔断层:在 dynamic-datasource 外套装熔断器

java 复制代码
@DS("slave")
@SentinelResource(
    value = "order-read-slave",
    fallback = "readFromMasterFallback"  // 从库故障时降级到主库
)
public Order getOrderById(Long id) {
    return orderMapper.selectById(id);
}

public Order readFromMasterFallback(Long id) {
    // 降级:强制读主库
    DataSourceContext.forceMaster();
    try {
        return orderMapper.selectById(id);
    } finally {
        DataSourceContext.clear();
    }
}

✅ 3. 连接池层:参数重新治理

参考文章 4 的"小池子"理念 ,对你之前的配置做如下调整:

yaml 复制代码
spring:
  datasource:
    dynamic:
      datasource:
        slave:
          hikari:
            # ✅ 核心调整
            maximum-pool-size: 20          # 从 60 降到 20,快速失败优于排队
            minimum-idle: 5                # 从 20 降到 5
            connection-timeout: 3000       # 3 秒拿不到连接立即失败
            idle-timeout: 60000            # 1 分钟回收空闲连接(原 10 分钟太快)
            max-lifetime: 540000           # 9 分钟,必须小于 MySQL wait_timeout
            leak-detection-threshold: 10000 # 10 秒未归还报警
            validation-timeout: 3000       # 连接校验超时
            
        master:
          hikari:
            maximum-pool-size: 30          # 从 40 降到 30
            minimum-idle: 10
            connection-timeout: 3000
            max-lifetime: 540000           # 9 分钟 < wait_timeout(默认 8 小时)
            leak-detection-threshold: 10000

💡 "小池子"原理 :HikariCP 官方推荐 pool-size 宁小勿大。连接数少 → 慢查询占用连接后快速失败 → 触发熔断降级 → 保护数据库。大池子反而会让慢查询"撑"更久,最终雪崩 。

✅ 4. 监控层:关键指标告警

yaml 复制代码
# 暴露 HikariCP 指标给 Prometheus
management:
  metrics:
    export:
      redis: true
  endpoints:
    web:
      exposure:
        include: metrics,health,hikaricp

必须监控的 5 个黄金指标:

指标 阈值 动作
hikaricp.connections.pending > 0 持续 10s 触发熔断
hikaricp.connections.active / max > 80% 预警
hikaricp.connections.idle = 0 预警
mysql.seconds_behind_master > 3s 关闭缓存一致性校验
tomcat.threads.busy > 80% 预警

✅ 5. 架构层:连接池隔离

yaml 复制代码
spring:
  datasource:
    dynamic:
      datasource:
        report:
          # 报表查询独立池,避免慢查询阻塞核心交易
          hikari:
            maximum-pool-size: 10      # 严格限制,防止拖垮报表库
            connection-timeout: 5000
            leak-detection-threshold: 30000
            # 报表查询强制走独立连接池,主库/从库故障不影响报表

✅ 6. 数据库层:强制查询超时

java 复制代码
// MyBatis 拦截器:所有查询强制 3 秒超时
@Intercepts(@Signature(
    type = StatementHandler.class,
    method = "query",
    args = {Statement.class, ResultHandler.class}
))
public class QueryTimeoutInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Statement stmt = (Statement) invocation.getArgs()[0];
        stmt.setQueryTimeout(3);  // 3 秒强制超时
        return invocation.proceed();
    }
}

或在 JDBC URL 加:

复制代码
jdbc:mysql://...?socketTimeout=3000&queryTimeout=3000

六、事故复盘核心教训

⚠️ 微服务架构中,任何一层看似合理的设计,都可能成为雪崩效应的放大器。

  1. 读写分离不是银弹:必须配合"写后读主"策略,否则主从延迟就是定时炸弹
  2. 连接池不是越大越好:小池子 + 快速失败 + 熔断降级 > 大池子硬扛
  3. 重试必须有退避:客户端 3 次无退避重试 = QPS 放大 3 倍
  4. 熔断要在数据源切换外层:dynamic-datasource 本身不带熔断,必须自己套 Sentinel
  5. HikariCP max-lifetime 必须小于 MySQL wait_timeout:否则会出现"幽灵连接"
  6. 异步场景 @DS 上下文会丢CompletableFuture、线程池切换时 ThreadLocal 不传递,必须手动清理或改造

七、对照你的原始配置,最终推荐的生产级配置

yaml 复制代码
spring:
  datasource:
    dynamic:
      primary: master
      strict: true
      # ✅ 新增:默认数据源也走熔断
      datasource:
        master:
          url: jdbc:mysql://127.0.0.1:3306/order_db?useSSL=false&serverTimezone=UTC&rewriteBatchedStatements=true
          username: root
          password: pwd
          hikari:
            pool-name: MasterHikariCP
            minimum-idle: 10
            maximum-pool-size: 30          # 从 40 降下来
            connection-timeout: 3000       # 3 秒快速失败
            idle-timeout: 600000
            max-lifetime: 540000           # 9 分钟 < wait_timeout
            connection-test-query: SELECT 1
            leak-detection-threshold: 10000 # 10 秒泄漏检测
            auto-commit: false
        slave:
          url: jdbc:mysql://127.0.0.2:3306/order_db?useSSL=false&serverTimezone=UTC
          username: read_user
          password: pwd
          hikari:
            pool-name: SlaveHikariCP
            minimum-idle: 5                # 从 20 降下来
            maximum-pool-size: 20          # 从 60 大幅降低
            connection-timeout: 2000
            idle-timeout: 600000
            max-lifetime: 540000
            connection-test-query: SELECT 1
            leak-detection-threshold: 10000
            auto-commit: false
        report:
          url: jdbc:mysql://127.0.0.3:3306/report_db?useSSL=false
          username: report_user
          password: pwd
          hikari:
            pool-name: ReportHikariCP
            minimum-idle: 3
            maximum-pool-size: 10          # 严格限制
            connection-timeout: 5000
            idle-timeout: 300000
            max-lifetime: 540000
            leak-detection-threshold: 30000
            auto-commit: false

八、给团队的 6 条军规

  1. 写后读必须走主库 ------ 用 DataSourceContext.forceMaster() 或自定义 @DSMaster 注解
  2. HikariCP maximum-pool-size 宁小勿大 ------ 快速失败胜过排队等死
  3. 所有查询强制 3 秒超时 ------ 用 MyBatis 拦截器或 JDBC URL 参数
  4. @DS 外层必套 Sentinel 熔断 ------ 从库故障自动降级主库
  5. 客户端重试必须带退避 ------ 指数退避 + 最大重试次数限制
  6. 监控 seconds_behind_master ------ 延迟 > 3 秒立即告警并触发降级

💡 这场事故最深刻的一课:主从延迟本身不可怕,可怕的是我们没有为"延迟"设计降级策略 。当 seconds_behind_master > 3s 时,系统应该自动切换到"读主库"模式,而不是死等从库追上来。

相关推荐
呆呆小孩8 小时前
零基础学 MySQL(上):从建库建表到增删改查,一篇搭好 SQL 基础
android·sql·mysql
我不会插花弄玉10 小时前
2.库的操作【由浅入深-MySQL】
数据库·mysql
King of fraud12 小时前
Linux 下 MySQL 基础操作:创建用户、数据库与权限管理实战
linux·数据库·mysql
这个DBA有点耶12 小时前
Redis大key“根治”指南:拆分策略与数据结构选型
数据库·mysql·架构
__zRainy__15 小时前
Node系列 · ORM:mysql 驱动程序
数据库·后端·mysql·node.js·orm
痕迹运维15 小时前
MySQL8 Docker容器化部署之C86架构(海光CPU)
mysql
程序员夏洛15 小时前
MySQL 中如果发生死锁应该如何解决?
数据库·mysql
破土士V16 小时前
【MySQL基础知识】数据库约束&联合查询&索引
数据库·mysql·索引·联合查询·数据库约束
像风一样自由202016 小时前
11.PostgreSQ、-MySQL与MongoDB-AI应用如何选择数据库
数据库·人工智能·mysql·mongodb·大模型·rag·智能体
夏之小星星17 小时前
【找回mysql被删除数据】
数据库·sql·mysql