“王石应悔偷灵药”这句话,不是评价哪位公众人物,说的是我早年带项目时一位姓王的资深工程师(化名)。他在一次赶工里做了一个典型的技术决策,短期赢得了速度,后面却搭进去数倍时间还人情。这篇东西就是想借这个例子,把“捷径开发”到“技术债爆雷”的完整链条拆开讲讲。适合正在赶交付、又总在“快”和“稳”之间摇摆的开发者、技术负责人,也包括那些被架构规范困扰过的人。
先交代一下背景。那是一个模拟项目X的跨平台订单系统,不算复杂,但涉及订单查询、报表预览和权限控制。客户要求在七天内给出一个能演示的版本,还特别强调“我要看到点东西”。团队由三个后端、两个前端组成,排期已经压缩到极限。压力摆在那里,于是“灵药时刻”来了。
1. 场景还原:一个订单模块的“七天速通”陷阱
1.1 需求看起来并不复杂,时间却把人逼到墙角
模拟项目X的核心业务是订单管理,客户需要两个主要能力:一是按客户编号查历史订单,二是预览合同文档。按正常流程,后端需要设计统一的数据访问层、封装订单服务接口、做权限校验、选型可靠的文档解析组件,再写基础测试。这套流程算下来,通常需要三到四周,可当时只有七天。
客户催得紧,团队自然开始讨论“能不能先跑通”。这种讨论在行业里太常见了,尤其在演示驱动的项目里。大家默认目标是“先让他看到东西”,而不是“先让架构正确”。从商业角度讲,这种策略有一定道理,真正的问题出在执行方式上——为了快,直接跳过了所有护栏,而不是只跳过一部分。
1.2 那个一眼看上去很美的“灵药时刻”
排期会上,老王提了个方案:订单查询直接在控制器里拼SQL,先别走统一服务;合同预览用一个老的开源解析库,丢进项目就能用;报表模块直接查一张现成的副本表,省得join一堆订单明细。
听起来每一步都合理:拼SQL最直接,老库不用配环境,副本表查询性能快。没有人明确提出反对,因为所有人都知道按规矩来时间真不够。于是这个方案以“暂时的妥协”为名通过了。这里就埋下了之后所有问题的种子。
1.3 为什么当时看起来合理,这是技术债的心理起点
人很容易在压力下低估“后续修复成本”。老王当时的判断是:先跑通,等演示完了再回头补。这种想法很普遍,问题在于“回头补”很少有人真能排上日程。演示通过后,新需求立刻涌进来,没人再有空碰那些“临时代码”。
如果说技术债有一个公式,大概就是“决策当时的合理性 × 后续被遗忘的时间”。当时的合理性难反驳,但被遗忘的时间才是真正的复利。半年后,当这堆代码开始一个个爆雷时,所有人都把这句话想起来了:“早知道当初应该应悔偷灵药”——偷灵药的时候不觉有异,飞升之后才后悔莫及。
2. 三颗灵药的成分分析:看着甜美,吃下去有毒
2.1 第一颗:控制器里直接拼SQL,省了接口丢了边界
老王给出的第一个“快招”,是在Controller里直接写SQL。代码大概是这样:
@GetMapping("/order/list") public List<Order> listOrder(String customerId) { String sql = "select * from ORDER_INFO where CUSTOMER_ID = '" + customerId + "'"; return jdbcTemplate.query(sql, new OrderMapper()); }这段代码在演示的时候跑得很欢,因为数据量小、场景单一。但它至少有四个隐患。
第一个是SQL注入。customerId没有做任何参数化处理,传入一段特殊字符串就能改变查询逻辑。演示时没人攻击,上线后挂在公网就是裸奔。
第二个是索引失效风险。ORDER_INFO表在CUSTOMER_ID上有索引,但这种字符串拼接方式在特定字符编码或隐式转换场景下,走不进索引。数据量一涨,全表扫描就来了。
第三个是业务规则散落。订单查询原本有“已删除订单不展示”的规则,这个规则在统一服务里已经实现了。可老王直接跳过服务后,这段SQL只查了物理存在的记录,压根没过滤。同一个客户在A页面和B页面看到的单子不一样,客户当场就翻脸了。
第四个是权限收口失效。架构里设计的数据权限过滤器,只对统一入口生效。绕过入口后,任何持有接口地址的人都可能查到越权数据,这在合规上是要出大事的。
用一句生活类比解释就是:装修时直接从燃气总管接了一根软管进厨房,省掉了申报和验收,燃气灶确实能点火,但整栋楼的人都在替你冒风险。
正确做法很简单,把查询挪回服务层,参数化SQL或者干脆走封好的查询对象:
@GetMapping("/order/list") public List<Order> listOrder(String customerId) { return orderService.queryByCustomerId(customerId); }封装一层不过是多写几行代码,但它把规则、权限、监控收口到了一处,后续维护的人不用满世界找SQL。
2.2 第二颗:绕过统一服务,直撬备份表,一致性当场失守
原设计里有一个orderService,所有订单状态变更都要通过它,保证缓存、消息、数据库三者联动。老王贪图快,在报表模块里另开了一条链路,直接查询一张用于统计的副本表。
这张副本表是定时同步生成的,设计用途是“供数仓抽取”,本来就没承诺实时准确。报表模块复用后,在同步间隙里,用户看到的订单数和订单详情对不上。线下测试的时候不容易暴露,因为测试环境同步频率高、数据量小。到了演示环境,数据更新频繁,问题就显形了。
当场出现的情况是:客户在订单列表页看到7笔订单,点进统计报表却显示5笔,因为同步任务刚好在延迟窗口。全场哑口无言。这比功能缺陷狠多了,它直接瓦解了客户对系统的信任。
这里我整理了一张对比表,把正规实现和“抄近道”的实现摆在台面上:
| 维度 | 统一服务方案 | 老王直查副本表方案 |
|---|---|---|
| 数据一致性 | 实时,强一致 | 定时同步,弱一致 |
| 权限控制 | 统一收口 | 绕过,存在越权风险 |
| 维护成本 | 一人维护即可 | 多段逻辑各管各的 |
| 出问题定位 | 看日志链路清晰 | 排查半天才发现不在主链路 |
| 短期速度 | 慢半天 | 快两小时 |
做技术选型有一个原则:读路径和写路径一样,都要守住一致性边界。副本表不是不能用,而是要在架构设计时明确它是“准实时分析数据”,不能直接提供给面向客户的在线查询。这个决策一旦拍板,脏数据就不是意外,而是必然。
2.3 第三颗:第三方库不带刹车,把守卫扔出城墙
合同预览功能需要解析PDF,老王图省事,直接下载了一个多年前就不再更新的开源解析库,把jar包扔进lib目录,[]然后完事。没有查许可证、没有做版本锁定、没有压测,也没有确认它是否支持流式解析。
上线初期看起来很平稳,直到某天有个同事上传了一份超过两百页、带高清图片的合同文件,服务内存直接飙升,紧接着OOM。查看堆栈才看到,那个老库在解析PDF时会把整个文件结构一次性加载进内存,完全没有流式处理能力。
依赖管理不是小事。任何一个外部组件,都应该带着三个问题入场:“这个库还有人维护吗?”“它的内存模型和我的部署环境匹配吗?”“它的许可证允许商业闭源使用吗?”标准做法是用构建工具统一管理依赖,并锁版本:
<dependency> <groupId>org.example</groupId> <artifactId>pdf-previewer</artifactId> <version>2.1.0</version> </dependency>版本号一旦定下来,升级就变成一次有意识的动作,而不是某天jar包神秘消失。更稳妥的做法是先写一个独立的解析服务,把第三方库隔离在业务进程之外。就算它炸了,也只炸一个边角料组件,不会波及主应用。
3. 应悔时刻:三轮线上故障的现场复盘
3.1 第一次翻车:演示现场数据对不上,客户当面质疑
事后看,这轮故障属于“必然中的偶然”。项目上线第三周,客户要求核对订单数据,运营同事从报表导出Excel,发现和系统里的搜索记录对不上。运营用的是报表模块,我们查的是订单列表。由于老王直插副本表,两份数据差了整整12笔订单。
定位过程持续了三个小时。一开始怀疑缓存、怀疑事务、怀疑消息队列异步延迟,查了一圈都不对。最后翻代码,才发现报表模块根本走的另一条查询源。这个问题暴露的是一个结构性问题:系统的真实数据路径已经没人能说清了。
排查教训:出问题先看调用链,不要凭感觉猜。如果有全链路追踪系统,第一步就该拉出接口的完整SQL和执行路径。可惜当时项目里没有,连数据库慢日志都没开,纯靠人工一行行看,效率极低。
3.2 第二次翻车:一份PDF让整个服务死了九分钟
文档预览功能是客户最引以为傲的“展示亮点”,结果在高峰时段,一位用户上传了一份80MB的合同扫描件,老库解析时直接撑爆了JVM堆。
我们紧急重启了两次,可只要有人点开同一份文件,内存曲线就又直线上升。最后dump出堆内存,分析发现是第三方库在解析PDF的图片流时,生成了一堆中间缓冲对象,没有任何阈值控制。
修复分两步。第一步先加文件大小限制,超过20MB直接拒绝预览,先把线上稳住:
if (file.getSize() > 20 * 1024 * 1024) { throw new PreviewException("文件过大,暂不支持预览"); }第二步才是换掉解析库。这里又要花时间,因为新库的API完全不同,意味着重新适配。如果在引入原库时做过技术选型评审,这一步本来可以完全避免。
3.3 第三次翻车:想升级数据库却找不到改动点
项目上线半年后,客户要求更换数据库版本以解决性能瓶颈。数据库运维同事列了一个改动计划,要求所有涉及数据源的地方都确认兼容性。结果一排查,发现系统中直接裸写的SQL有37处,绕过统一服务的数据访问点有9处。
意味着什么?数据库版本升级不能通过改一处配置完成,而是要把这46个点全部翻出来验证。有些SQL关联了四张表,需要专门建索引才能跑。整个升级工作量从预估的2天变成了6天,而且测试期间还发现两个点漏改,导致查询报错。
这一轮下来,团队达成了一个共识:走捷径省下的时间,一定会在另一个环节连本带利还回去。所谓“应悔偷灵药”,悔的不是当时的需求不合理,而是不该把临时方案当成长久答案。
3.4 把故障经验汇总成一张速查表
| 症状 | 根因 | 快速定位手段 | 补救措施 |
|---|---|---|---|
| 数据对不上 | 绕过统一服务直查副本表 | 对比两份数据源的更新时间戳 | 将读路径迁回统一服务 |
| 内存OOM | 第三方库未做技术选型与压测 | 堆转储,分析解析大文件时的缓冲对象 | 换库、加文件大小限制 |
| 数据库升级后查询失败 | 大量裸SQL散落各处 | 全局搜索关键字show tables、jdbcTemplate | 分批改造,引入统一数据接口 |
| 权限越权 | 控制器层直接拼SQL且未鉴权 | 审计日志抽查访问来源 | 收口入口,补权限过滤器 |
这张表后来成了团队新人的安全手册。每次有人想“快一点”时,先看一眼对应症状,往往就会冷静下来。
4. 后悔药配法:先止血,再治根,最后防复发
4.1 先给核心链路补回归测试围栏
直接推翻重来是最差的选择。系统已经跑着业务,全部重写意味着把不确定性扩大百倍。当时我们采用的策略是“先止血,再治根”。
止血的第一步,就是给核心链路补上最小回归测试集。不需要覆盖率全绿,但要把订单查询、权限判断、报表汇总这些关键路径保护起来。这样后续无论怎么改,只要测试变红,就知道改坏了东西。
看一个最基础的集成测试样例:
@SpringBootTest class OrderQueryTest { @Test void testQueryOrderByCustomerId() { OrderQueryResult result = orderService.queryByCustomerId("C001"); assertEquals(2, result.getOrderList().size()); assertTrue(result.getOrderList().stream() .allMatch(order -> order.visible())); } }这套测试可能只覆盖了原来30%的查询场景,但已经是救命稻草。有了它在,重构时敢动手;没有它,改动全靠胆大。
4.2 把读路径迁回统一服务,一次只动一条路
有测试围栏之后,我们开始把老王的直连逻辑一条条迁回统一服务。原则是:一次只迁一个业务点,改完跑测试,再切线上流量。
为了降低切换风险,还引入了一个简易开关。比如在配置里定义一个开关,默认走老逻辑,发布后再手工打开走新逻辑:
feature: order-query-v2: true代码里根据开关切换调用路径:
public List<Order> query(String customerId) { if (featureToggle.isEnabled("order-query-v2")) { return orderService.queryByCustomerId(customerId); } // 老逻辑,只为灰度期保留 return legacyQuery.query(customerId); }等到新路径稳定运行一周后,再把老代码整段删掉。用开关的好处是,出问题可以秒回滚,不用重新发布。
整个迁移过程花了三周,相比一开始的重构估算已经非常可控。团队心态也变了:开始觉得37处裸SQL不可能清完,最后发现只要按业务模块一块块切,速度远比想象快。
4.3 第三方依赖重新收编,补上“引入审批流程”
针对第三方库问题,做了三件事。第一,把所有lib目录下的jar包全部迁移到构建工具的受管依赖中,逐一定版本号。第二,建立了一个内部依赖白名单,凡是白名单之外的库,必须经过技术评审和压测。第三,引入自动化检查,构建阶段发现未登记的库就直接失败。
这套流程看起来增加了环节,实际上省掉的是未来几天的排查时间。第三方的代码本质上是一份契约,没签合同就往生产环境搬,出事了连找谁投诉都不知道。
4.4 重构到什么程度算是头
很多人容易走极端,要么什么都不动,要么非把整个系统推翻。我的经验是抓住“风险点”这个标准:凡是不可监控、不可测试、不可回滚的路径,就应该优先改造;凡是还有测试覆盖、有监控、有明确负责人兜底的临时方案,可以继续保留。
比如某个直连SQL虽然丑,但只有内部运营在用,数据量可控,而且已经补了回归测试,那就允许它存在,等有大事时再顺手收编。重构不是洁癖,是一场资源投入与风险收益的权衡。
5. 实操心得:四个信号帮你分清“灵药”和“良方”
5.1 信号一:这个方案是不是绕过了既有抽象
凡是方案里出现“为了省事,不调那个服务”“直接查表吧”这类描述,就要停下来按住口袋。既有抽象往往凝结了之前的经验,绕过它的每一次操作,都是在给系统增加一个不透明节点。
问自己一个问题:如果半年后深夜两点线上出问题,靠日志能不能在三分钟内找到这个改动点?如果答案是不能,那这就是灵药,不是良方。
5.2 信号二:是不是只快了“第一天”
判断标准很简单:这个方案是只让第一天快,还是让以后每天都快。统一服务封装看起来多花半天,但之后每个调用它的场景都在享受沉淀。直改数据库看起来五分钟完成,但之后每个排查问题的晚上都在为这五分钟交利息。
我见过太多人把“现在的我”和“六个月后的我”当成两个人。现在的我欠下的技术债,六个月后的我根本不知道,也不想去还。这种心态正是债越堆越多的根源。
5.3 信号三:这段代码敢不敢在评审会上讲清楚
如果自己都觉得这段代码不适合放在组会上展示,那基本就是在走捷径了。我面试时喜欢问候选人一个场景:你会怎么向团队解释这个方案?只要对方支支吾吾,说明心里清楚这个方案经不起推敲。
代码是一门公共语言,不是个人笔记。写在项目里的任何东西,都应该默认可以被团队里任何一个人接手、理解、修改。孤僻的代码像孤僻的人,短期内安静无害,一旦开口就是事故现场。
5.4 信号四:有没有对应的回归测试
有没有测试是衡量“灵药”与“良方”最客观的分水岭。没有测试的方案,哪怕跑得再流畅,都只是侥幸。有测试的方案,就算现在不完美,至少它把下限兜住了。
老王的临时方案如果当时有一条测试断言“报表数据与订单列表一致”,根本不会走到客户面前翻车。测试不是负担,是安全垫。安全垫不是为了跳得更高,而是为了摔下来时不至于直接落地。
最后再分享一个小技巧,也是我后来一直用的习惯。每次接到一个排期很紧的需求,我都会先问“哪个环节可以安全地缩短”,而不是问“哪一步可以省掉”。省掉护栏和缩短流程是两码事。把测试写薄一点,把文档写短一点,把联调时间压一点,这些都能省时间;但数据一致性、权限控制、依赖管理这些安全底线,没有一项是可以靠弯曲来省下的。王石应悔偷灵药,悔的是当时吃了不知道后果的药。技术选型也一样,所有“偷”来的灵药早在入口处就标好了价格,有些当天结算,有些半年后连本带利找上门。希望读到这里的同行,都能在自己的项目里少一点“应悔时刻”。