ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

预算约束下的技术选型:从业务流量模型推导架构形态

预算约束下的技术选型:从业务流量模型推导架构形态

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的云主机,单机就完全能够扛住这个流量。

那老系统为什么卡?因为代码层面的低效——N+1查询、缺少索引、报表实时计算、没有缓存、数据库连接池配置不合理等等。这些问题和微服务、分布式没有关系,它就是纯粹的代码质量问题和架构设计问题。

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个人、两个人还是新人的团队里,强制上微服务、上领域驱动设计、上各种“最佳实践大全套”,结果多半是项目拖期、代码更难维护,团队被耗得筋疲力尽。

做项目这么多年,我越来越觉得架构设计本质上是一门“妥协的艺术”。每个决策都不是在“最好”和“次好”之间选择,而是在“当前最合适”和“看起来更先进”之间选择。守住这条逻辑链——从业务需求推导流量模型,从流量模型推导架构形态,从成本约束推导技术取舍,从团队能力推导实现路径——即使最终方案谈不上惊艳,它也能在预算范围内平稳落地,并且在未来两三年内不会成为团队的负担。

这对我来说,就是一次成功的技术选型。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进