第一章:Go读写分离架构的演进与本质困境
Go语言凭借其轻量协程、高效并发模型和原生网络能力,成为构建高吞吐数据库中间件与数据访问层的首选。早期实践中,开发者常通过简单sync.Pool缓存连接、手动维护主从连接池(如*sql.DB双实例)实现读写分离——写操作路由至主库,读操作轮询分发至从库。这种“硬编码路由”虽成本低,却暴露了不可忽视的本质困境:一致性与可用性的根本张力。
事务边界与强一致性缺失
当业务逻辑跨多个读操作(如先查用户余额,再查交易流水)时,若两次查询被调度到不同从库,可能因主从复制延迟导致数据视图不一致。Go标准库database/sql本身不提供跨连接事务上下文透传机制,无法像分布式事务框架那样自动绑定读操作到同一复制组。
连接生命周期与故障传播
典型代码如下:
// 主库连接(写)
masterDB, _ := sql.Open("mysql", "user:pass@tcp(10.0.1.10:3306)/db")
// 从库连接池(读)
slaveDB, _ := sql.Open("mysql", "user:pass@tcp(10.0.2.20:3306)/db")
slaveDB.SetMaxOpenConns(50)
// 路由伪逻辑(无健康检查)
func execRead(query string) (*sql.Rows, error) {
return slaveDB.Query(query) // 若从库宕机,此处panic且无降级
}
该模式缺乏连接健康探测、自动剔除失效节点及读库权重动态调整能力。
架构演进中的关键断点
| 阶段 | 核心手段 | 暴露问题 |
|---|---|---|
| 手动路由 | if/else 判断读写类型 | 业务侵入性强,难以统一治理 |
| 中间件代理 | 使用ShardingSphere-Proxy等 | 增加网络跳数,Go生态适配不足 |
| SDK嵌入式路由 | 基于context.Value传递路由策略 | 上下文污染,测试隔离困难 |
真正可持续的解法,不在于更复杂的路由算法,而在于将复制延迟感知、会话一致性保障与熔断降级策略深度融入数据访问层的语义契约中——这要求Go生态亟需标准化的ReplicaReadOption接口与可插拔的延迟观测器。
第二章:连接泄漏——被忽视的资源黑洞
2.1 连接池生命周期管理原理与go-sql-driver/mysql实现剖析
database/sql 包中的连接池并非独立实现,而是由驱动(如 go-sql-driver/mysql)配合 sql.DB 协同完成状态管理。
连接获取与复用机制
当调用 db.Query() 时,sql.DB 从空闲连接队列中尝试获取可用连接;若无空闲连接且未达 MaxOpenConns 上限,则新建连接;否则阻塞或超时返回错误。
db, _ := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/test")
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(60 * time.Second) // 连接最大存活时间
SetMaxOpenConns: 控制池中最大并发连接数(含忙/闲),为 0 表示无限制;SetMaxIdleConns: 控制最大空闲连接数,超出部分在归还时被立即关闭;SetConnMaxLifetime: 强制连接在创建后60s内被回收,避免长连接 stale(如因网络中断或服务端 kill)。
状态迁移关键节点
graph TD
A[NewConn] -->|成功认证| B[Idle]
B -->|acquire| C[Busy]
C -->|release| B
C -->|error/close| D[Closed]
B -->|idleTimeout| D
A -->|auth fail| D
连接清理策略对比
| 触发条件 | 是否同步阻塞 | 是否触发底层 Close() |
|---|---|---|
ConnMaxLifetime 到期 |
否 | 是 |
MaxIdleConns 超限 |
否 | 是 |
db.Close() 调用 |
是(等待所有 Busy 归还) | 是 |
2.2 读写分离场景下Context超时未传递导致连接永久占用的复现与验证
复现场景构造
在基于 sql.DB + context.WithTimeout 的读写分离中间件中,若主库写操作携带 ctx,但从库读请求未透传该 ctx,将导致连接池中连接无法及时释放。
关键代码缺陷
func queryFromSlave(ctx context.Context, sql string) (*sql.Rows, error) {
// ❌ 错误:使用 background context,忽略入参 ctx
return slaveDB.QueryContext(context.Background(), sql) // 应为 ctx
}
context.Background() 切断了上游超时链路;slaveDB.QueryContext 因无超时控制,在网络抖动时持续阻塞,连接被长期持有。
影响对比
| 场景 | 连接释放时机 | 是否触发连接泄漏 |
|---|---|---|
正确透传 ctx |
超时后立即归还连接 | 否 |
使用 Background() |
直至连接空闲超时(默认30m) | 是 |
根本原因流程
graph TD
A[HTTP Handler with 5s timeout] --> B[Write to Master: ctx passed]
A --> C[Read from Slave: ctx dropped]
C --> D[QueryContext blocks indefinitely]
D --> E[Connection stuck in pool]
2.3 基于pprof+net/http/pprof定位泄漏连接的真实案例(含goroutine dump分析)
故障现象
线上服务内存持续增长,/debug/pprof/goroutine?debug=2 显示数千个 net/http.(*conn).serve goroutine 处于 select 状态,且 State: "idle" 比例异常偏高。
关键诊断命令
# 抓取阻塞型 goroutine 快照(含调用栈)
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines.txt
# 过滤疑似泄漏的 HTTP 连接
grep -A 5 "net/http\.\*conn\.serve" goroutines.txt | grep -E "(select|idle|read)"
该命令捕获全量 goroutine 栈,
debug=2输出含源码行号;select状态结合net.Conn.Read调用链,指向未关闭的长连接或客户端未发送 FIN。
泄漏根因
服务启用了 Keep-Alive,但某第三方 SDK 在 HTTP client 中未设置 Timeout 且复用 http.Transport 时漏调 CloseIdleConnections()。
| 配置项 | 当前值 | 安全建议 |
|---|---|---|
IdleConnTimeout |
0(无限) | ≥90s |
MaxIdleConnsPerHost |
100 | ≤50(防连接堆积) |
// 修复示例:显式管控空闲连接
tr := &http.Transport{
IdleConnTimeout: 90 * time.Second,
MaxIdleConnsPerHost: 50,
ForceAttemptHTTP2: true,
}
client := &http.Client{Transport: tr}
// ……使用后需定期调用:
defer client.Transport.(*http.Transport).CloseIdleConnections()
CloseIdleConnections()主动关闭所有空闲连接,避免net/http内部连接池在 GC 前长期驻留;IdleConnTimeout则由 runtime 自动驱逐超时空闲连接。
2.4 使用sqlmock构建可断言的单元测试捕获隐式连接泄露
隐式连接泄露常源于 defer db.Close() 缺失、rows.Close() 忘记调用,或事务未显式提交/回滚。sqlmock 可精准验证连接生命周期。
模拟未关闭的 Rows 泄露
func TestQueryLeakDetection(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("SELECT").WillReturnRows(
sqlmock.NewRows([]string{"id"}).AddRow(1),
)
_, _ = db.Query("SELECT id FROM users")
// ❌ 忘记 rows.Close() → sqlmock 会在 AssertExpectations 时失败
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("leak detected: %v", err) // 断言失败,暴露泄露
}
}
逻辑分析:sqlmock.NewRows() 构造结果集;ExpectQuery() 声明期望语句;AssertExpectations(隐含在 mock.ExpectationsWereMet() 中)检查所有 Rows 是否被 Close(),否则报错。
连接泄露检测能力对比
| 场景 | sqlmock 是否捕获 | 原生 database/sql |
|---|---|---|
rows.Close() 遗漏 |
✅ | ❌ |
tx.Commit() 缺失 |
✅ | ❌ |
多次 db.Query 未 Close |
✅ | ❌ |
核心机制示意
graph TD
A[执行 Query] --> B{Rows 被 Close 吗?}
B -->|是| C[Expectation 满足]
B -->|否| D[ExpectationsWereMet 返回 error]
2.5 连接泄漏防御模式:defer sql.Close()的局限性与Context-aware连接封装实践
defer db.Close() 的陷阱
defer 仅保证函数退出时执行,但若连接在 long-running goroutine 中被复用(如 HTTP handler 持有 *sql.DB),Close() 实际调用时机不可控,导致连接池耗尽。
Context-aware 封装核心思想
将连接生命周期与请求上下文绑定,避免“连接逃逸”到 goroutine 外部:
func withDB(ctx context.Context, db *sql.DB, fn func(*sql.Conn) error) error {
conn, err := db.Conn(ctx) // 阻塞直到获取连接或 ctx 超时
if err != nil {
return err
}
defer conn.Close() // 确保本次请求结束即释放
return fn(conn)
}
逻辑分析:
db.Conn(ctx)基于context.Context获取底层连接,超时/取消时自动中止等待;conn.Close()仅归还连接至池,不关闭底层资源(*sql.DB生命周期独立)。
对比维度
| 维度 | defer db.Close() |
db.Conn(ctx) 封装 |
|---|---|---|
| 作用对象 | 整个数据库实例 | 单次连接会话 |
| 超时控制 | ❌ 无 | ✅ 原生支持 |
| 连接复用安全性 | ❌ 易泄漏 | ✅ 上下文隔离 |
graph TD
A[HTTP Request] --> B{withDB(ctx, db, ...)}
B --> C[db.Conn(ctx) - 阻塞/超时]
C --> D[执行业务SQL]
D --> E[conn.Close()]
E --> F[连接归还池]
第三章:事务穿透——读库误入事务上下文的静默灾难
3.1 Go标准库sql.Tx与driver.Conn的事务传播机制深度解析
Go 的 sql.Tx 并非直接持有底层连接,而是通过 driver.Conn 的 Begin() 获取事务上下文,并在 Commit()/Rollback() 时复用同一 driver.Conn 实例。
事务生命周期绑定关系
sql.Tx持有*driverConn(未导出)和driver.Txdriver.Tx由driver.Conn.Begin()返回,不继承原连接的活跃状态- 同一
*sql.DB的并发Tx可能复用不同物理连接
关键传播约束
// sql/sql.go 中 Tx.QueryContext 的简化逻辑
func (tx *Tx) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) {
// tx.dc 是 *driverConn,已锁定且不可被其他 goroutine 复用
return tx.dc.query(ctx, query, args, false) // false 表示非原始连接查询
}
此处
tx.dc.query()直接复用事务专属的driver.Conn,避免跨事务连接污染;false参数禁用连接池重入,强制使用当前事务绑定连接。
| 阶段 | 涉及接口 | 是否可跨 goroutine |
|---|---|---|
| Begin() | driver.Conn.Begin() | 否(连接被 tx.dc 锁定) |
| Exec/Query | driver.Tx.Stmt() | 是(但 Stmt 绑定到 driver.Tx) |
| Commit() | driver.Tx.Commit() | 否(需原 driver.Conn 存活) |
graph TD
A[sql.Tx.Begin] --> B[driver.Conn.Begin]
B --> C[返回 driver.Tx + 保留 driver.Conn 引用]
C --> D[所有 Tx 方法调用均路由至该 driver.Conn]
D --> E[Commit/Rollback 触发 driver.Tx 接口]
3.2 读写分离中间件中TransactionIsolationLevel误判导致主库查询被路由至从库的调试实录
问题现象
某次压测中,SELECT FOR UPDATE 语句偶发返回过期数据,日志显示其被路由至从库——而该语句本应强走主库。
根本原因
中间件依据 Connection.getTransactionIsolation() 判断事务隔离级别,但 Spring 的 DataSourceTransactionManager 在只读事务中提前调用 setReadOnly(true) 并重置隔离级别为 TRANSACTION_READ_COMMITTED,导致中间件误判为“非可重复读以上级别”,放行读请求至从库。
// Spring TransactionSynchronizationUtils 中的典型行为
if (definition.isReadOnly()) {
con.setReadOnly(true); // ⚠️ 此操作隐式覆盖原有 isolation level
con.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
}
逻辑分析:
setReadOnly(true)触发 JDBC 驱动内部隔离级别重置;中间件仅检查当前getTransactionIsolation()返回值,未追溯isReadOnly()状态,从而丧失对“强一致性读”意图的识别能力。
验证路径
- ✅ 开启
@Transactional(isolation = REPEATABLE_READ, readOnly = false)→ 正确路由至主库 - ❌
@Transactional(isolation = REPEATABLE_READ, readOnly = true)→ 路由至从库(误判)
| 场景 | isReadOnly() | getTransactionIsolation() | 实际路由 |
|---|---|---|---|
| RR + readOnly=false | false | REPEATABLE_READ | 主库 ✅ |
| RR + readOnly=true | true | READ_COMMITTED | 从库 ❌ |
修复策略
graph TD
A[拦截 ConnectionWrapper] --> B{isReadOnly() == true?}
B -->|Yes| C[检查原始隔离级别缓存]
B -->|No| D[按原逻辑判断]
C --> E[若原始为 REPEATABLE_READ/ SERIALIZABLE → 强制主库]
3.3 基于opentelemetry-sql注入Span标签识别事务穿透路径的可观测方案
传统SQL埋点难以关联跨服务的事务上下文。OpenTelemetry通过sql.query, db.statement, db.operation等标准语义约定,结合自定义Span标签实现事务路径显式标注。
标签注入策略
- 在DAO层拦截SQL执行前,向当前Span注入:
transaction.id: 全局事务唯一标识(如Seata XID)span.kind: 显式标记为"database"或"transaction_boundary"db.statement.hash: SQL指纹哈希,用于归类慢查询
示例:MyBatis插件注入逻辑
// OpenTelemetryMyBatisPlugin.java
public Object intercept(Invocation invocation) throws Throwable {
Span current = Span.current();
if (current != Span.getInvalid()) {
current.setAttribute("transaction.id", getCurrentXid()); // 从ThreadLocal获取分布式事务ID
current.setAttribute("db.statement.hash",
DigestUtils.md5Hex(sql.replaceAll("\\s+", " ").trim())); // 标准化后哈希
}
return invocation.proceed();
}
该插件在SQL执行前将事务上下文写入Span,使Jaeger/Tempo可沿trace_id + transaction.id双维度下钻追踪。
关键标签映射表
| 标签名 | 类型 | 说明 |
|---|---|---|
transaction.id |
string | 分布式事务全局ID(如192.168.1.10:8080:123456789) |
db.statement.hash |
string | 规范化SQL的MD5,消除参数差异干扰 |
db.system |
string | 数据库类型(mysql, postgresql) |
graph TD
A[应用入口] --> B[生成TraceID & XID]
B --> C[DAO执行SQL]
C --> D[OTel插件注入transaction.id等标签]
D --> E[Export至Collector]
E --> F[Tempo按transaction.id聚合路径]
第四章:预处理语句缓存——跨库复用引发的协议级冲突
4.1 MySQL PREPARE/EXECUTE协议在主从库间不兼容的本质原因(SQL_MODE、字符集、时区差异)
数据同步机制
MySQL 主从复制基于 binlog 事件重放,但 PREPARE/EXECUTE 协议本身不记录参数绑定上下文,仅记录语句模板与执行时的原始 SQL 文本(如 EXECUTE stmt USING @a, @b),而实际值解析依赖执行时刻的会话环境。
关键差异维度
- SQL_MODE:主库启用
STRICT_TRANS_TABLES时EXECUTE报错,从库若为宽松模式则静默截断 - 字符集:
SET NAMES utf8mb4后PREPARE的字符串参数按连接字符集解码,主从character_set_client不一致 → 二进制内容误判 - 时区:
NOW()绑定为参数时,主库time_zone='+08:00'生成2024-05-01 12:00:00,从库time_zone='+00:00'解析为04:00:00
协议缺陷示例
-- 主库执行(time_zone='+08:00', sql_mode='STRICT_TRANS_TABLES')
SET @t = '2024-05-01 12:00:00';
PREPARE stmt FROM 'INSERT INTO events(ts) VALUES (?)';
EXECUTE stmt USING @t;
⚠️ 分析:
@t是字符串,EXECUTE时按当前time_zone转换为DATETIME。从库若time_zone='+00:00',该字符串被解释为 UTC 时间,导致写入值偏移 8 小时;且STRICT模式缺失时,非法日期可能被静默修正。
兼容性验证表
| 环境变量 | 主库值 | 从库值 | 后果 |
|---|---|---|---|
sql_mode |
STRICT_TRANS_TABLES |
'' |
主库报错,从库插入成功 |
character_set_client |
utf8mb4 |
latin1 |
中文参数乱码或截断 |
time_zone |
'+08:00' |
'+00:00' |
时间字段全局偏移 |
graph TD
A[客户端发送 PREPARE] --> B[主库解析模板+绑定参数]
B --> C[binlog 记录 EXECUTE 事件]
C --> D[从库重放:用自身会话变量解析参数]
D --> E[结果不一致:SQL_MODE/charset/time_zone 差异放大]
4.2 database/sql中Stmt对象生命周期与连接绑定关系的源码级追踪(src/database/sql/sql.go#Stmt)
*Stmt 并非直接持有底层连接,而是通过 stmtConn 间接复用连接池中的连接:
// src/database/sql/sql.go#Stmt
func (s *Stmt) Query(args ...interface{}) (*Rows, error) {
// 实际执行时才从池中获取连接(可能复用或新建)
dc, err := s.db.conn()
if err != nil {
return nil, err
}
return s.query(dc, args)
}
该调用不绑定固定连接,每次 Query/Exec 都触发独立的连接获取与释放流程。
生命周期关键节点
- 创建:
db.Prepare()→ 初始化Stmt结构体,不分配连接 - 执行:按需从
db.connPool获取driver.Conn - 关闭:
stmt.Close()仅标记为 closed,不归还连接(连接由Rows或Result自行释放)
连接绑定关系本质
| 状态 | 是否绑定连接 | 说明 |
|---|---|---|
| Stmt创建后 | ❌ | 仅保存 SQL 模板与驱动接口 |
| Query执行中 | ✅(临时) | 从池获取,执行完立即释放 |
| Stmt已关闭 | ❌ | 不再允许执行,但不影响池中连接 |
graph TD
A[Stmt.Prepare] --> B[Stmt结构体初始化]
B --> C{Query/Exec调用}
C --> D[connPool.Get()]
D --> E[driver.Stmt.Exec/Query]
E --> F[connPool.Put]
4.3 读写分离下Prepare语句缓存击穿导致从库报错“Unknown prepared statement handler”的复现与修复
复现场景还原
当应用使用连接池(如 HikariCP)并开启 cachePrepStmts=true,且读写分离路由将 PREPARE/EXECUTE 发往主库、后续 EXECUTE 却被路由至无该 stmt ID 的从库时,触发报错。
关键参数配置对比
| 参数 | 主库生效值 | 从库限制 |
|---|---|---|
max_prepared_stmt_count |
16382 | 同步延迟下 stmt 元数据未同步 |
prepared_stmt_cache_size |
256 | 从库不维护 prepare 缓存 |
-- 应用侧典型误用(未显式 DEALLOCATE)
PREPARE stmt1 FROM 'SELECT * FROM users WHERE id = ?';
EXECUTE stmt1 USING @id;
-- 此处连接可能被归还池中,下次从池获取的连接若路由到从库,执行 EXECUTE 将失败
逻辑分析:MySQL 的 Prepare handler ID 是会话级内存标识,不跨连接同步,更不复制到从库。从库无法解析主库生成的 stmt ID,故报
Unknown prepared statement handler。根本原因在于读写分离中间件(如 ShardingSphere、MyCat)未拦截并重写 Prepare 相关命令。
修复路径
- ✅ 应用层:禁用
cachePrepStmts或统一走主库执行所有 Prepare/Execute; - ✅ 中间件层:拦截
PREPARE/EXECUTE/DEALLOCATE并强制路由至主库; - ❌ 从库侧:不可通过调参解决(因 stmt handler 本质无持久化)。
4.4 面向读写分离的Stmt工厂模式:按目标库动态生成独立Stmt实例的工程实践
在读写分离架构中,Statement 实例需严格绑定至对应数据源(主库/从库),避免跨源复用导致路由错乱或事务污染。
核心设计原则
- 每个
DataSource独享专属StmtFactory实例 - 工厂根据执行意图(
READ_ONLY/WRITE)选择目标库并创建隔离Statement
动态生成流程
public Statement createStatement(ExecutionHint hint) throws SQLException {
DataSource ds = dataSourceRouter.route(hint); // 基于hint路由到主/从库
Connection conn = ds.getConnection(); // 获取专属连接
return conn.createStatement(); // 返回绑定该连接的Stmt
}
逻辑说明:
hint含isWriteOp()、consistencyLevel()等元信息;dataSourceRouter查表/注解/上下文确定物理库;conn.createStatement()确保 Stmt 生命周期与连接强绑定,杜绝跨库泄漏。
路由策略对照表
| Hint 类型 | 主库匹配条件 | 从库匹配条件 |
|---|---|---|
WRITE |
✅ 强制命中 | ❌ 排除 |
EVENTUAL_READ |
❌ 排除 | ✅ 健康且延迟 |
graph TD
A[createStatement hint] --> B{isWriteOp?}
B -->|Yes| C[route to Master DS]
B -->|No| D[route to Slave DS]
C & D --> E[getConnection]
E --> F[createStatement]
第五章:重构之道——面向稳定性的读写分离治理全景图
核心矛盾:高并发写入与强一致性查询的不可调和性
某电商大促系统在秒杀场景下出现数据库连接池耗尽、从库延迟飙升至12秒以上,订单状态页频繁显示“已支付但未发货”。根因并非硬件瓶颈,而是业务层将用户订单状态查询(SELECT status FROM orders WHERE user_id = ?)与库存扣减(UPDATE inventory SET stock = stock - 1 WHERE sku_id = ? AND stock > 0)混用同一主库连接,且未配置读写分离路由策略。重构后引入ShardingSphere-JDBC,通过hint强制路由+动态数据源切换,将92%的只读请求导向3个延时
治理边界:明确读写分离的四类不可逾越红线
| 场景类型 | 允许读从库 | 禁止读从库 | 技术保障机制 |
|---|---|---|---|
| 用户中心基础信息查询 | ✅ | — | 基于@ReadOnly注解自动路由 |
| 支付结果回调验证 | ❌ | ✅(必须主库) | Spring AOP拦截器校验事务传播级别 |
| 订单创建后立即查详情 | ❌ | ✅(必须主库) | TransactionSynchronization.afterCommit()触发缓存预热 |
| 实时风控规则匹配 | ✅ | — | Redis本地缓存+TTL=30s双保险 |
动态熔断:基于SLA的实时路由降级策略
当从库P99延迟>200ms且持续30秒,自动触发ReadWriteSplitter的FallbackToMaster模式,并向Prometheus推送read_split_fallback_total{reason="replica_latency"}指标。某次MySQL主从GTID复制中断事件中,该机制在47秒内完成全量读请求回切,避免了下游17个微服务的雪崩式超时。
// 自定义ShardingSphere读写分离负载均衡算法
public class StabilityAwareReplicaLoadBalancer implements ReplicaLoadBalanceAlgorithm {
@Override
public String getDataSource(String name, String writeDataSourceName, List<String> readDataSourceNames) {
return readDataSourceNames.stream()
.filter(ds -> metrics.getLatencyPercentile(ds, 95) < 150)
.findFirst()
.orElse(writeDataSourceName); // 降级至主库
}
}
数据血缘:构建跨组件的读写链路追踪图
使用OpenTelemetry注入read_from_replica与write_to_master语义标签,结合Jaeger生成端到端调用拓扑。发现商品详情页存在隐式写操作:GET /items/{id}接口内部调用了update_view_count(),导致本应走从库的请求被错误路由至主库。通过字节码增强(Byte Buddy)拦截ViewCountService.update()方法并打标,修复后主库QPS下降38%。
graph LR
A[API Gateway] -->|trace_id: abc123| B[Product Service]
B --> C{Read-Write Router}
C -->|isWrite=true| D[(MySQL Master)]
C -->|isWrite=false & latency<150ms| E[(MySQL Replica-1)]
C -->|isWrite=false & latency>=150ms| F[(MySQL Master)]
D --> G[Binlog Producer]
G --> H[Kafka Topic: mysql-binlog]
H --> I[ES Search Index]
监控基线:定义稳定性黄金指标组合
每分钟采集5项核心指标:replica_lag_seconds(Max)、read_from_replica_ratio(Avg)、master_write_qps(Sum)、read_timeout_count(Counter)、fallback_trigger_count(Counter)。当连续5个周期满足replica_lag_seconds > 300 && read_from_replica_ratio < 0.7时,自动触发告警并启动mysql-replica-health-check运维脚本。
