技术债先还代码,还是先拆团队的绕行小路?
重写救不了你,救你的是先关掉那些已被默认的旁路。
text
[读者] 正在还技术债的后端与架构同学
[痛点] 核心模块重写完,临时脚本一条没少
[现在读] 旁路正在偷偷定义你的新系统
[读完] 能盘出旁路清单,并给每条定退出标准
text
[旧方案] 排期重写核心模块
|
v
[新需求] 业务要更快上线、更多租户
|
v
[冲突] 速度指标奖励绕行,绕行又反哺隐性依赖
|
v
[后果] 新系统背着旧旁路,对账与故障面反而变大
我是老李,做了十几年后端,见过太多次"重写完了,问题没少"的现场。团队把重构当成还债的终点,结果上线几个月,旁路脚本一条没删,影子表还在双写。
所以这一次换个记账方式:不先记代码,先记人绕过了什么。
债务的利息几乎不是从核心代码里长出来的。它长在"先这样跑一下""下周就删""运维手动补一条"这些句子里。这些句子被写进脚本、写进运行手册,也写进某个人的肌肉记忆,然后就不再是临时的了。
Ward Cunningham 提出技术债的比喻之后,Martin Fowler 在 TechnicalDebt 一文里把它拆成"有意/无意"与"谨慎/鲁莽"的组合,同时提醒这个比喻的局限:金融债务可以精确计价,代码里的债往往说不清。反过来读更有意思------说不清的那部分,通常不在主干道上。
那么问题来了。
核心模块和旁路脚本同时摆在你面前,你先动哪一个?
先重写核心,还是先拆旁路?
如果只重写核心、旁路一条不动,新系统上线那天,你是在还债,还是在给旧依赖换一套更贵的房子?
01、故事
一个典型现场:订单中心、结算、对账三套系统。核心逻辑在订单中心。某次大促前,为了让某个租户的退款立刻生效,有人在网关加了一个临时接口,直接改库;为了让历史数据对得上,有人建了影子表做双写;为了补齐漏单,有人写了个每晚跑的脚本,人工确认后补录。
任务是重写订单中心。
团队很努力,半年完成重写,代码干净,测试也补了。上线后却发现:对账差异没减少,反而多了一条排查路径------新系统读到的数据,有一部分被影子表和补录脚本改过。
更麻烦的是,那个临时接口还在被调用。调用方不是别人,是团队自己的运行手册。
这就是第二序思维的落点:你要评估的不是"这次绕行能不能过",而是"这次绕行被复制之后,系统会长成什么样"。
一次是决策
两次是习惯
三次成流程
流程最难改
02、问题
旧方案失效不是因为它错了,而是因为它只覆盖了代码。
业务影响:发布节奏被旁路绑架。任何一次核心变更,都要先确认那几条脚本会不会挂。
技术表现:同一份业务含义存在三处写入(核心链路、临时接口、补录脚本);影子表与主表字段逐渐分叉,没人说得清哪张是权威;故障时日志散落在网关、脚本机、人工工单三处;新人上手的第一课是"这几个地方别动",而不是"这套设计为什么这样"。
可验证的完成标准,不是"代码变干净",而是:一、能列出全部旁路,每条都有责任人和过期日;二、过期未达退出标准的旁路默认不可调用;三、任一笔订单的数据来源可被审计还原;四、核心模块变更前,不需要人工确认脚本兼容性。
前三项是盘点与收口,第四项才是重写的收益。顺序反了,第四项永远达不成。
代码能重写
脚本没人删
标准不在多
在能否验收
03、原理
本篇只需要两个模型。
第一个是第二序思维:看决策的后果的后果。临时绕行的一阶后果是"今天上线了",二阶后果是"明天有人照着它再做一次",三阶后果是"它变成默认路径,核心代码反而成了特例"。
第二个是激励机制:被什么指标奖励,就会被什么行为复制。当周指标奖励"快",绕行就是理性选择;没人因为少写一条脚本被表扬,也没人因为多留一条旁路被追问。
反直觉判断:技术债的存量不在代码库里,而在团队的默认动作里。
因为代码可以被重写,默认动作不会。你重写核心模块,只是把默认动作的服务对象换了一遍;只要旁路还在,新系统就得继续兼容它们,于是你得到一个更现代、但负载更重的旧系统。
顺着这个判断,Fowler 的绞杀者模式才显出重点:关键不是"新系统长出来",而是"旧系统最后被移除"。藤蔓绕过去了、旧树没断,养分会一直从旧树上来。
所以还债的顺序是:先断供给,再换主干。
一阶看上线
二阶看复制
藤不断则树在
先断供给再换
04、架构
text
[输入] 业务请求 / 脚本调用 / 人工补录
|
v
[模块] 统一入口 + 旁路登记与准入
|
v
[数据/状态] 旁路台账(责任人/过期日/退出标准)+ 影子表清单
|
v
[处理] 审计埋点 → 按租户灰度切流 → 过期默认拒绝
|
v
[输出] 主链路流量 + 旁路调用计数与告警
边界:这套结构管的是入口与登记,不管业务正确性。它不能替你判断某条旁路该不该留,只能保证留下来的那条是可见、有主、有期限的。
收益:把隐式依赖变成显式清单,后面的重写才有靶子。
代价:多一层准入判断,短期会让"随手加个接口"变慢。这正是激励要改的地方,慢一点是设计目标,不是副作用。
适用条件:旁路数量已经超过"大家心里都记得"的程度,或者核心模块即将重写。旁路只有一两条时,一张表加一次周会就够,不必上系统。
入口收一处
台账记一笔
慢是设计值
快是旧惯性
05、实战一次
环境与依赖:Python 3.11 及以上,FastAPI 与 Uvicorn,SQLite 做台账存储。具体小版本以官方文档为准,本文不锁定。
bash
pip install "fastapi>=0.110" "uvicorn>=0.27"
核心实现,单文件 bypass_gate.py:
python
import sqlite3, time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException
DB = "bypass.db"
def init():
with sqlite3.connect(DB) as c:
c.execute("CREATE TABLE IF NOT EXISTS bypass("
"name TEXT PRIMARY KEY, owner TEXT NOT NULL, "
"expires_at INTEGER NOT NULL, exit_criteria TEXT NOT NULL, "
"tenant TEXT NOT NULL DEFAULT '*')")
def lookup(name):
with sqlite3.connect(DB) as c:
return c.execute("SELECT name,owner,expires_at,tenant FROM bypass WHERE name=?",
(name,)).fetchone()
@asynccontextmanager
async def lifespan(app: FastAPI):
init()
yield
app = FastAPI(lifespan=lifespan)
@app.post("/bypass")
def register(name: str, owner: str, days: int, exit_criteria: str, tenant: str = "*"):
exp = int(time.time()) + days * 86400
with sqlite3.connect(DB) as c:
c.execute("INSERT OR REPLACE INTO bypass VALUES(?,?,?,?,?)",
(name, owner, exp, exit_criteria, tenant))
return {"name": name, "expires_at": exp}
@app.get("/bypass")
def listing():
now = int(time.time())
with sqlite3.connect(DB) as c:
rows = c.execute("SELECT name,owner,expires_at,exit_criteria,tenant FROM bypass").fetchall()
return [{"name": r[0], "owner": r[1], "expires_at": r[2], "expired": r[2] < now,
"exit_criteria": r[3], "tenant": r[4]} for r in rows]
@app.middleware("http")
async def gate(request: Request, call_next):
name = request.headers.get("X-Bypass")
if name:
row = lookup(name)
if row is None:
raise HTTPException(404, "unregistered bypass")
if row[2] < int(time.time()):
raise HTTPException(403, "bypass %s expired, owner=%s" % (name, row[1]))
resp = await call_next(request)
if name:
resp.headers["X-Bypass-Used"] = name
return resp
@app.get("/order/{oid}")
def order(oid: str):
return {"oid": oid, "source": "main"}
这里用 lifespan 而不是旧的 on_event 挂载启动逻辑,后者在 FastAPI 文档中已标注为弃用方向,实际接口以当前官方文档核验。
启动与请求:
bash
uvicorn bypass_gate:app --port 8000
bash
curl -X POST "http://127.0.0.1:8000/bypass?name=refund-patch&owner=lisi&days=-1&exit_criteria=order-center-v2"
curl -H "X-Bypass: refund-patch" "http://127.0.0.1:8000/order/A1"
curl "http://127.0.0.1:8000/bypass"
未在当前环境实测,以下为预期结果(示例输出):
json
{"detail": "bypass refund-patch expired, owner=lisi"}
json
[{"name": "refund-patch", "owner": "lisi", "expires_at": 1700000000, "expired": true, "exit_criteria": "order-center-v2", "tenant": "*"}]
V1 到此跑通:旁路可登记、可查、过期即拒。遗留问题也很明显------它只管带 X-Bypass 头的请求,那些直连库的脚本、绕开网关的补录工具,它一条也看不见。这正是第 06 章要查的。
先跑通登记
再拒绝过期
最小闭环成
遗留即线索
06、排查
第一条链。
现象:新订单中心上线后,对账差异条数没有下降。
怀疑:新系统金额计算有精度问题。
检查:抽样差异单号,回溯三个写入点的日志与最后修改时间。
证据:差异单号几乎都集中在每晚补录脚本运行之后;主表与影子表的更新时间不一致,但金额字段一致。
根因:不是算错,是读到了被旁路改过的数据。新系统读主表,补录脚本写影子表后由触发器回灌,回灌顺序不定。
修复:先给补录脚本加登记与过期日,把回灌改成幂等写入,并在对账任务里区分"业务差异"和"写入来源差异"。
错误尝试: 一开始去调浮点精度、换 Decimal 库。为什么错------差异的分布是时间聚集,不是数值聚集;精度问题会随机分布,不会每天固定在同一小时冒出来。这个线索当时就在日志里,只是被"金额问题就是精度问题"的直觉盖住了。
第二条链。
现象:迁移完成后,那条临时接口的调用量没有下降,反而上升。
怀疑:调用方在偷偷依赖它。
检查:网关按调用来源维度统计。
证据:调用来源里占比最高的,是团队自己的运维脚本仓库。
根因:临时接口没有责任人、没有过期日,它被写进了运行手册,于是从"绕行"升级成"标准操作流程"。激励机制在起作用------用它最快,不用它要写工单。
修复:给接口挂过期日,过期后默认返回 403;把手册里的步骤改成调用统一入口。
两条链的共同点是:故障面不在代码里,在"谁绕过了什么、为什么绕过"里。
差异看时间
精度是错觉
来源定根因
直觉要先疑
07、优化
V2 严格基于上两章的证据,只改三处。
根因一:旁路只在带请求头时被识别。修改:把登记下沉到数据访问层,凡走非主链路的写入必须携带旁路标识,否则拒绝写入。原因:入口可以绕,写库绕不过。新行为:绕开网关的脚本也会被拦住。验证:用直连数据库的脚本试写,预期被拒。
根因二:过期只是提示,不是禁止。修改:过期即 403,并把责任人写进错误信息。原因:没有成本的过期日等于没有过期日。新行为:过期旁路会直接打断调用方,逼出一次确认。验证:登记一条 days=-1 的旁路,请求预期返回 403 并带责任人。
根因三:没有按租户灰度。修改:台账增加 tenant 字段,切流按租户放量。原因:一次性关闭旁路会伤业务连续性,先用小流量验证替代链路。新行为:同一条旁路对不同租户可处于不同状态。验证:对租户 A 关闭、租户 B 保留,分别观察两侧调用计数。
这三处都不动核心业务逻辑。这是有意的:V2 修的是绕行的可见性与成本,不是绕行的功能。
只改三处门
不动业务核
成本即信号
可见即治理
08、演进
text
[同一输入:一次临时绕行]
|
+--[V1] 直接上线,口头约定下周删
| 代价:依赖沉淀,重写后仍背旧债
|
+--[V2] 登记 + 过期即拒 + 租户灰度
| 代价:加接口变慢,需要人维护台账
|
[Trade-off]
得到:旁路可见、有主、有期限,重写有靶子
失去:随手改的灵活性,短期交付速度
适用边界:旁路已成规模,或核心即将重写
正确性:V1 在单次场景下是对的,V2 在多团队、长周期下才对。差别不在技术,在时间尺度。
稳定性:V2 用"过期即拒"换来确定性,代价是把故障从"悄悄错"变成"当场报错"。这通常是好事,但要配回滚预案。
复杂度:V2 多了一张表、一层准入、一套审计。它本身也是技术债------如果台账没人维护,它会变成新的影子系统。这一点必须承认。
成本:登记、评审、过期处理都是人力成本,且集中在最忙的那个人身上。
适用范围:旁路少、团队小、生命周期短的系统,用一张共享表格加周会即可,不必上中间件。
遗留问题:直连库脚本、历史跑批任务、外部合作方的定点接口,仍是最难收口的部分。
旧路留代价
新路留成本
取舍在边界
不在优劣间
09、洞见
9.1 债务的利息来自复制,不来自缺陷
一个缺陷只影响一处;一条被复制的绕行会影响所有后来的人。所以治理优先级不是"哪段代码最丑",而是"哪个做法被抄得最多"。
反直觉判断:最该先还的债,往往不是最烂的代码,而是最方便的那条小路。
9.2 激励机制不改,清债就是一次性劳动
如果指标仍然只奖励"上线快",这一轮清完,下一轮大促还会长出来。台账、过期日、默认拒绝,本质上都是在给绕行定价,让它在指标上不再免费。
反直觉判断:让加旁路变慢,是效率工程,不是流程官僚。
9.3 重写不是还债,是换容器
容器换了,装的东西还是旧的。绞杀者模式有两个动作:新枝长出来、旧树被移除。多数团队只做第一步就宣布胜利,于是旧树成了"不可删的祖传依赖"。
反直觉判断:重写的验收标准应该写成"能删掉什么",而不是"新写了什么"。
9.4 收口要先于修复
先让旁路可见、可计数、可拒绝,再动核心逻辑。顺序反过来,你会在一个不断变化的靶子上做重构。
反直觉判断:先修主干道的人通常修不完,先关小路的人反而把主干道修完了。
最丑不先还
最便最该拆
慢一点是效率
快一点是欠账
10、系统落地
原来有什么:一份写在 wiki 里的"已知临时方案"列表,半年没更新;核心模块重写已经排上期。
本篇新增什么:旁路台账(责任人、过期日、退出标准、租户)、统一入口的准入判断、审计埋点与调用计数。
现在能做什么:能回答"系统里现在有几条绕行、归谁、什么时候到期",能在重写前把依赖清单钉死,能按租户灰度关闭。
还缺什么:直连库脚本的覆盖、外部合作方接口的收口、台账本身的维护责任------它也会腐化。
下一步演进:把旁路计数接进发布门禁,新增旁路必须登记,未登记的调用在预发环境直接失败;台账按季度复审,过期未续的一律删除并留档。
一句话的执行动作:这周先做一件事,把所有绕过主链路的做法列成一张表,每条后面写上人名和日期。
先列一张表
再写一个人
时间与标准
缺一不算账
11、小结
text
Q1 → 先动核心还是先动旁路:先收口旁路,再重写核心
Q2 → 旁路会自己消失吗:不会,除非它有责任人和过期日
Q3 → 重写是还债还是换房:不拆旁路,就是给旧依赖换更贵的房子
状态 → 可交付:一份带责任人、过期日、退出标准的旁路台账 + 过期即拒的准入
关于标题里的争议,我的答案偏向"先拆小路"。这不是道德判断,是顺序判断:小路不拆,主干道修好了也会被重新踩出岔口。
先拆掉小路
再修好主干
顺序一颠倒
重写白两遍
12、作业
12.1 理解题:为什么说"技术债的存量不在代码库里"?
参考答案:代码可以被重写,默认动作不会。只要"遇到问题先加个脚本"仍是被奖励的最优解,债务就会持续再生产。存量沉淀在运行手册、口头约定和审批习惯里,代码只是它的产物。
12.2 实战题:给一条已存在的补录脚本加上退出机制,最少需要几个字段?
参考答案:至少四个------责任人、过期时间、退出标准(什么条件满足后可删除)、当前依赖方。没有退出标准就没有删除依据,没有责任人就没有人推动,没有过期时间就没有压力。落地可以先用一张表加一次周会。
12.3 排障题:重写后对账差异变多,怎么判断是"新系统算错"还是"读到了旁路数据"?
参考答案:看差异在时间与单号上的分布。算错通常随机分布,并与金额或边界条件相关;读到旁路数据会呈现时间聚集(比如集中在补录脚本运行后)或单号聚集(集中在某类人工处理过的单)。再用写入来源审计交叉验证:同一单号在主表、影子表、补录日志里的最后修改时间与来源。分布特征比数值本身更能定位根因。
12.4 架构判断题:是否应该一次性关闭全部旁路,然后重写核心?
参考答案:不应该。旁路往往在承担真实的业务连续性,一次性关闭会把技术问题变成业务事故。合理路径是:先登记与审计(可见),再按租户灰度(可控),验证替代链路与回滚方案,最后才关闭并删除。同时要接受一个前提------不是所有临时兜底都该立即删除,判断标准是替代链路是否已验证,而不是它是否临时。
答在台账里
证在分布中
判在边界上
做在灰度前
13、思考
这场争议的实质不是"代码重要还是流程重要",而是"你在哪个层面记账"。
如果债务只记在代码里,你会得到一次次重写和一次次回潮;把绕行也记进账本,重写才第一次有了终点。第二序思维提醒我们看后果的后果,激励机制提醒我们后果是被指标养出来的------两个模型合起来,指向同一个动作:先给绕行定价。
适用条件是明确的:旁路已成规模,或者核心即将重写。旁路只有一两条时,别急着上系统,先把那一条删掉。
别只装修主干道,先停止把小路修成永久路线。
一阶看上线
二阶看复制
先收旁路口
再动主干枝