
1. 项目代号“bazhong”一次被预算逼出来的架构复盘先交代一下背景。我所在的小团队接了一个内部系统的重构项目代号“bazhong”目标是替换一套已经跑了五年的老单体应用。这套老系统承担着公司内部的任务管理、工时填报、项目进度跟踪、报表汇总等一堆杂活随着业务膨胀代码里已经堆了不少“一次性逻辑”每次加需求都要在几个模块之间来回打补丁维护成本肉眼可见地上升。需求方给我们的预期是新系统要能支撑未来三年的业务增长包括人员规模翻倍、项目并行数量翻倍、跨部门协同频率增加同时要兼容老系统的历史数据切换过程不能影响正常业务。听起来很常规对吧真正麻烦的是预算约束——老板明确说了今年没有额外采购服务器的预算也没有招聘新人的名额团队还是原来那六个人其中还有两个是刚毕业一两年、主要写业务代码的初级开发。所以在项目一开始我们面对的就不是一个“选什么技术栈最好”的问题而是一个“在给定资源下怎么把系统做出来、并且保证它不会在两年后再次腐烂”的问题。这次复盘我想聊的不是某个中间件怎么用、某个框架怎么配而是整个决策链条——从业务流量模型推导架构形态从成本约束反推技术取舍从团队规模反推实现方式把每一步为什么这么做、为什么不做另一个选项都摊开来讲清楚。我先把结论放在前面所谓“技术选型”真正比拼的从来不是你用了多时髦的框架、搭了多少个微服务、引入了多少种中间件而是你对业务的理解深度和对成本的控制能力。后面我会用这次“bazhong”项目里几个具体的决策过程来说明这句话。2. 先别聊架构把业务流量模型算清楚再说很多技术人做系统重构上来就画架构图、定技术栈这是最容易翻车的开场方式。架构是服务于业务的业务长什么样、流量怎么走、数据怎么流转这些没搞清楚之前任何技术选型都是空中楼阁。所以在“bazhong”项目启动的第一周我们做的主要工作不是写代码而是梳理业务模型和流量特征。2.1 老系统的流量画像真实的数字往往和直觉相反我们把老系统近一年的日志翻了出来统计了几个核心数据日均活跃用户数、峰值并发请求数、核心接口的调用频次、数据表的增长量。结果挺有意思——这个被大家抱怨“卡得要死”的系统真实的QPS每秒查询数其实低得可怜。日均活跃用户大概在300人左右峰值时段集中在上午9点到11点和下午2点到4点这两个时段通常是大家上班打卡后集中填报工时、更新任务状态的时间。即便算上峰值系统的整体QPS也就在20到50之间大多数核心接口的调用频率是每分钟几次到几十次。真正消耗资源的是几个报表查询接口——它们要跨多张表做聚合老系统又没有做缓存每次查询都会触发全表扫描单次响应时间有时候能到十几秒。这个画像和我们头脑中的“系统很慢是因为并发太高”完全不同。系统慢的根因不是用户量大、并发高而是几个慢查询把数据库拖垮了同时又影响了其他正常请求的响应。换句话说即便我们什么都不改直接把那几个报表接口优化一下用户体验也能提升一大截。2.2 未来三年的数据推演增长模型决定架构形态光看现状还不够还要往前看。我们和业务方反复确认了未来三年的预期人员规模从300人增长到600人左右项目数量也会相应增长跨部门协同的需求会增加但总体来看这仍然是一个“企业级内部工具”的量级而不是一个面向C端用户的互联网产品。我们做了一个简单的测算即便人员翻倍、使用频率提高50%系统在峰值时段的QPS大约也就是100左右。这个数字意味着什么意味着我们根本不需要复杂的微服务架构、不需要消息队列削峰、不需要分布式缓存集群。一台配置还不错的物理服务器甚至一台8核16G的云主机单机就完全能够扛住这个流量。那老系统为什么卡因为代码层面的低效——N1查询、缺少索引、报表实时计算、没有缓存、数据库连接池配置不合理等等。这些问题和微服务、分布式没有关系它就是纯粹的代码质量问题和架构设计问题。2.3 业务模型推导出的第一项约束单体优先基于上面的流量模型我们得出了第一个架构结论继续采用单体架构但要做模块化拆分。很多人一听到“重构”就条件反射地想到微服务但对于我们这个场景微服务带来的分布式事务、服务间调用、部署运维复杂度远远超过了它能解决的问题。这就像你要开一家社区小饭馆结果花重金请了一套中央厨房的供应链体系——也许未来开连锁店用得上但眼下只会把厨房搞得又贵又乱。单体架构配合清晰的模块边界足够支撑300到600人的内部系统。关键在于把“模块化”做好——同一个应用进程里按照业务域划分出任务管理、工时填报、项目跟踪、报表统计、组织权限等几个模块模块之间通过内部的接口交互不允许跨模块直接访问对方的数据表。这样既保留了单体的简单性也为将来某个模块确实需要独立拆分时留好了边界。2.4 报表模块的独立演进从“拖垮全库”到“独立战场”整个系统里唯一需要特殊对待的就是报表统计这个模块。老系统的报表接口每一次都在做全表聚合数据量一大数据库CPU直接飙升。这次我们把它单独拿出来做了特殊设计——报表数据不直接查业务库而是通过一个轻量级的异步任务定期把需要统计的原始数据同步到独立的统计库中报表模块只读统计库。这个方案不需要引入大数据组件也不需要引入消息队列只是在业务数据变更后通过应用内的异步任务把增量数据同步过去。我们用了一张增量同步表记录每个业务表的变更事件一个简单的定时任务每30秒扫一次把变更数据同步到统计库。报表查询走独立的只读连接彻底隔离了对业务库的性能冲击。回过头来看这个决策如果我们一开始盲目引入“报表数据实时同步 大数据分析平台”那成本和复杂度会成倍增加而收益几乎为零——因为我们根本不需要实时分析只需要每天早晨给管理层出一份前一天的汇总报表。3. 成本约束反推技术取舍数据库选型和缓存策略的真实考量预算有限、不能采购新服务器直接影响最大的就是技术选型。我们原本考虑了几套方案包括引入一套分布式数据库、上一组Redis集群、把文件存储迁移到对象存储等等。每一件事单独看起来都合理但把预算和运维成本摊开算一遍之后该砍的砍该降级的降级最后留下来的解决方案比预想的朴素得多。3.1 为什么最终选择单机PostgreSQL而不是分布式数据库第一轮讨论时有成员提议把数据层换成分布式数据库理由是“以后数据量大了不用愁”。我让他先算一下账老系统五年累积的数据量包括任务记录、工时记录、日志流水等加在一起大约40G。按照前面推演的未来三年增长最多也就不到200G。200G对于单机数据库来说完全不是压力PostgreSQL、MySQL都处理过多少个TB级别的数据了分布式数据库能带来什么水平扩展能力、高可用架构、跨机房的容错。但我们不需要水平扩展——单机就够跑我们也不追求那种每秒几万次写入的吞吐——一个内部工具一天的总写入量可能还不到几万条高可用当然好但预算不允许再买一台机器做备库硬要做的话只能在应用层做补偿成本反而更高。于是我们决定继续使用PostgreSQL但做两件以前没做的事一是把所有核心表的数据量、索引使用情况、慢查询记录全部梳理一遍建立规范的索引体系二是把大字段比如任务描述、附件元信息拆到单独的表里避免它们撑大主表的行宽。单机数据库的“天花板”远比很多人想象得高关键是你有没有把索引和查询设计好。3.2 缓存中间件被砍掉的完整逻辑Redis也被讨论了好几轮。需求之一是用户打开任务列表时希望能快速看到自己参与的任务状态而不是等一两秒。有成员提议引入Redis缓存热数据把任务列表、用户信息、配置数据都放进去。听起来很合理但我问了一个问题如果我们把任务的实时性要求降一档能不能在不引入Redis的情况下达到类似效果分析下来是可以的。任务列表的热点数据是“当前用户参与的任务通常是几个到十几个”和“项目的基本信息项目名、状态、负责人”这类数据有个特点——单条数据量小、变化不频繁、读取频率相对高。我们用PostgreSQL自身的查询优化就能解决大部分问题给外键列建索引、把常用查询写成覆盖索引能命中的形式、应用层做简单的本地内存缓存比如Guava Cache或者Caffeine设置30秒到5分钟的过期时间。完全不需要额外的中间件。那为什么很多团队会在这个场景里坚持上Redis因为“用中间件”本身成了一种惯性而不是因为业务真的需要。本地内存缓存的缺点是什么多实例之间缓存数据不一致。但我们是单体应用默认就部署一个实例这个缺点根本不存在。后来又考虑到将来可能部署两个实例加一层简单的版本号失效机制也就够了成本远低于维护一套Redis。3.3 文件存储的大简化从对象存储回到本地磁盘老系统里有一个附件管理功能用户可以上传任务相关的文件。原来的实现是把文件存到服务器本地磁盘后来因为磁盘占用经常报警有同事提出要接对象存储。这个需求本身没错但对象存储每年几千块的费用摆在那里而且还要额外引入一套鉴权体系。我们又算了笔账所有附件加起来的存储量大约在200G左右公司里的服务器还有不少空闲磁盘空间。附件的特点是什么访问频率低、绝大多数文件只会在上传后的一两周内被反复查看之后几乎不再有人碰。所以我们做了一个低成本方案用Nginx直接代理本地磁盘目录作为静态文件服务同时加了一个简单的文件清理策略——超过6个月未被访问的文件自动迁移到冷备磁盘我们找了两块旧硬盘做了一个简单的冷存储需要时可以人工找回。这个方案几乎零成本运维复杂度也极低唯一的代价是文件存储量增长过快时需要人工介入清理。这个决策如果换个场景就会完全不同比如我们是面向C端用户的SaaS产品附件会大量生成且需要长期留存那对象存储就是合理选择。但“合理”的前提是匹配自己的业务形态和成本结构而不是因为它更“先进”。4. 团队规模反推实现方式老系统重构的路线图是怎么排出来的架构定了、存储定了接下来就是最考验团队能力的事情——怎么在只有6个人、其中2个是初级开发的情况下把这一套系统做出来并且平稳切换老系统这是我们这次复盘里最有价值的一段因为技术方案再漂亮落不了地就是零。4.1 按“业务域”而不是按“技术层”拆分迭代很多人做系统重构喜欢按技术层分任务A负责数据库设计B负责后端接口C负责前端页面。听上去分工明确实际上很容易造成“各管一段、谁也不清楚完整业务”。我们这次反着来按业务域分任务每个模块从数据库、后端接口到前端页面由同一两个人从头跟到尾。团队6个人分成了三个小组每组负责一个大模块。第1组负责任务管理和工时填报第2组负责项目跟踪和报表统计第3组负责组织权限和系统配置。每个组都要能独立回答这组业务的核心问题数据模型长什么样、有哪些状态流转、用户的操作路径是什么、哪些查询是高频率的。这样拆分的好处是每个人对自己负责的模块拥有完整的上下文不至于出现“前端改了接口不知道后端为什么这么设计”的割裂状态。坏处是组与组之间需要约定好模块之间的交互接口否则联调时会打架。为了减少冲突我们提前用一周时间把跨模块的核心流程场景全部列出来包括“创建任务 - 分配人员 - 填报工时 - 归档项目”这类全链路场景逐一确认每个环节由哪个模块负责。4.2 数据迁移的“双写”策略不让业务等我们老系统的数据迁移是另一个难点历史数据不能丢但新老系统并行期间两边数据都在变。我们设计的方案是“双写”新系统上线后新旧系统并行运行一段时间业务操作同时写入两个系统新系统提供的查询结果作为主数据源老系统作为备份和对照。这个方案对业务方来说比较透明不需要他们改变习惯只是在后台多了一个自动同步逻辑——业务人员在新系统里的操作会实时写入新库同时通过一个适配层把同样的操作转换成语义后写入老库。运行两周后我们比对了两个系统的数据针对不一致的地方逐条排查根因修复了同步逻辑中的几个边界问题。确定数据一致性和功能覆盖率达到预期后才逐步关停老系统的写入口最后在第三周切掉老系统。说起来轻松但“双写”阶段实际上是整个项目里最容易翻车的地方。最大的坑在于老系统里的一些“隐性逻辑”没有在新系统里体现——比如某个字段在老系统里看似无关紧要但实际上会影响另一个模块的状态判断。为了排查这类问题我们建立了一个“字段映射清单”把老系统所有核心表的字段逐一列出标明新系统里对应的位置、默认值、转换规则。这个清单看着枯燥却是数据迁移中最救命的东西。4.3 初级开发占比高那就用“约定”降低认知负担团队里有两位初级开发这个问题不能回避。我的处理思路是不指望他们理解所有底层原理而是通过“约定”把容易出错的地方提前堵住。比如模块之间的数据访问我们约定不允许直接操作其他模块的数据表必须走模块对外暴露的接口。这个约定写进代码规范里同时在数据库层面通过不同的PostgreSQL用户来隔离权限——每个模块连接数据库时使用独立的账号这个账号只能访问自己模块的表。这是技术手段强制保证边界比单纯靠团队成员的自觉可靠得多。再比如所有报表查询必须走只读统计库这个约定也通过数据库账号权限来落地——业务模块的账号根本没有统计库的连接权限报表模块的账号只有读权限。初级开发即便想写出跨模块直查表代码也会在数据库权限层面被拦下来。还有一个约定是关于异常处理的所有对外接口统一返回固定的响应结构错误码和错误信息集中定义在统一的类里不允许业务代码里到处抛出不同的错误原因。这样初级开发写代码时不需要思考“这个错误应该怎么表达”只需要从统一错误码表里挑一个合适的填上具体的错误信息即可。4.4 两周一次的“架构守护”评审拒绝腐烂的唯一办法最后说说我们是怎么防止新系统在两年后重新腐烂的。老系统的核心问题是没有边界、没有约定等到补丁越来越多谁也不敢动。新系统如果在开发过程中不设防线早晚会走上同样的路。我们设了一个轻量级的“架构守护”机制每两周一次集中评审每次只用一个下午由三个模块负责人轮流展示自己模块的代码变更重点检查三件事——是否出现绕过模块边界的调用、是否出现跨模块直接查询数据表、是否出现重复逻辑没有收敛到公共组件。这个机制不面向所有代码只面向容易坏的地方。我们宁愿每两周花半天时间守住边界也不愿等到一年后花一个月时间处理“模块间调用混乱”的历史遗留问题。实践证明初级开发的代码确实偶尔会试图“图方便”绕过边界但在评审中被拦下来几次之后他们就会养成先查约定再动手的习惯。5. 这套方案上线之后一些真实数据、残余问题与个人反思到这里“bazhong”项目从需求分析、架构选型到落地执行的全过程已经复盘得差不多了。最后一部分我想补充项目上线后的真实表现数据以及我们仍然没有解决、但暂时接受了的事情。5.1 上线三个月的实际表现把“快”和“稳”分开谈新系统上线后的第一个月我们统计了几个关键指标。老系统里最慢的报表查询接口优化后从平均8秒下降到1秒以内任务列表加载从老系统的平均2.5秒下降到0.3秒左右最让我意外的是数据库的CPU使用率峰值从老的60%以上降到了10%上下——这说明老系统的性能瓶颈主要被几个糟糕的查询撑爆了而不是数据库本身不够强。稳定性方面三个月内没有发生一次因为系统问题导致的业务中断。这个成绩不算是优秀的架构设计带来的更多是“简单”带来的——我们只有一台应用服务器、一套数据库、几乎没有无可替代的中间件故障面天然就小。用户反馈里也能看到变化投诉“系统慢”的声音基本消失了倒是出现了几条“统计报表里的数据和我们项目里的对不上”的疑问。排查之后发现都不是系统错误而是统计库的同步存在一定延迟用户看报表时恰好赶上同步窗口。我们把同步频率从30秒调整到15秒又给报表页面加了一个“数据更新于X分钟前”的提示这类疑问也就少了。5.2 残余问题知道会有但选择了先接受有残余问题吗有而且不止一个。比如附件存储在本地磁盘的方案虽然省了钱但未来如果附件总量超过1TB现在的清理策略就会显得人手不够——清理要人工触发没有人盯着冷备磁盘很快就会满。这个问题我们在方案评审时就知道之所以接受是因为按照当前的增长速度至少还要两年才会达到这个量级。两年后也许公司已经批了新服务器也许这个系统本身已经被更新的一套工具替代了。现在为不确定的未来投入确定的成本不划算。再比如单体应用虽然做了模块化拆分但如果公司未来组建了一个专门做报表分析的新团队想要独立演进报表模块现在的拆分还是不够彻底——报表模块和业务模块仍然在同一个进程里只能做到逻辑隔离做不到独立部署。我们当时的决策逻辑是为了“可能的”未来现在就要付出一倍的运维复杂度不值得。还有一个老生常谈的问题双写阶段结束后我们并没有立刻关闭老系统的实例而是让它在后台继续跑了一个月作为备份。这个“备份”本身也消耗人力——服务器要维护、数据每日比对要有人看、偶发的同步报警要处理。回头看其实双写运行三周后数据一致性已经稳定可以提前关闭。但当时是求稳心态占了上风多花了一个月的运维成本。5.3 复盘中最想分享的三条经验如果这些内容对看到这篇文章的人有一点参考价值我想那应该集中在以下三件事第一技术选型必须从业务流量模型出发。一个系统的架构是单体、微服务、分布式还是Serverless不是由技术潮流决定的而是由业务的用户规模、数据量、访问特征、增长预期共同决定的。拿我们的系统来说300人的内部工具单机就能跑微服务带来的额外复杂度只会让我们更早地陷入运维泥潭。第二所有技术取舍都要过一遍成本账。这听起来像废话但实际操作中我们总是倾向于看到新技术带来的“好处”而选择性忽略它的成本——学习成本、运维成本、故障排查成本、团队认知负载。每当有人提出一个新组件先问三个问题不引入它问题真的解决不了吗引入之后谁负责维护它半年后团队还有没有能讲清楚它内部原理的人三个问题一旦答不上来这个组件就不该进系统。第三团队的规模和能力结构应当在架构设计中占据一席之地。技术方案做得再完美如果团队里的人不具备维护它的能力那么这个方案就是失败的。我们说“简单优于复杂”很多时候不是因为复杂不好而是因为复杂带来的收益与成本不成正比。在一个只有6个人、两个人还是新人的团队里强制上微服务、上领域驱动设计、上各种“最佳实践大全套”结果多半是项目拖期、代码更难维护团队被耗得筋疲力尽。做项目这么多年我越来越觉得架构设计本质上是一门“妥协的艺术”。每个决策都不是在“最好”和“次好”之间选择而是在“当前最合适”和“看起来更先进”之间选择。守住这条逻辑链——从业务需求推导流量模型从流量模型推导架构形态从成本约束推导技术取舍从团队能力推导实现路径——即使最终方案谈不上惊艳它也能在预算范围内平稳落地并且在未来两三年内不会成为团队的负担。这对我来说就是一次成功的技术选型。