刚上读写分离,客服就收到投诉了
十一月初,我们把订单库做了读写分离:一主两从,写走主库,查走从库。用 ShardingSphere-JDBC 5.0.0-alpha(当时叫 Sharding-JDBC),配置很简单:
spring:
shardingsphere:
datasource:
names: master,slave0,slave1
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://10.20.1.10:3306/shop?useSSL=false
username: appuser
password: xxx
slave0:
jdbc-url: jdbc:mysql://10.20.1.11:3306/shop?useSSL=false
...
slave1:
jdbc-url: jdbc:mysql://10.20.1.12:3306/shop?useSSL=false
...
masterslave:
name: ms
master-data-source-name: master
slave-data-source-names: slave0,slave1
load-balance-algorithm-type: round_robin
上线当天下午,客服转过来一个工单:"用户说刚支付完,订单列表里显示的还是待付款,刷新好几次才变。"
这是读写分离最经典的坑——主从延迟导致的写后读不一致。
先量化:延迟到底有多大
用 SHOW SLAVE STATUS 看从库的复制状态:
mysql> SHOW SLAVE STATUS\G
*************************** 1. row ***************************
Slave_IO_State: Waiting for master to send event
Master_Host: 10.20.1.10
Master_User: repl
Master_Port: 3306
Connect_Retry: 60
Master_Log_File: mysql-bin.000842
Read_Master_Log_Pos: 882134221
Relay_Log_File: relay-bin.003412
Relay_Log_Pos: 882133918
Relay_Master_Log_File: mysql-bin.000842
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Seconds_Behind_Master: 0
Last_IO_Errno: 0
Last_IO_Error:
Seconds_Behind_Master: 0,看着很健康。但这个值不可靠——它是用 SQL 线程当前执行事件的时间戳减去从库系统时间算的,精度到秒,而且在从库空闲时会显示 0,即使还有未应用的 relay log。
更靠谱的做法是心跳表:在主库上定期更新一行时间戳,在从库上读它,算差值。
CREATE TABLE t_heartbeat (
id INT NOT NULL PRIMARY KEY,
ts TIMESTAMP(6) NOT NULL, /* 微秒精度 */
server_id INT NOT NULL
) ENGINE = InnoDB;
-- 主库上每秒跑一次(写个定时任务或者用 event)
REPLACE INTO t_heartbeat (id, ts, server_id) VALUES (1, NOW(6), @@server_id);
-- 从库上查询,算延迟
SELECT TIMESTAMPDIFF(MICROSECOND, ts, NOW(6)) / 1000 AS delay_ms FROM t_heartbeat WHERE id = 1;
我们把这个查询接到了监控里,采样间隔 5 秒。上线第一周的数据:
| 时段 | 平均延迟 | P99 延迟 | 最大延迟 |
|---|---|---|---|
| 凌晨(低峰) | 12ms | 48ms | 210ms |
| 白天(正常) | 180ms | 1200ms | 3400ms |
| 大促/批量任务 | 2.4s | 18s | 62s |
平均 180ms 看着还行,但 P99 到 1.2 秒,批量任务期间能到 62 秒。用户点完"支付"立刻跳到订单列表,这 180ms 就足够让他看到旧状态了。
为什么会有延迟
MySQL 8.0 默认的复制是异步的,流程是:主库事务提交 → 写 binlog → 立即返回客户端 → 从库的 IO 线程拉 binlog 写 relay log → SQL 线程(8.0 可以是多线程)回放 relay log。
关键点:主库不等从库。所以延迟是必然存在的,只是大小问题。延迟主要来自三处:
- 网络传输:binlog 从主库网卡到从库,跨机房的话这部分不小。
- 从库回放速度:这是大头。主库上并发执行的事务,在从库上要按顺序回放。MySQL 8.0 引入了基于
writeset的并行复制(slave_parallel_type = LOGICAL_CLOCK),能并行回放没有冲突的事务,但遇到大事务还是会卡。 - 大事务:主库上一个跑 60 秒的批量 UPDATE,从库回放也要 60 秒,这期间延迟单调递增。
我们那次 62 秒的延迟,就是运营在后台跑了一次全量改价,一个事务改了 180 万行。
解决方案一:写后读强制走主库
最直接的思路:用户刚写完数据,紧接着的读操作去主库。
ShardingSphere 的做法是用 Hint 强制路由:
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setMasterRouteOnly(); // 本次查询强制走主库
return orderMapper.selectByNo(orderNo);
}
Spring 的写法可以封装成注解,用 AOP 在方法前后加 Hint:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RouteToMaster {
}
@Aspect
@Component
public class MasterRouteAspect {
@Around("@annotation(RouteToMaster)")
public Object route(ProceedingJoinPoint pjp) throws Throwable {
try (HintManager hm = HintManager.getInstance()) {
hm.setMasterRouteOnly();
return pjp.proceed();
}
}
}
然后给那些"写完立刻读"的方法加上注解:
@Override
@RouteToMaster
public OrderVO getOrderAfterPay(String orderNo) {
return orderMapper.selectByNo(orderNo);
}
这个方案的问题在于要靠人肉识别哪些方法需要走主库,很容易漏。我们上线第一周加了 8 处注解,还是漏了 3 个(订单详情、订单列表、物流进度)。
解决方案二:按时间窗口自动判断(我们最终用的)
漏注解的本质是"人记不住"。改成自动的:记录最近一次写操作的时间,如果这个连接有过写操作且时间间隔在阈值内,后续读全部走主库。
实现方式是基于 ThreadLocal 的读写标记,配合 MyBatis 拦截器或者数据源路由。我们自己写了一个轻量版本:
public class WriteReadRouter {
private static final ThreadLocal<Long> LAST_WRITE = new ThreadLocal<Long>();
/** 写操作后 3 秒内的读都走主库,这个值要大于 P99 主从延迟 */
private static final long WINDOW_MS = 3000;
public static void markWrite() {
LAST_WRITE.set(System.currentTimeMillis());
}
public static boolean shouldReadMaster() {
Long last = LAST_WRITE.get();
return last != null && (System.currentTimeMillis() - last) < WINDOW_MS;
}
public static void clear() {
LAST_WRITE.remove();
}
}
写操作之后打标记。用 AOP 拦截所有写事务:
@Aspect
@Component
public class WriteMarkAspect {
@After("@annotation(org.springframework.transaction.annotation.Transactional)")
public void mark(JoinPoint jp) {
Transactional tx = ((MethodSignature) jp.getSignature())
.getMethod().getAnnotation(Transactional.class);
if (!tx.readOnly()) { // 只读事务不算写操作
WriteReadRouter.markWrite();
}
}
}
数据源路由用 Spring 的 AbstractRoutingDataSource:
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 有 Hint 强制走主库,或者刚写完,都走主库
if (WriteReadRouter.shouldReadMaster() || HintManager.isMasterRouteOnly()) {
return "master";
}
// 在事务中也走主库,避免同一个事务内读写不一致
if (TransactionSynchronizationManager.isActualTransactionActive()) {
return "master";
}
return "slave";
}
}
这个方案覆盖了 95% 的场景,剩下漏网的是"用户 A 写、用户 B 读"的跨用户场景——但这个业务上本来就允许短暂延迟。
ThreadLocal 必须在请求结束时清理,否则线程池复用会把标记带到下一个请求。加个 Filter 或者 Interceptor:
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
Object handler, Exception ex) {
WriteReadRouter.clear();
}
解决方案三:能不用读就不读(最省事)
这是我在改造过程中体会最深的一点:很多"写后读"的查询,其实根本不需要查。
比如支付成功后跳转订单详情,原来的流程是:
// 1. 支付回调,更新订单状态
orderMapper.updateStatus(orderNo, "PAID");
// 2. 返回给前端,前端拿着 orderNo 去查详情
// 3. 后端:SELECT * FROM t_order WHERE order_no = ?
改成更新时返回最新数据:
@Transactional
public OrderVO payCallback(String orderNo) {
// MySQL 8.0 支持 RETURNING 吗?不支持,那是 PostgreSQL。
// 但可以用 UPDATE + SELECT 在同一个事务里
orderMapper.updateStatus(orderNo, "PAID", LocalDateTime.now());
// 同一个事务内查,走主库,一定能读到自己刚写的
OrderPO po = orderMapper.selectByNo(orderNo);
return convert(po);
}
因为 determineCurrentLookupKey 里判断了 isActualTransactionActive(),同一个事务内的读自动走主库,一定一致。前端拿到返回的 VO 直接渲染,不需要再查一次。
另一个思路是用缓存挡住:写操作完成后同步更新 Redis,读走缓存。但这又引入了缓存一致性问题,我们的原则是——能用"返回数据"解决的,不加缓存。
解决方案四:半同步复制(评估后没用)
MySQL 有半同步复制(rpl_semi_sync_master):主库提交时至少等一个从库确认收到 binlog 才返回。
# 主库
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 = 1000; # 1ms,超时后降级为异步
# 从库
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;
STOP SLAVE IO_THREAD; START SLAVE IO_THREAD;
注意关键点:半同步只保证 binlog 到达从库的 relay log,不保证 SQL 线程已经回放完。所以延迟从"网络传输 + 回放"缩短到"只回放",大概能减少 30~50%,但不能消除。
而且它有明显的代价:主库写性能下降(我们压测 QPS 从 8200 降到 5400,降幅 34%);从库宕机或者网络抖动时,rpl_semi_sync_master_timeout 超时后会自动降级成异步,一致性保证又没了。
我们的判断:为了满足一个 180ms 的不一致窗口,付出 34% 的写性能,不划算。而且半同步在从库全挂的情况下会让主库写操作卡住(等待超时),这是更要命的可用性风险。所以没上。
延迟监控和告警
不管用哪种方案,延迟监控都是必须的。我们做了三层:
第一层:心跳表(前面说的),接 Prometheus + Grafana,5 秒采样。告警规则:
# 延迟超过 3 秒持续 1 分钟,P2 告警
mysql_slave_delay_ms{instance="slave0"} > 3000
# 延迟超过 10 秒持续 30 秒,P1 告警(电话)
mysql_slave_delay_ms{instance="slave0"} > 10000
第二层:从库只读必须设对。这是防人为写入从库导致数据错乱的最后一道保险:
# 从库 my.cnf
[mysqld]
read_only = ON
super_read_only = ON # 连 super 权限的用户也只读,MySQL 5.7.8+
read_only 对有 SUPER 权限的用户无效,所以要两个都开。我们有一次就是因为只开了 read_only,DBA 用 root 在从库上改了一行数据,导致主从数据不一致,复制报错中断,最后只能重建从库。
第三层:复制健康检查,定期检查 Slave_IO_Running 和 Slave_SQL_Running 是否都是 Yes,以及 Last_SQL_Error 是否为空。
SELECT
Slave_IO_Running,
Slave_SQL_Running,
Seconds_Behind_Master,
Last_IO_Error,
Last_SQL_Error
FROM performance_schema.replication_applier_status_by_worker;
还有个坑:从库的查询会拖慢复制
上线两周后发现,业务高峰时从库延迟会周期性涨到 8 秒。排查下来是慢查询占用了从库的资源——运营后台的统计报表直接查从库,一个查询跑 20 秒,期间的复制回放被拖慢。
解决办法:
- 把慢查询(统计、报表、导出)单独路由到一个"离线从库",不服务在线业务。
- 给从库的连接池设上限,避免慢查询占满连接导致复制线程拿不到资源。
- MySQL 8.0 可以调并行复制的 worker 数:
SET GLOBAL slave_parallel_workers = 8。
mysql> SHOW VARIABLES LIKE 'slave_parallel%';
+------------------------+---------------+
| Variable_name | Value |
+------------------------+---------------+
| slave_parallel_type | LOGICAL_CLOCK |
| slave_parallel_workers | 8 |
+------------------------+---------------+
改成 8 个 worker 之后,回放速度提升明显,批量任务时的最大延迟从 62 秒降到 14 秒。
改造后的效果
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 写后读不一致工单 | 17 单/周 | 0 |
| 主库 QPS | 8200(读写混合) | 3100(纯写) |
| 主库 CPU | 78% | 31% |
| 查询 P99 | 420ms | 95ms |
| 走主库的读占比 | 0% | 11% |
11% 的读走主库,这是"写后读窗口内 + 事务内 + 显式 Hint"加起来的比例,可以接受。如果超过 25% 就说明路由策略太激进了,读写分离的意义就没了。
留个问题
关于《读写分离下的数据一致性问题与解决》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。