电商订单因MySQL主从延迟少发100单的教训 一文讲透数据一致性维护的5大坑与解决方案
凌晨两点,客服群的提示音炸了。
“用户反馈订单已支付但查不到订单信息!” “有100单用户说没收到发货通知,但支付成功页明明显示’订单创建成功’!”
我猛地从床上坐起来。作为这家电商公司刚入职半年的DBA,我隐约有种不好的预感——这是典型的主从延迟引发的数据不一致事故。
果不其然,第二天早上站会,CTO黑着脸甩出一张图:过去24小时,有100笔订单的状态是”已支付”,但在订单查询服务里根本查不到。
事故还原:那100单去哪了?
先说背景。我们的系统架构是这样的:
- 订单服务(写服务):直连MySQL主库
- 订单查询服务(读服务):连接MySQL从库
- 主从复制方式:异步复制(Async Replication)
用户下单流程:
- 用户在订单服务下单,写入主库
- 订单服务返回”创建成功”
- 用户去订单查询页面看订单状态
- 查询服务读从库数据
问题就出在2和3之间。
当时的情况是:主库压力很大,从库复制滞后严重。有100个用户在下完单后,立刻去查订单,结果从库还没同步过来,返回”订单不存在”。
更致命的是,后续有用户因为看不到订单,以为支付没成功,重复支付了,然后又去投诉”为什么扣款两次但没订单”。
坑一:以为”写入成功”就是”真正成功”
这是很多团队最容易踩的坑。
订单服务调用:
// 订单创建
public OrderResult createOrder(OrderRequest request) {
// 1. 写主库
long orderId = orderMapper.insert(request);
// 2. 立即返回成功
return OrderResult.success(orderId);
}
看起来没问题?错!
异步复制场景下,”写入主库成功”不等于”数据已持久化到集群”。
如果主库在返回成功后、数据同步到从库前宕机,就会出现:
- 主库数据丢失(有binlog但没commit)
- 从库没有这条数据
- 用户看到”创建成功”,但查不到订单
解决方案:关键业务要等从库同步
public OrderResult createOrder(OrderRequest request) {
long orderId = orderMapper.insert(request);
// 如果是关键订单,等待从库同步
if (request.isHighPriority()) {
// 轮询从库,直到同步完成
int maxRetries = 5;
for (int i = 0; i < maxRetries; i++) {
Thread.sleep(200); // 200ms间隔
if (orderQueryService.existsInSlave(orderId)) {
return OrderResult.success(orderId);
}
}
// 超时未同步,降级处理
throw new OrderCreateException("从库同步超时,请联系客服");
}
return OrderResult.success(orderId);
}
当然,轮询从库本身也有问题。更好的方式是使用semi-sync复制(半同步复制),让主库在返回成功前,确保至少一个从库已接收并写入binlog。
-- 开启半同步复制
mysql> INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
mysql> SET GLOBAL rpl_semi_sync_master_enabled = ON;
mysql> SET GLOBAL rpl_semi_sync_master_timeout = 1000; -- 1秒超时
这样主库会在返回成功前,等待从库确认收到数据。虽然会增加约10-50ms的延迟,但能避免大部分数据不一致问题。
坑二:读从库不判断延迟,直接返回”不存在”
当时的查询代码是这样的:
public Order getOrderById(Long orderId) {
// 直接读从库
Order order = orderQueryMapper.selectById(orderId);
// 查不到直接返回null
if (order == null) {
throw new OrderNotFoundException("订单不存在");
}
return order;
}
这是最坑的代码之一。
用户刚刚下单,主库有数据,但从库还没同步。查询服务直接返回”订单不存在”,用户以为是系统bug,实际是主从延迟。
解决方案:读从库失败时fallback到主库
public Order getOrderById(Long orderId) {
try {
// 优先读从库
Order order = orderQueryMapper.selectById(orderId);
if (order != null) {
return order;
}
// 从库查不到,可能是延迟,尝试读主库
// 注意:这里用特殊标记的SQL绕过读写分离
Order masterOrder = orderQueryMapper.selectByIdForMaster(orderId);
if (masterOrder != null) {
log.warn("订单{}在从库不存在,回源主库查询成功", orderId);
return masterOrder;
}
// 主库也没有,才是真的不存在
throw new OrderNotFoundException("订单不存在: " + orderId);
} catch (Exception e) {
log.error("查询订单失败: orderId={}", orderId, e);
throw new OrderQueryException("查询订单异常");
}
}
关键是selectByIdForMaster这个SQL,通常通过SQL注释或特殊参数标记:
-- 主库查询(通过/* FORCE_MASTER */ 标记)
SELECT * FROM t_order WHERE order_id = #{orderId} /* FORCE_MASTER */
或者在MyBatis配置中,给这个方法单独指定数据源。
坑三:用”最后同步时间”判断一致性,但没考虑binlog格式
我们当时有个监控:
// 主从延迟监控
public long getReplicationLag() {
// 获取从库的Last_SQL_Event_Time
// 与主库当前时间比较
return System.currentTimeMillis() - slaveLastEventTime;
}
看起来合理?其实有个大坑。
如果binlog格式是STATEMENT(语句格式),而不是ROW(行格式),延迟监控会失效。
比如这个更新语句:
UPDATE t_order SET status = 'PAID' WHERE order_id = 123456;
主库执行后,从库回放。但如果主库执行时和从库回放时,order_id=123456的行结构不同(比如主库刚改了表结构),从库可能找不到这一行,导致回放失败。
更隐蔽的问题是:SHOW SLAVE STATUS返回的Seconds_Behind_Master可能是NULL,表示从库无法判断延迟。
解决方案:必须用ROW格式+监控报警
-- 主库配置
binlog_format = ROW
binlog_row_image = FULL -- 记录变更前后的完整数据
-- 监控从库状态
SELECT
master_host,
slave_io_running,
slave_sql_running,
seconds_behind_master,
last_error,
relay_log_space -- 中继日志空间,可以判断堆积情况
FROM information_schema.slave_status;
监控报警逻辑:
public void checkReplicationHealth() {
SlaveStatus status = getSlaveStatus();
// IO线程或SQL线程异常
if (!"Yes".equals(status.getSlaveIoRunning())) {
alert("从库IO线程异常: " + status.getLastIoError());
}
if (!"Yes".equals(status.getSlaveSqlRunning())) {
alert("从库SQL线程异常: " + status.getLastSqlError());
}
// 延迟超过阈值
if (status.getSecondsBehindMaster() > 5) {
alert("主从延迟超过5秒,当前延迟: " + status.getSecondsBehindMaster() + "秒");
}
// 中继日志堆积
if (status.getRelayLogSpace() > 100 * 1024 * 1024) { // 100MB
alert("中继日志堆积超过100MB");
}
}
坑四:重试机制没区分”主从延迟”和”真正失败”
用户看到”订单不存在”后,疯狂刷新页面。系统触发了重试逻辑:
// 重试查询
public Order getOrderWithRetry(Long orderId) {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
Order order = getOrderById(orderId);
if (order != null) {
return order;
}
try {
Thread.sleep(1000); // 等1秒重试
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return null;
}
这个重试是无效重试。
因为主从延迟可能持续几秒甚至几十秒,重试3次(共等3秒)根本不够。而且每次重试都打从库,加剧从库压力。
解决方案:区分延迟和失败,用指数退避
public Order getOrderWithSmartRetry(Long orderId) {
int maxRetries = 5;
long[] delays = {500, 1000, 2000, 4000, 8000}; // 指数退避
for (int i = 0; i < maxRetries; i++) {
// 第一次尝试读从库
Order order = null;
try {
order = orderQueryMapper.selectById(orderId);
} catch (Exception e) {
log.warn("从库查询异常,尝试主库: orderId={}", orderId, e);
}
if (order != null) {
return order;
}
// 从库查不到,直接fallback到主库,不等
if (i == 0) {
order = orderQueryMapper.selectByIdForMaster(orderId);
if (order != null) {
log.warn("订单{}从库不存在,主库查询成功", orderId);
return order;
}
}
// 主库也没有,等一会再试(应对主从延迟)
try {
Thread.sleep(delays[i]);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return null; // 真的不存在
}
关键改进:
- 第一次查从库失败,立刻fallback主库
- 主库也没有,才进入重试循环
- 用指数退避,避免频繁请求
坑五:没考虑高并发下的延迟放大效应
事故当天,正好是秒杀活动。
主库QPS飙到5000+,从库复制跟不上,延迟从正常的200ms涨到5秒+。
问题在于:延迟不是固定的,而是随负载波动的。
我们当时的延迟监控只看当前值,没有历史记录。等CTO发现问题时,延迟已经持续了10分钟。
解决方案:延迟监控要关注趋势,而不仅是瞬时值
public class ReplicationMonitor {
private final Queue<Long> lagHistory = new ArrayDeque<>(60);
private static final int MAX_HISTORY = 60;
private static final long ALERT_THRESHOLD = 3000; // 3秒
private static final long DURATION_THRESHOLD = 60000; // 1分钟
public void recordLag(long lag) {
lagHistory.add(lag);
if (lagHistory.size() > MAX_HISTORY) {
lagHistory.poll();
}
// 检查过去1分钟的延迟趋势
checkTrend();
}
private void checkTrend() {
if (lagHistory.size() < 10) {
return;
}
// 计算过去10个采样点的平均延迟
long totalLag = lagHistory.stream().mapToLong(Long::longValue).sum();
long avgLag = totalLag / lagHistory.size();
// 检查是否持续高延迟
long highLagCount = lagHistory.stream()
.filter(l -> l > ALERT_THRESHOLD)
.count();
if (highLagCount > lagHistory.size() * 0.5) {
// 超过50%的采样点延迟超过3秒
alert("主从延迟持续过高,平均延迟: " + avgLag + "ms, 采样点: " + lagHistory.size());
}
// 检查延迟是否在增长
if (lagHistory.size() >= 10) {
long firstHalf = lagHistory.stream()
.limit(lagHistory.size() / 2)
.mapToLong(Long::longValue)
.average().orElse(0);
long secondHalf = lagHistory.stream()
.skip(lagHistory.size() / 2)
.mapToLong(Long::longValue)
.average().orElse(0);
if (secondHalf > firstHalf * 1.5) {
alert("主从延迟正在恶化!前段平均: " + firstHalf + "ms, 后段平均: " + secondHalf + "ms");
}
}
}
}
另外,建议在业务代码里加延迟探测:
public class SlaveHealthChecker {
// 定期向从库写入测试数据,验证可读取
private volatile long lastTestTime = 0;
private static final long TEST_INTERVAL = 5000; // 5秒
public boolean isSlaveHealthy() {
if (System.currentTimeMillis() - lastTestTime < TEST_INTERVAL) {
return slaveHealthy;
}
try {
// 写入测试数据
long testTime = System.currentTimeMillis();
orderQueryMapper.insertTestRecord(testTime);
// 立即从从库查询
TestRecord record = orderQueryMapper.selectTestRecord(testTime);
slaveHealthy = record != null;
lastTestTime = System.currentTimeMillis();
return slaveHealthy;
} catch (Exception e) {
slaveHealthy = false;
return false;
}
}
}
这样业务层能实时感知从库健康状态,而不是等到用户投诉。
事故后的架构改进
这次事故后,我们做了以下改进:
1. 开启半同步复制
-- 主库配置
mysql> INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
mysql> SET GLOBAL rpl_semi_sync_master_enabled = ON;
mysql> SET GLOBAL rpl_semi_sync_master_timeout = 1000;
-- 从库配置
mysql> INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_master.so';
mysql> SET GLOBAL rpl_semi_sync_slave_enabled = ON;
主库写入时,会等待至少一个从库确认接收binlog,才返回成功。
2. 关键读操作强制读主库
// 订单查询、支付状态查询等关键操作
@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
public Order queryOrderForBusiness(Long orderId) {
// 使用 /* FORCE_MASTER */ 强制读主库
return orderMapper.selectByIdForMaster(orderId);
}
或者通过动态数据源切换:
@DataSource("master")
public Order getOrderForceMaster(Long orderId) {
return orderMapper.selectById(orderId);
}
3. 增加数据一致性校验任务
@Component
public class DataConsistencyChecker {
@Scheduled(fixedRate = 60000) // 每分钟执行
public void checkConsistency() {
// 检查主库有但从库没有的订单
List<Order> masterOnly = orderConsistencyMapper.findOrdersOnlyInMaster();
if (!masterOnly.isEmpty()) {
log.warn("发现{}个订单仅在主库存在,可能从库延迟", masterOnly.size());
// 强制同步
for (Order order : masterOnly) {
orderConsistencyMapper.forceSyncToSlave(order.getOrderId());
}
}
}
}
4. 业务层增加订单查询的超时降级
public Order getOrderWithFallback(Long orderId) {
// 先尝试读从库(带超时)
Order order = null;
try {
order = orderQueryMapper.selectById(orderId);
} catch (Exception e) {
log.warn("从库查询超时,降级到主库", e);
}
if (order != null) {
return order;
}
// 从库查不到,读主库
order = orderQueryMapper.selectByIdForMaster(orderId);
if (order == null) {
// 主库也没有,返回友好提示
throw new OrderNotFoundException("订单查询失败,请稍后重试");
}
return order;
}
血泪总结
这次事故让我深刻理解了数据一致性不是技术问题,而是架构设计问题。
5个坑,概括起来就是:
- 写入成功 ≠ 数据一致 —— 异步复制下,主库写入成功不代表从库已有数据
- 读从库不判断延迟 —— 查到”不存在”可能是延迟,不是真的不存在
- 监控只看瞬时值 —— 延迟趋势比当前值更重要
- 重试机制盲目 —— 重试前应该先fallback主库,而不是傻等
- 没考虑负载影响 —— 高并发下延迟会放大,监控要有趋势分析
最后送给大家一句话:在主从架构下,永远不要假设从库有主库的数据。
如果你正在做类似系统,建议至少做到:
- 关键读操作强制读主库或读写分离+fallback
- 开启半同步复制或GTID强一致复制
- 监控主从延迟趋势,而不仅是瞬时值
- 业务层增加延迟感知和降级逻辑
毕竟,100单少发的代价,足够让一个DBA睡不好几个觉。
