Administrator
发布于 2020-11-10 / 1029 阅读
12

读写分离下的数据一致性问题与解决

刚上读写分离,客服就收到投诉了

十一月初,我们把订单库做了读写分离:一主两从,写走主库,查走从库。用 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 延迟最大延迟
凌晨(低峰)12ms48ms210ms
白天(正常)180ms1200ms3400ms
大促/批量任务2.4s18s62s

平均 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_RunningSlave_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
主库 QPS8200(读写混合)3100(纯写)
主库 CPU78%31%
查询 P99420ms95ms
走主库的读占比0%11%

11% 的读走主库,这是"写后读窗口内 + 事务内 + 显式 Hint"加起来的比例,可以接受。如果超过 25% 就说明路由策略太激进了,读写分离的意义就没了。

留个问题

关于《读写分离下的数据一致性问题与解决》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考