做了这么多年架构设计和系统重构,我越来越觉得,“架构演进”这四个字被很多人误解了。有人把它当成技术升级,有人把它当成微服务改造,还有人一上来就想着分库分表、上中间件。但真正经历过业务从零到一、从一台机器到几十台机器的人会明白,架构演进不是技术秀,而是一个不断“补短板”的过程。今天我想把这条演进链路中最关键的一段讲清楚:从最原始的初始阶段,一步步走到读写分离。
这一节对应的是架构设计基础里的演进部分。无论你是刚准备转架构师的开发,还是已经被迫开始思考系统扩容的初中级工程师,这篇文章都值得完整看一遍。我会把单体应用怎么跑起来、什么时候该拆分、读写分离的底层机制、落地细节,以及我踩过的坑全部讲透,尽量不放过任何一个关键决策背后的逻辑。
1. 架构演进到底在演什么:先想清楚再动手
1.1 演进是补短板,不是追新
拿木桶理论来理解架构演进特别合适。一个系统能支撑多少用户、承担多少流量,不取决于它最强的那个组件,而取决于最弱的那个组件。早期系统一般是应用和数据库部署在同一台服务器上,这时候的短板很明显:单台机器的CPU、内存、磁盘、带宽全都在互相抢。等到访问量上来,最先撑不住的往往不是应用,而是数据库。于是演进的方向就很清晰了——先把数据库从应用里拆出去。等数据库独立了,你会发现自己又开始头疼连接数、慢查询、主库负载。这时候短板变成了“单库的读写能力”,于是读写分离自然就成了下一步。
这里有个很重要的认知:演进不是因为你用了新框架、新技术,而是因为当前结构已经无法满足业务的量级。如果你连瓶颈在哪都没搞清楚,就盲目去上注册中心、上容器编排、上微服务,那不是演进,是给自己挖坑。我在实际工作中遇到过不止一个团队,系统日活只有几千,却把工程拆成了十几个微服务,结果排查问题要跨好几个系统,联调成本暴涨,这就是典型的没想清楚就动手。
1.2 启动演进前必须满足的三个前提
没有充分准备的演进很容易翻车。我自己的习惯是,任何一次大型架构调整,都必须先满足三个前提。
第一,监控是完整的。你得能准确说出当前系统每天的QPS、数据库连接数、慢查询数量、磁盘IO、CPU使用率。没有这些数据,你连“瓶颈在哪”都是拍脑袋,那后续的容量规划、方案对比、效果验证全都没有依据。第二,有可回滚的方案。架构演进不是一次性切换的赌局。数据库从单库变成主从结构、应用从直连单库改成走路由层,每一步都要能退回去。哪怕为了回滚需要多保留一套旧逻辑,也值得。第三,业务方愿意配合验证。演进不只是技术团队的事,业务上线窗口、用户感知、数据校验,都需要业务方认可。如果有其中一个前提不满足,我宁可把演进方案往后推一推,也不想带病上线。
1.3 架构师在演进中的角色
聊到架构师的角色,很多人觉得架构师就是做技术决策的人。这个理解太片面了。做决策只是最后一步,在那之前有大量的信息收集、利弊权衡、团队沟通和节奏把控。架构师更像是一个系统演进的操盘手:既要看到当前系统的缺陷,也要预判未来半年到一年的业务走势,还要考虑团队现有的维护能力。
尤其在读写分离这个阶段,架构师要做的不是“我来定方案”,而是把所有备选方案的取舍摊在桌面上,让大家明白为什么这个阶段不上分库分表、为什么读写分离已经足够、为什么从库数量先加两台而不是直接上中间件。技术决策一旦缺少解释过程,后面落地时基本都会遇到阻力。我自己的原则是,宁可多花时间对齐认知,也不要让团队带着疑惑去写代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 初始阶段:一套代码一台机器跑天下的日子
2.1 单体应用加单库的典型形态
几乎所有项目的起点都是同一个样子。一个小团队,一个版本仓库,一个应用服务,一个数据库实例,部署在一台服务器上,前面再挂一个Nginx。应用内部可能分了很多模块,用户、订单、商品、支付,但这些模块都跑在同一个进程里,共用同一个数据库连接池。我见过很多早期项目连服务器配置都很随意,4核8G的机器上跑着一个Spring Boot应用加一个MySQL实例,照样能支撑几千个用户的日常使用。
这个阶段最大的好处是效率。代码调试方便,日志集中,事务好保证,部署也简单,发布一个包就完事。而且因为所有功能都在一个进程里,排查问题时不需要链路追踪,不需要跨服务查日志,几乎凭经验就能定位。所以我不建议任何人一上来就把系统拆得稀碎。单体不是耻辱,它是业务探索期的最优解。尤其当一个项目的业务逻辑还在频繁调整时,单体应用能让你以最低成本试错。
这里有一个很多人忽略的细节:即便在单体阶段,代码结构也需要保持清晰的分层。用到的核心关键词是“架构演进”,但演进不是说以后代码结构会自动变好。如果早期因为偷懒把业务逻辑全写在Controller里,后面做应用拆分的时候就要付出巨大的重构成本。我自己在给团队做技术评审时,最常强调的一句话就是:你现在写的每一行代码,都会影响未来架构演进的难度。
2.2 第一次拆分:把应用和数据库分开
单体应用跑了一段时间后,第一个明显的瓶颈很快就会暴露。应用和数据库在同一台机器上,CPU被应用的计算逻辑消耗,磁盘IO被数据库查询消耗,内存被两边同时竞争。更麻烦的是,应用偶发Full GC或者数据库有慢查询,都会互相拖累,导致你排查问题时完全分不清卡顿的根源到底在哪。
所以绝大多数系统的第一次架构演进,并不是上什么高级组件,而是做一件非常朴素的事:把应用和数据库部署到两台不同的机器上。这一步看似简单,却能立刻带来几个立竿见影的效果。首先是资源隔离,数据库不再被应用的计算逻辑拖累,磁盘IO和内存都更稳定。其次是应用可以水平扩容了,因为数据库独立出去之后,应用层是无状态的,多起几个实例完全没问题。最后是故障隔离,数据库慢查询不会再直接拖垮应用的响应速度。
但这个阶段也会暴露出新的问题:应用和数据库之间的网络交互变多了,每一次查询都是远程调用,于是连接管理、超时配置、连接池大小这些以前不太需要关心的参数,开始变得重要起来。我曾经因为在初期阶段没有设置合理的数据库连接池上限,导致高峰期应用线程全部阻塞在等待连接上,整个服务假死。这个教训让我明白,架构演进的每一步,都会把新的复杂性带进来。
2.3 单库的容量天花板和瓶颈信号
应用和数据库分离,数据库仍然是单独的实例,它终有一天会撞上单机的性能天花板。一台普通的MySQL单实例服务器,抛开复杂的业务场景不谈,简单查询的QPS支撑到一两千可能就很吃力了,写操作的QPS上限还要更低。连接数默认配置是151,虽然可以调大,但连接数越多,线程切换和锁竞争就越严重。
要判断单库是不是已经到达瓶颈,我会重点盯几类信号。第一类是数据库CPU持续处于高位,比如稳定超过70%到80%,说明计算资源已经吃紧。第二类是慢查询数量明显上升,一排 slow log 密密麻麻,说明SQL执行耗时越来越长,索引和优化已经很难兜住数据量增长。第三类是连接数经常打满,应用频繁报错“Too many connections”,这说明数据库的处理能力已经跟不上并发请求了。第四类是主从延迟,等到你发现数据库已经不得不加从库时,其实已经算是进入下一阶段了。
还有一点很容易被忽略:磁盘容量。即使CPU和连接数看起来还行,如果单表数据量已经达到了千万甚至亿级别,一次全表扫描的代价会变得非常大。很多团队就是在这个阶段被一个简单的列表查询卡死的,加索引都救不回来。这种时候,单库的结构已经很难支撑业务的发展,读写分离或者数据归档必须提上日程了。
3. 读写分离为什么是第一道分水岭
3.1 读多写少:绝大多数业务的默认画像
在做架构设计之前,应该先把自己的业务流量画像搞清楚。我接触过的绝大部分业务,读操作和写操作的占比是非常悬殊的。内容平台是典型的读多写少,用户一分钟可能刷几十次信息流,但是点“发布”的次数少得可怜;电商系统也一样,用户逛商品详情页的请求量远远大于下单和支付的请求量;即使是后台管理类系统,报表查询和列表查看也是绝对的主力。
通常来说,读请求占比能达到80%、90%,夸张一点可以达到95%以上。这种流量结构意味着一个非常直观的问题:单库架构下,所有的读请求和写请求都在抢占同一个实例的资源。哪怕你只在做几个简单的查询,并发一多,照样会把数据库连接池耗尽,连带着写操作也跟着变慢。更不合理的是,写请求可能只占了10%,却被那90%的读请求拖着遭殃。
所以读写分离的本质,不是把一个数据库拆成两个,而是把流量按照“读”和“写”两种完全不同的特征分开对待。写请求聚焦在主库上,读请求可以被多个从库分担。这样主库的压力能够保持稳定,从库则可以随时通过扩容来提升整体的读能力。
3.2 缓存先上场,为什么还是挡不住读压力
很多团队在引入读写分离之前,都会先试缓存。缓存确实很有效,对于热点数据,一次缓存命中能省去一次数据库查询,响应时间从几十毫秒降到几毫秒。但缓存有一个天然的限制:它不是全量数据的解决方案。
缓存适合的是“热点数据”,比如热门的商品、高流量的首页内容。但业务里的读请求往往是非常分散的,用户可能查任意一个商品、任意一张订单、任意一个搜索结果。当请求的数据在缓存里没有命中时,请求还是会穿透到数据库。一旦缓存命中率不够高,或者遇到活动期间大量数据在短时间内失效,就很容易引发缓存穿透、缓存雪崩、缓存击穿这一类问题。更要命的是,缓存与数据库的一致性维护成本并不低,很多团队在这里踩过坑。
那为什么不直接用缓存替代读写分离呢?因为二者解决的问题维度不同。缓存解决的是“单条数据被频繁读取”的问题,读写分离解决的是“数据库整体读能力不足”的问题。它们可以同时存在,而且在实际架构中通常是并存关系:先让缓存挡住大量热点读,再把剩下的读流量分散到多个从库上,主库的压力才能真正确保安全。
3.3 什么时候正式上读写分离
读写分离不是越早做越好,因为它会引入主从复制延迟、数据一致性、路由切换这一系列复杂度。我的判断标准通常包含下面几个条件。
第一,监控数据显示主库的读压力已经持续偏高。比如数据库CPU在业务高峰期长期超过60%,慢查询数量明显增加,连接数频繁告警。第二,已经尝试过SQL优化、索引优化、缓存兜底,但依然不能有效降低主库压力。第三,从业务上能够接受短暂的从库读延迟。如果业务要求任何时刻读到的数据和刚写入的数据严格一致,那读写分离会做得非常痛苦。第四,团队有基本的运维能力,能够处理主从复制异常、从库延迟这些常见问题。
如果这几点都满足,那就别犹豫了,读写分离是性价比极高的演进方案。相比分库分表,它不需要改动表结构,不需要重新设计数据分布,大部分改造集中在数据源路由和SQL访问方式上,整体风险可控得多。
4. 主从复制:读写分离的地基不能稀里糊涂
4.1 MySQL 主从复制的工作链路
读写分离的前提是主从复制,你得先有几台数据库实例,数据能在主库和从库之间保持同步。MySQL的主从复制核心逻辑可以简化成三步:主库产生binlog,从库拉取binlog,从库重放binlog。
具体来看,主库上所有改变数据的事务提交时,都会写入binlog日志。从库启动后,会有一个IO线程主动连接到主库,请求从指定位置开始的binlog,并把拉取到的日志暂存到本地的relay log(中继日志)里。接下来,从库上的SQL线程会读取relay log,把里面的SQL事件逐个应用到自己本地,从而实现数据同步。整个过程对业务代码是透明的,应用只感知到多了一个或多个数据源,不需要关心数据是怎么复制过去的。
这里有个关键配置需要留意:binlog的格式。MySQL支持STATEMENT、ROW和MIXED三种格式。早期用的较多的是STATEMENT,它记录的是原始SQL,日志量小,但某些函数和不确定操作会导致主从数据不一致。现在生产环境我更推荐使用ROW格式,它记录的是每一行数据的实际变更,复制一致性更好。缺点是日志量会大一些,但这在大容量磁盘时代完全不是问题。很多主从数据不一致的问题,根源就出在binlog格式选择不当上。
4.2 复制延迟的来源与排查
主从复制不是瞬时的,从主库提交事务到数据在从库可见,中间会有延迟。正常情况下,内网环境下主从延迟可以控制在毫秒级别,但一旦延迟明显上升,读写分离就会出问题。
延迟产生的原因非常多,最常见的一个是主库上的大事务。一次更新几十万行数据的操作,产生的binlog量很大,从库重放需要很长的时间,这期间所有依赖该事务数据的读请求都会读到旧版本。另外,从库的重放能力也是个瓶颈。早期的MySQL复制是单线程回放,主库写入并发一大,从库很容易跟不上。现在虽然有了并行复制,但对事务之间的依赖还是有限制,并非所有场景都能线性加速。
还有一个经常被忽略的延迟源是DDL操作。比如在千万级别的表上执行ALTER TABLE修改列,虽然现在很多版本支持在线DDL,但产生的binlog事件依然会让从库压力陡增。如果主库上有大批量的数据清理、历史数据归档任务,也很容易造成延迟。排查延迟问题时,我一般会先看Seconds_Behind_Master这个指标,再结合从库的IO线程和SQL线程状态,判断延迟卡在拉取还是卡在回放,然后针对性处理。
4.3 一致性取舍:你没有多少选择
谈到主从复制,就必须面对一个绕不开的问题:一致性。MySQL默认的异步复制下,主库提交事务成功即返回成功,从库是否能及时拿到binlog并完成应用,主库完全不关心。这意味着,极端情况下,主库刚写完的数据,从库可能还没同步,读请求落到从库上就会读到旧数据。
对于完全不能容忍这种“从库读到旧数据”的业务,你只有两个选择:要么强制这些请求走主库,要么使用半同步复制。半同步复制(semi-sync)的意思是,主库在提交事务后,要等待至少一个从库确认已经接收到binlog,才向客户端返回成功。这能大幅降低数据丢失的概率,也能让从库大概率已经拿到日志,但对写入性能有一定影响,而且它只保证从库收到了日志,并不保证从库已经重放完成。也就是说,半同步能解决“丢数据”的问题,但解决不了“延迟可见”的问题。
在做一致性取舍时,我建议大家先跟业务对齐容忍度,而不是技术上一刀切强一致。绝大多数业务场景里,用户刚提交的内容自己马上能看见,和别人的查询隔几秒才能看见,这两类需求完全不一样。前者可以路由到主库,后者可以放心走从库。架构设计不是追求理论上的完美,而是在业务可接受的前提下找到成本最低的方案。
5. 读写分离落地的几个关键决策点
5.1 数据源路由:中间件、客户端还是自研
主从复制就绪之后,接下来要解决的核心问题是:一条SQL来了,怎么决定它走主库还是走从库。数据源路由有几种常见实现方式,各有各的适用场景。
第一类是中间件代理模式,代表有早期很火的MyCat,以及ShardingSphere-Proxy。这类方案是在应用和数据库之间加一层独立服务,应用连接中间件,中间件解析SQL并转发到后端的实际数据库节点。好处是应用几乎无感知,不需要改动代码里的数据源配置,对多语言应用尤其友好。坏处是引入了一个新组件,需要额外部署和维护,SQL转发会有一定的性能损耗,而且中间件本身会成为新的单点,还要考虑它的高可用。
第二类是客户端模式的ShardingSphere-JDBC。它把路由逻辑以jar包形式集成到应用内部,应用配置多个数据源,由ShardingSphere在框架层完成路由。好处是性能损耗小、部署简单,功能也很丰富,支持读写分离、分库分表、分布式事务等。坏处是只对Java生态友好,而且改造有一定侵入性,升级版本时还需要回归测试。
第三类是自己写路由。用一个动态数据源,例如基于Spring的AbstractRoutingDataSource,在进入DAO层之前根据方法名或者注解标记来决定使用主库还是从库连接。优点是轻量、完全可控,不用引入额外依赖;缺点是路由规则得自己维护,功能相对简陋,后续如果要做分片会比较吃力。
我的建议是,如果团队规模不大,主从节点数量少,先做一个简单的自研路由就够用。等到节点数量变多、分库分表成为刚需时,再考虑引入成熟的中间件或客户端框架,避免一步到位造成不必要的运维负担。
5.2 事务和读写分离的冲突处理
读写分离落地中最容易踩的坑,是事务内读写不一致。想象一个场景:一个方法开启了数据库事务,先往订单表里插入了一条记录,然后在同一个事务里查询这张订单表。如果系统按“读请求走从库”的规则,把这条查询路由到了从库,那极有可能查不到刚插入的数据,因为在同一个事务里,主库的写入还没有来得及复制到从库。
这会造成非常诡异的线上问题,比如“保存成功但列表里没有”“下单成功但订单查询不到”。解决这一类问题,最核心的原则是:一旦进入事务,就必须走主库。因为在Spring这类框架的管理下,一个事务内的所有操作应当共享同一个数据库连接,这样才符合事务的隔离性和一致性语义。通常的做法是,在读写分离路由判断时,检测到当前线程已经存在一个活跃事务,就强制使用主库数据源,而不是按读方法默认路由到从库。
还有一个容易忽略的细节是只读事务。很多团队会给查询类方法加上@Transactional(readOnly = true),本意是提示数据库走只读,但在读写分离环境中,这个注解本身并不会阻止事务开启。只要事务开启,连接就会被绑定,后续在同一事务内的所有查询都应当走主库。如果你用的是自研路由,需要特别注意这一点,不要被“只读”两个字误导。
5.3 延迟敏感场景的绕行手段
即使非事务的普通读请求,也会遇到“写后立即读”的延迟问题。最典型的场景是用户注册成功后,马上跳转到登录页登录;或者是用户发了一篇文章,立刻回跳详情页;再比如支付成功后立即查询订单状态。这类请求一旦命中尚未完成复制的从库,就会给用户一种“操作失败”的错觉,用户体验非常差。
针对这类场景,我通常有几种处理手段。第一种最简单粗暴:对延迟敏感的接口强制走主库查询。哪怕大部分情况下走主库会稍微增加主库压力,但这类接口的占比通常不高,压力完全可控。第二种是前端做延迟策略,比如提交成功后跳转页面时加入一个短暂的等待或重试逻辑,给复制留出时间窗口,但这个方法体验上不是最优。第三种是使用缓存标记,写操作完成后把一个最新状态的标识写入缓存,读请求先查缓存确认状态,如果发现数据可能不是最新,再回源到主库查询。
实际项目中,我更喜欢把第一种和第三种结合使用:接口层面设置一个白名单,白名单内的读操作全部路由到主库;非白名单的查询继续走从库。这样既保证了核心链路的准确性,又不至于让所有读流量都压在主库上。
5.4 一次线上“注册成功却登录不了”的复盘
分享一个我自己遇到的真实线上问题。有段时间系统经常收到用户反馈,说注册流程提示成功后,点击登录却提示“用户不存在”。刚开始开发团队很困惑,因为数据库里明明已经有数据了。后来排查发现,问题就出在读写分离的延迟上。
注册接口做的事情是:往用户表插入一条新记录,返回注册成功。登录接口根据用户名和密码去查询用户表,在当时的代码里,登录查询走了从库。由于主从复制存在延迟,用户注册完成后的那一两秒内,从库还没有同步到这条新记录,于是一查就是“用户不存在”。用户再刷新几次,数据同步完成,用户又能登录了,但绝大多数用户不会等那么久,直接就流失了。
这类问题的根本原因就是事务写和普通读被拆分到了不同的数据源。修复方案很简单,把注册成功后的登录校验强制路由到主库。但这件事给我最大的启发是,读写分离不是简单的“读写分流”,你必须识别出哪些读操作在业务语义上其实强依赖于最近的写入。与其在出现线上问题后加班排查,不如在改造设计阶段就把这个链路梳理清楚。
6. 读写分离之后:演进的路还长着
6.1 读写分离解决的和没解决的
读写分离最直接的价值是:主库的压力大幅降低,读能力可以通过增加从库横向扩展。尤其是读多写少的业务,这套结构可以用很长时间。但从架构演进的角度看,它并没有解决所有问题,只是把问题的焦点转移了。
首先,主库的写能力仍然存在上限。读写分离只把读压力分摊出去了,所有写请求依然集中在主库上。如果业务进入高速增长期,写请求也跟着快速上涨,主库迟早会再次成为瓶颈。其次,数据量增长带来的单表过大问题依然存在。读写分离不会减少数据量,当单表达到亿级规模时,即使读已经分流,一些复杂的查询依然会因为索引层级和扫描代价而变慢。再次,主库会成为新的单点。从库只负责读,一旦主库发生故障,所有写操作都会立即中断,这时候如果没有完善的故障转移机制,整个系统就瘫痪了。
所以判断一个架构方案的价值,不能只看它解决了什么,还要看它把新的风险放在了哪里。读写分离把风险从“数据库撑不住所有请求”转移到了“主库故障会影响写入”和“复制延迟会影响一致性”,这两块需要架构师持续关注。
6.2 高可用与故障转移
读写分离落地之后,紧接着要考虑的就是主库的高可用问题。只做主从复制而不做自动故障转移,那主库宕机时,整个系统仍然处于不可写状态。手动切换从库为主库虽然可行,但操作过程需要人工介入,故障时间往往以分钟甚至小时计算,对在线业务来说代价太高。
常见的做法是引入高可用管理组件,比如MySQL场景下可以用MHA、Orchestrator,或者NewSQL数据库自带的高可用方案。它们做的事情本质上是一样的:监控主库健康状态,一旦发现主库不可用,自动选择一个数据最新的从库提升为新主库,并通知业务应用重新路由。这里面有很多细节需要处理,例如从库数据的完整性校验、旧主库恢复后如何处理、虚拟IP如何切换或注册中心如何更新。
不过要提醒的是,高可用方案的复杂度远高于读写分离本身,它需要团队有足够的数据库运维能力和演练经验。我见过很多团队把高可用组件搭起来了,但从来没有做过故障演练,真出问题时反而因为切换逻辑不熟练而故障时间更长。架构演进到这一步,技术和运维必须同步跟上,否则漂亮的设计图也撑不起真实的故障场景。
6.3 演进节奏的控制与体会
最后想聊聊演进节奏。很多技术人容易陷入一种惯性:看到别人分了库、分了表、上了微服务,就觉得自己系统也该这么做。但实际上,架构演进没有标准答案,只有基于自身业务规模的答案。读写分离之后,如果业务发展依然迅猛,下一步也不一定就是分库分表。你可以先评估多级缓存、消息队列削峰、数据归档、垂直拆分等方向,哪一个能解决当前最痛的问题,就先做哪一个。
我自己的体会是,每一次演进都要有明确的业务驱动和可量化的目标。比如“这轮优化之后,希望数据库CPU从70%降到30%以下”“希望读QPS支撑能力提升到原来的三倍”,有这种目标,推进过程才不会乱。演进完成后还要收集数据做验证,如果结果不达预期,要及时回退或调整,而不是硬着头皮将错就错。架构演进本身就是个持续迭代的过程,不要追求一步到位,也不要因为某个技术热门就强行引入。控制住节奏,比做出炫技的方案更重要。
最后再分享一个实操习惯。我在团队里一直提倡,做任何架构改造前,先把当前系统的核心指标梳理成一张“能力基线”,记录下QPS、响应时间、数据库负载、连接数、慢查询数等数据。改造完成后,拿着新数据对比基线,你才能准确说出这次演进到底带来了多少收益。这种用数据说话的做事方式,会让你的架构设计少很多争议,也更容易获得团队和业务侧的信任。
