在单机数据库模式下,我们享受了太多的理所当然:ACID 事务、强一致性读写、瞬间的数据可见性。然而,一旦引入了异步主从复制,这个美好的世界就崩塌了。系统从 CP (一致性) 模型滑向了 AP (可用性) 模型。主从延迟(Replication Lag) 像一个幽灵,让许多在单机下奉为圭臬的最佳实践,变成了分布式环境下的致命陷阱。

核心矛盾根源:真相的传播延迟

  • 单机模式:"真相"只有一个(唯一的 DB 实例)。写入即事实,读取即真相。时间差近乎为零。
  • 主从模式:"真相"在传播。主库是当前的真相,从库是几百毫秒甚至几秒前的"历史影像"。依赖历史影像做决策,必然出错

从单机到主从架构的升级,不是简单的加几台机器,而是一场思维方式的革命:

  1. 放弃对实时强一致性的幻想:在分布式系统中,除了核心的金融级数据,大部分场景都要接受最终一致性
  2. 识别真理之源:时刻清楚 Master DB 是唯一的真理。Slave DB 只是用于分担非敏感读压力的快照副本。
  3. 防御性编程:写代码时,永远要假设你读到的数据可能是旧的,并思考"如果它是旧的,我的业务逻辑会不会炸?"如果会炸,就必须升级方案(走主库、加异步校验链等)。

本篇接下来就梳理单机模式最佳实践在主从架构下失效的经典场景。

1. 缓存一致性

在单机模式下,为了应对 MySQL 和 Redis(缓存)的一致性,业界通用的解决方案是 Cache Aside(旁路缓存)。即:先更新 DB,再删缓存。下次读请求发现 Cache Miss,查 DB 并回填。因 DB 里的数据永远是最新的,回填缓存没问题。

但是在主从模式下就不一样了:

写请求删了缓存。读请求去从库查到了旧数据(因为延迟)。读请求把旧数据当作新数据塞回了 Redis。结果就导致了 Redis 里存储了脏数据。

总结

核心场景 单机模式下的最佳实践 (Best Practice) 主从模式下的失效原因 (The Trap) 必须升级的分布式方案 (The Upgrade Path)
缓存一致性 (Cache-Aside) 先写 DB,再删缓存 依赖读操作回填缓存。简单高效,极少出现不一致。 脏数据回填死结 写完主库删缓存后,读请求立刻打到延迟的从库,读到旧数据并永久回填入缓存。(即我们刚深入讨论的案例)。 方案 A (标准):Binlog 异步消息队列删除(引入异步重试机制兜底)。
方案 B (极强):回填前强制校验主库版本号(牺牲主库性能换取强一致)。
读己之写 (Read-Your-Own-Write) 写完直接读 用户修改资料后,立刻刷新页面调用查询接口,能马上看到更新后的信息。 用户体验崩塌 刚写完主库,转头读了从库。用户发现自己刚改的数据没生效,产生恐慌或重复提交。 方案 A (折中):短期强制路由(写操作后的 N 秒内,该用户的读请求强制走主库)。 方案 B (复杂):客户端版本追踪(客户端记住自己刚修改的版本号,请求时带上,中间件判断从库是否追上)。
唯一性检查 / 业务前置校验 (Business Check) 先查后写 (Check-Then-Act) 例如注册时:SELECT count(*) FROM user WHERE email=?,若为 0 则 INSERT。 并发重复与校验失效 两个请求同时发到不同从库查询,都以为 email 不存在,然后同时向主库发起 INSERT。虽然主库唯一索引能挡住,但业务层的校验逻辑彻底失效,引发大量报错。 方案 A:强制查主库(所有涉及状态决策的前置查询,必须走主库)。
方案 B:分布式锁(在查 DB 前,先在 Redis/ZK 上抢占一个 key,但这引入了新组件依赖)。
高并发库存扣减 (Inventory Deduction) 悲观锁 SELECT FOR UPDATE 或 乐观锁 UPDATE ... WHERE count > 0 依赖数据库行锁或 MVCC 保证原子性扣减。 从库读取导致超卖 如果为了性能去从库查询“当前库存”,得到旧值(比如显示有货,实际主库已没货),然后发起扣减请求,导致判断失误。 铁律:涉及钱和库存的操作,读写必须全部在主库完成。 或者彻底升级架构,使用 Redis + Lua 脚本做库存扣减中心,数据库只做异步落库。
事务状态查询 (Transaction Status) 直接查询订单表状态 支付回调后,查询订单表确认订单是否已变为“已支付”。 回调“未找到订单” 支付成功的回调非常快,可能在主从同步完成前就到达。此时去从库查订单状态,可能查到还是“未支付”,导致业务逻辑错误。 方案:强制查主库。 对于此类时效性极高的状态确认查询,绝不能走从库。