说起读写分离很多人的第一反应是主从复制一搭写走主库读走从库不就完事了但真的把事情搞上线之后你才会发现这个完事背后坑一个接一个。我这两年光是在读写分离上就栽了不止一次跟头踩过的坑可以列一张长长的清单主从延迟导致数据对不上、事务里切库导致连接错乱、从库流量不均直接被打垮、主库切换后路由规则失效……每一个问题单独看都不算大但它们组合在一起足以让一个看起来简单的架构变得极其难缠。这篇文章我会把读写分离从选型到落地、再到日常运维的完整过程拆开讲一遍重点放在那些容易踩坑的细节点。不是从零科普什么是主库从库而是站在我要把读写分离真正用好的角度把自己的实操记录和排查经验整理出来。适合正在做读写分离、或者打算做读写分离的研发和运维同学参考尤其适合那些已经发现照着教程搭起来很简单一上线就出各种幺蛾子的朋友。1. 为什么读写分离是个坑而不是个方案1.1 当初为什么要上读写分离先回到最根本的问题我们到底在解决什么。绝大多数系统的瓶颈都在数据库。单库时代CPU、内存、磁盘IO三项总有一个先扛不住慢查询一多整个业务的响应时间全线飘红。读写分离的核心思路是把读的压力从主库上拆走让主库专心处理写操作从库分担读操作。这个思路本身没有问题但它有一个被很多人忽略的前提你的系统真的存在读多写少的特征。如果用不上这个特征硬上读写分离只会让简单问题复杂化。我见过一个项目日均写入量远大于读量结果上了读写分离之后主库依然繁忙从库倒是闲得发慌还得额外维护一份复制链路和一套路由逻辑纯属给自己找事。所以做读写分离前第一个要确认的不是怎么做而是该不该做。评判标准很简单读请求占总请求的比例、单表数据量、当前主库的负载构成。如果读比例确实很高比如达到 80% 以上而且很多读操作是重查询那读写分离是划算的。如果只是几十万的量级、读压力也不大先把索引优化和缓存做好比折腾主从复制有意义得多。1.2 读写分离带来的根本矛盾数据不一致读写分离的坑本质上全部源于同一个问题主库和从库之间存在复制延迟。只要是异步复制主库写完数据之后从库不会立刻看到这条数据中间一定存在一个时间窗口。你在这个窗口内去从库读读到的是旧数据。这个矛盾是绕不开的。你可以缩短窗口但不能消除它。真正考验架构的地方在于怎么让业务在绝大多数场景下感知不到这个窗口同时在少数据关键场景下强制走主库。很多团队就是在这一点上处理得过于粗糙——要么一切读操作都走从库结果用户刚提交完订单就查不到要么为了保证一致性全部走主库读写分离名存实亡。所以读操作需要分等级。对于一致性要求极高的读比如支付结果、订单详情、账户余额必须走主库对于允许最终一致性的读比如列表页、搜索页、统计报表走从库。这个分流逻辑看起来简单但落地时牵扯出一堆细节后面会专门讲。2. 开始前的架构选型中间件还是应用层路由2.1 两条技术路线的对比实现读写分离主流的做法有两类数据库中间件方案和应用层方案。数据库中间件比如 ProxySQL、MyCat、ShardingSphere-Proxy是在应用和数据库之间架一个代理层应用程序连接中间件由中间件负责把 SQL 语句分发到主库或从库。好处是应用无感知SQL 解析和路由在代理层完成改动对业务方最小而且中间件本身还能提供连接池管理、读写分离之外的读写扩展能力。应用层方案则是程序内部直接管理多个数据源通过框架或自研代码在代码级别决定每次请求用哪个库。典型实践是 Spring 的 AbstractRoutingDataSource 配合 AOP 切面方法上标注 ReadOnly 注解走从库没标注的默认走主库。好处是灵活、不引入额外组件、路由规则完全可控坏处是每个需要走从库的方法都要手动指定对代码有侵入。2.2 我最后选了应用层方案的原因不是中间件不好而是它不适合我当时的场景。我上一家公司的业务逻辑非常复杂同一个接口里会先查缓存、再查订单、再写日志每个查询的实时性要求都不一样。如果用中间件只能在 SQL 级别做规则匹配比如select走从库、insert/update/delete走主库这种粗粒度规则大概率会把刚写入就查询的请求错误地分发给从库然后出现数据不一致。应用层方案可以精确到方法级别。我在 Service 方法上加 ReadOnly 注解框架就知道这个方法走了从库但方法内的某一次指定查询需要强一致我再单独用 ForceMaster 注解强制走主库。这种精细化的控制是中间件很难做到的。选型结论如果团队数据库规模大、数据库种类多、想把复杂路由逻辑从应用里剥离出去中间件是更合适的方向如果业务逻辑多样、团队以业务研发为主、希望快速上线并且不想增加一个需要高可用保障的中间件节点应用层方案更实际。没有绝对好坏只有匹配不匹配。2.3 连接管理多数据源连接池的容量规划多数据源带来的第一个隐藏坑是连接数。原本一个数据库连接池只需要配置 maxActive50现在变成了主库 50、从库 50加起来 100 个连接。如果是两个从库就是 200 个。数据库服务端的最大连接数如果没有同步调整极有可能出现应用起来一切正常一到高峰期就报 Too many connections。我在实际部署时踩过一次当时从库加了三个节点顺手把每个连接池的 maxActive 都配成了 100结果数据库端 max_connections 只有 300三个从库三个连接池加起来已经 300主库连接池一申请就连不上。后面把主库 max_connections 调大同时把从库连接池数量精打细算才解决。这件事给了一个教训连接池的数量不是随便配的要结合数据库端上限、预估并发、每个连接的平均占用时间一起算。注意每个连接池都是独立占用数据库连接的应用部署的实例数越多连接数膨胀越严重。如果你的应用是 10 个实例每个实例配 50 个从库连接两个从库就是 1000 个连接数据库端压力会骤然上升。3. 读写分离的核心难点主从延迟是怎么拖垮业务的3.1 延迟从哪来复制的机制与瓶颈主从复制的链路是主库写完 binlogdump 线程把 binlog 发送给从库从库的 IO 线程接收并写入 relay logSQL 线程再重放 relay log 更新数据。延迟就产生在多个环节网络传输耗时、IO 线程写入 relay log 慢、SQL 线程重放慢。SQL 线程重放慢是最常见的瓶颈。主库是单线程写入从库本来也是单线程应用 binlog虽然新版本 MySQL 支持了并行复制MTS但并行度仍然受限于表的关联关系和事务的冲突程度。遇到一个大事务在主库执行 10 秒从库可能需要 10 秒甚至更久来重放这段时间内从库的数据就会明显落后。主库在高并发写入时产生大量 binlog如果从库的磁盘性能跟不上IO 线程的接收速度也会拖后腿。量化延迟的指标是 Seconds_Behind_Master。但这里有个容易误判的点这个值是从库 SQL 线程当前执行时间与 IO 线程接收最新 binlog 时间的差值如果 IO 线程本身也慢了这个值可能显示为 0实际上数据已经落后很多。我更习惯用另一种方式判断从库上执行SHOW MASTER STATUS对比主库的 binlog 位点或者直接查某个业务表的最大自增 ID与主库对比差值。3.2 延迟的量化你需要知道自己的系统能容忍多少要想不被延迟坑得先把延迟容忍度量化出来。延迟容忍度不仅仅是一个时间值它还和具体的业务场景绑定。我自己的习惯是给每个读写分离的读场景打一个标签分成三类场景延迟容忍度路由规则示例强一致读0必须走主库支付结果、订单状态、用户余额弱一致读几秒优先从库失败重试主库商品列表、用户主页、搜索页最终一致读分钟级直接走从库报表统计、定时任务扫描这张表是后续所有路由策略的基础。没有它你根本说不清楚到底哪些读操作能走从库。很多团队把读写分离做成全量读走从库就是因为没有做这个分级动作最后只能靠加各种特殊补丁救火。3.3 缓解主从延迟的常见手段从源头到策略缓解主从延迟有几个层面的做法从最根本的复制机制优化到业务侧兜底都要考虑。第一是并行复制。MySQL 5.7 之后的并行复制是基于组提交的主库上同时提交的事务越多从库并行重放的效率就越高。线上评估一下写入并发度把 slave_parallel_workers 调到一个合适的值比如 8 或者 16往往能明显降低延迟。第二是半同步复制。把异步复制升级成半同步复制主库在提交事务后要等待至少一个从库收到 binlog 并写入 relay log 才返回成功。这样虽然不能保证从库重放完成但可以保证从库不会丢数据也不会处于一个长时间完全未知的状态。代价是写入性能有一定损耗大约 10% 到 20%但很多核心业务愿意接受这个成本换取更高的可用性。第三是业务策略兜底。延迟问题的最终防线在业务代码里从库读到数据时判断数据的时间戳或版本如果发现数据太旧可以短时间等待后重试或者直接路由到主库重新查询。我在订单查询场景就是这么做的——查出订单状态之后对比更新时间如果距离当前时间超过 3 秒就走主库重查一次。实测下来大多数情况下从库的数据是新的根本不需要触发重试但一旦触发就能挡住那些尖峰时刻的不一致问题。4. 实操配置记录Spring Boot 动态数据源落地全过程4.1 核心原理AbstractRoutingDataSource在 Spring 生态里做应用层读写分离核心是 AbstractRoutingDataSource。这个类的本质是一个 RoutingDataSource它本身不直接持有真正的数据库连接而是维护一个目标数据源的 Map并通过 determineCurrentLookupKey() 方法返回的 key 来决定当前线程使用哪个目标数据源。重点在于当前线程。Spring 的事务管理器在开启事务时会从数据源获取连接而数据源的选择是在运行时动态决定的。只要我们能在调用数据源之前把当前线程需要使用的数据源标识设置到一个 ThreadLocal 里就能实现方法级别的路由。这个方案早期用的人很多到现在依然实用。很多人问过为什么不直接用 ShardingSphere 的 JPA 或 MyBatis 插件其实对于只需要读写分离、不需要分库分表的项目自研路由的成本很低而且最容易把控。引入一个大型框架很多时候杀鸡用牛刀。4.2 代码实现注解、切面与上下文第一步是定义一个上下文类保存当前线程的数据源标识public class DataSourceContextHolder { private static final ThreadLocalString CONTEXT new ThreadLocal(); public static void setDataSource(String dataSourceType) { CONTEXT.set(dataSourceType); } public static String getDataSource() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }第二步是定义两个注解ReadOnly 和 ForceMaster。ReadOnly 标注方法默认走从库ForceMaster 用于在走从库的方法里强制某一次查询走主库。第三步是配置类。把主库和从库数据源都封装成独立的数据源 Bean然后注册到 RoutingDataSource 里Configuration public class DataSourceConfig { Bean Primary public DataSource routingDataSource( Qualifier(masterDataSource) DataSource master, Qualifier(slaveDataSource) DataSource slave) { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DataSourceType.MASTER, master); targetDataSources.put(DataSourceType.SLAVE, slave); RoutingDataSource routingDataSource new RoutingDataSource(); routingDataSource.setDefaultTargetDataSource(master); routingDataSource.setTargetDataSources(targetDataSources); return routingDataSource; } }RoutingDataSource 的关键实现是重写 determineCurrentLookupKey把上下文里的 key 返回出去public class RoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }第四步是 AOP 切面。拦截标注了 ReadOnly 的方法在方法执行前设置数据源标识为从库执行完毕后清理 ThreadLocal。这里要注意切面加载顺序必须让切面先于事务切面执行否则事务已经开启、连接已经拿到再切换数据源就晚了。这个顺序问题后面会专门讲是一个极其容易踩的坑。4.3 路由规则细节哪些场景必须强制主库切面只是一个基础框架真正写清楚路由规则才是保证一致性的关键。我总结了自己线上用的这套规则可以直接参考查询用户自己刚产生的数据强制主库。比如用户提交订单后跳转到订单详情这个详情必须走主库因为从库可能还没复制过去。后台管理端的查询一律走主库。管理端本身流量不大一致性要求极高不需要为了省一点负载去冒数据延迟的风险。定时任务、统计报表走从库。这类任务本来就是处理历史数据允许最终一致。接口级别默认走主库。只有明确标注 ReadOnly 的方法才走从库宁缺毋滥。秒杀、价格计算这类并发极高但对数据新鲜度敏感的场景走主库加缓存结合不能直接压到从库。4.4 配置落地时的几个细节配置多数据源时有几个细节要注意每一个都可能让系统在运行期出现诡异问题。一个是 MyBatis 的插件问题。如果项目里用了 PageHelper 这类分页插件它们会拦截 SQL如果插件在拦截时拿到了错误的数据源信息分页查询就会走错库。我当时遇到的情况是方法标注了 ReadOnly但 PageHelper 的分页 SQL 还是走到了主库。排查后发现是插件在 MyBatis 执行器创建时就绑定了数据源和我们的路由切面执行顺序冲突。最后通过调整拦截器顺序确保我们的切面先执行解决了问题。另一个是连接池的初始化问题。Spring Boot 2.x 默认使用 HikariCP多个数据源如果配置了相同的 Bean 名称会互相覆盖。每个数据源的配置必须独立命名空间否则会出现两个库共用一个连接池的情况从库的连接池里混进了主库的连接路由规则直接失效。再一个是事务管理器只认一个数据源。Spring 的 DataSourceTransactionManager 需要绑定一个具体的数据源。如果项目里既有主库又有从库事务管理器是绑定在 RoutingDataSource 上还是绑定在主库数据源上会导致完全不同的事务行为。这个我放在下一节详细说因为它牵扯到读写分离最隐蔽的一个问题。注意代码里不要直接把从库数据源注入到 MyBatis 的 SqlSessionFactory否则所有查询都会走从库主库写操作反而找不到连接。5. 事务与读写分离的隐形冲突一处没想清楚整个链路崩5.1 事务内为什么不能随意切换数据源这是我在读写分离上栽过的最深的一个坑。当时有一段核心代码方法上标注了 ReadOnly 走从库但方法内部又触发了一个写操作于是我在代码里手动调用了 DataSourceContextHolder 切换数据源到主库。结果运行时事务管理器已经提前开启了事务并且事务管理器是基于 RoutingDataSource 获取连接的。问题在于事务开启时连接已经和当前线程绑定了后面的查询和写入都通过这个连接执行。我从从库切换到主库只是改变了 Key但事务管理器不会去重新获取连接它仍然使用之前从从库拿到的连接执行写入。结果就是写入操作实际还是在从库上执行从库直接报错The MySQL server is running with the --read-only option。这个问题的根源是一旦事务开启数据源的选择权就不在路由层而在事务管理器手里。事务管理器在事务开始时拿了一个连接后续所有操作都复用这个连接和当前线程的数据源标识无关。5.2 正确的做法读写分离必须让位给事务踩完这个坑之后我立了一个规矩只要方法内包含写操作或者方法被 Transactional 标注一律不允许走从库。更具体地说切面的判断逻辑要最优先检查这个条件Aspect Component Order(0) public class DataSourceAspect { Around(annotation(readOnly)) public Object switchDataSource(ProceedingJoinPoint joinPoint, ReadOnly readOnly) throws Throwable { // 1. 如果当前线程已存在事务说明已绑定连接不能再切换数据源 // 2. 如果事务同步管理器处于活动状态说明事务已开启 if (TransactionSynchronizationManager.isActualTransactionActive()) { return joinPoint.proceed(); } // 3. 没有事务才允许切换到从库 DataSourceContextHolder.setDataSource(DataSourceType.SLAVE); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }这个逻辑保证了事务的优先级永远高于读写分离。没有事务的方法才考虑走从库一旦有事务哪怕标注了 ReadOnly 也直接走主库。虽然会损失一些读性能但换来的是正确的数据一致性和可用性。别为了那一点点性能去赌事务处理数据错乱的时候你会付出更大代价。5.3 强制主库的 Hint 机制绕开事务限制的旁路但确实存在一些场景方法本身没有事务但内部某个查询要强制走主库。这时候可以在框架里额外提供一个 Hint 机制比如在方法内调用 DataSourceContextHolder.setDataSource(DataSourceType.MASTER)覆盖之前的从库设置。这个操作并不违反上一节说的原则因为它是在没有事务的前提下做的切换连接还没绑定切换一定生效。直接设置数据源标识的问题在于如果方法内部有多个查询最后一次设置的标识会一直生效容易漏设置导致其他查询也走了主库。更可控的方式是用一个专门的工具类配合 ThreadLocal 栈public class DataSourceHint { public static void forceMaster() { // 压栈之前的值确保用完后可以恢复 DataSourceContextHolder.push(DataSourceType.MASTER); } public static void clearHint() { DataSourceContextHolder.pop(); } }try-finally 包住强制主库的部分用完立刻弹出恢复到走从库的默认状态。这个写法比较繁琐但线上环境里就像安全带一样用不到的时候觉得多余真出问题的时候能救你一命。5.4 连接透传与事务传播级别的坑还有一个和事务相关的坑是使用 REQUIRES_NEW 传播级别时事务管理器会挂起当前事务并创建一个新事务。新事务开启时同样会从数据源获取连接如果方法标注了 ReadOnly新事务里的查询就可能走从库但外层事务是写事务已经有一个主库连接了。这时候就会出现同一个线程里同时持有主库和从库两个连接的情况。这不算致命错误但会对数据库连接占用明显上升也会导致内部事务查询的数据和外部事务不一致。我当时的解决办法是在所有开启新事务的内部方法上不加 ReadOnly强制它们走主库哪怕只做查询。这样虽然增加主库的一点压力但换来的是事务内数据的一致性。如果确实有大量的内部读需要走从库那就要重新评估这个方法的业务场景是否合理。6. 常见故障与排查实录6.1 故障一凌晨大批量读从库把从库压垮第一次实际运行中遇到的大事故是凌晨 2 点的定时报表任务把从库 CPU 打到了 99%。排查下来发现定时任务扫描了一个大表SQL 里缺少合适的索引导致全表扫描每次扫描跑了 4 分钟。因为定时任务启动时间一致多个任务并行扫描从库资源被瞬间耗尽主库写入的 binlog 无法及时同步延迟一路飙升到 600 秒最终引发大批业务报错。那次处理的教训有两个。一是大查询在从库执行不代表无风险从库不是随便造的库它同样有性能上限。二是定时任务之间必须在时间上错峰避免同时触发多个大查询。后面给所有线上报表任务加了一个分布式锁保证同一时间只有一个任务在跑同时要求每一条定时任务 SQL 都必须先过 explain确认走索引之后才能上线。6.2 故障二从库数据不一致导致订单状态错乱另一件事是客户反馈订单状态时好时坏前端第一次查询是已付款刷新一下又变成待支付。最终定位是订单状态在从库的延迟窗口内被读到了旧数据。之所以不一致这么明显是因为订单查询接口默认走了从库而下单接口写入主库后立刻跳转到详情页用户刷新速度太快每次都赶上了延迟窗口。修复方案有两层。第一层是把订单详情这个强一致接口强制走主库确保用户查询到的永远是最新状态。第二层是在订单写入后设置一个 3 秒的路由主库标记也就是写入后的短时间内同一个用户的所有请求一律走主库。这两个手段叠加之后这个问题再没有出现过。个人经验是任何用户刚发起写操作后的读操作都要小心这几乎是不一致的集中爆发点。6.3 故障三连接池被打满引发雪崩还有一次故障不是从库的锅是连接池配置的问题。当时从库连接池的 maxActive 设置偏小因为业务量上涨有大量查询阻塞在连接池等待区线程全部卡在 getConnection 上最终导致应用整体响应超时。因为主库连接池配置足够所以刚开始看起来只是读接口变慢但大量读请求失败之后用户开始重试重试请求又打到主库连带主库也不稳定了。排查后发现两个问题一是连接池的 maxActive 设置没有做过容量估算拍脑袋填的二是读接口的超时时间设置过长大量请求积压在等待队列里线程资源被耗尽。结合这次故障我把连接池扩容到合理水平同时给所有数据库查询添加了超时控制让慢查询尽快失败而不是无限期等待。6.4 常见问题速查表下面是我整理的一个速查表覆盖读写分离运维中的高频问题建议收藏对照现象可能原因排查方法解决方案刚写入就查不到数据主从延迟读请求走了从库查看 Seconds_Behind_Master强一致场景强制走主库或写入后设置一段时间路由主库从库 CPU 高但主库正常慢查询被路由到了从库查看从库慢查询日志从库也要做索引优化定时任务错峰应用报 Too many connections连接池数量超过数据库上限查看数据库 max_connections按实例数*连接池数估算总量合理分配写操作在从库上执行报 read-only事务开启后切数据源检查代码中事务与路由顺序有事务一律走主库切库必须在事务开启前主从延迟持续上涨大事务或复制线程卡住查看复制状态和 relay log拆分大事务开启并行复制主库宕机后从库不能切换缺少高可用方案检查复制链路引入主从自动切换或使用 MHA/Orchestrator6.5 主从复制链路自身的高可用检查很多人把重点全放在应用侧的路由规则上忽略了复制链路本身的健康度。复制链路一旦中断从库会一直处于数据停止更新的状态但应用并不知道仍然把大量读请求发过去用户看到的就是一片过期数据。我后来养成了一个习惯每天定时检查从库的复制状态。核心指标有三个Slave_IO_Running 是否为 Yes、Slave_SQL_Running 是否为 Yes、Seconds_Behind_Master 是否在合理区间内。如果 IO 线程或 SQL 线程停了立即告警手动排查原因如果延迟持续超过阈值自动将对应从库从路由列表里摘除流量切到其他健康的从库或主库。这个摘除逻辑最初是运维手工执行后面我写成了简单的脚本加上健康检查效果显著。7. 写在最后我个人的一点实战体会读写分离这个方案说穿了就是一个用一致性换性能的交易。交易的规则必须提前定清楚哪些数据可以让步、哪些数据绝对不能让步让步的幅度是多少秒。规则不清楚就上线出问题只是时间问题。我见过太多团队在架构评审时纠结中间件的选型却对订单查询必须走主库这种基础规则没有达成一致最后全都靠线上故障来教育。一个特别实用的小技巧是在所有的 DAO 层查询语句里尽量打上数据源的标记。比如 XML 映射文件里每个 select 标签都写上一行注释绑定数据源slave或绑定数据源master这在别人接手代码、或者你自己半年后再回来看这些代码时能少掉非常多头发。线上排查问题时通过日志里的数据源标识也一眼能看出有没有走错库。如果你们团队正准备做读写分离我的建议是先小范围试点把一个非核心但读量较大的接口切过去跑一两周观察延迟、连接数、慢查询这些指标确认没有问题了再逐步扩展。不要一开始就把所有读流量都切过去那不是上线是赌博。读写分离本身不难难的是把边界条件都想清楚。希望我这些踩坑记录能帮你少走一些弯路。
