第二章 第一个 Bug

一

入职第三天,王志远接到了他人生中第一个正式的工作任务。

赵北辰在 Jira 上给他指派了一个 bug,优先级标着"Medium",标题写着:

BUG-2847:订单详情页金额显示偶尔出现小数点后三位

赵北辰说:"这是个小 bug,客户反馈的。正常金额应该显示两位小数,比如 '99.90',但偶尔会出现 '99.900'。你查一下原因,改掉。"

"好的。"王志远在笔记本上认认真真地记下了 bug 编号。

"不急,慢慢来。先读代码,搞清楚数据流转的链路,再动手。"赵北辰补了一句。

王志远点头如捣蒜。他心想:一个小数点的问题,能有多难?

他打开 IntelliJ,搜了"金额"——一百多个结果;又搜"amount"——三百多个;再搜"price"——两百多个。

他盯着屏幕,感觉自己像是拿着一根针,站在一个草垛面前。每个结果都像在对他说:你以为你要改的只是两位小数?不,你要先在这一堆历史里找到“谁动过这口锅”。

二

他花了整整一个上午,才大致理清金额的数据流转:前端 → Controller → Service → DAO → DTO。中间还夹着金额计算工具类、格式化拦截器,以及一个来路不明的转换方法。

他在笔记本上画了张流程图,箭头指来指去,最后连自己都看不懂。唯一确定的是:链路很长,任何一个“小改动”都可能像多米诺骨牌一样往外倒。

午饭的时候,他还在想那个 bug:同样是 BigDecimal 转字符串,有的地方 toString(),有的地方 toPlainString(),还有的 String.format("%.2f", amount)——三种写法混在一起,很难保证一致。

更要命的是,他并不知道“偶尔出现三位小数”到底意味着什么:是某些计算把 scale 拉长了?还是数据库里本来就存了 99.900?又或者只是展示层某个地方直接 toString(),把尾巴原封不动吐出来?

他把这些疑问写在便签上贴在屏幕角落,像给自己立了条规矩:先搞清楚哪一步变脏,再谈怎么洗干净。

下午他开始动手改。

他的思路很简单:统一用 String.format("%.2f", ...)。他在 AmountUtils.java 里把 toString() 一股脑换成 String.format("%.2f", amount.doubleValue())。本地一跑,页面上看起来“好了”,他就提交了。

三

第二天早上站会的时候,赵北辰照例问每个人的进度。轮到王志远,他挺直了腰板说:"BUG-2847 已经修复了,昨天提交了代码。"

赵北辰点了点头:"提了 PR 没有?"

"提了。"

"好,等 Review 通过就合。"

王志远心里美滋滋的。第一个 bug,两天搞定,不算太慢吧?

站会结束后不到二十分钟,钉钉弹出一条消息。是苏薇。

苏薇:@王志远 你的 PR 我看了。来一下会议室 B。

王志远的心咯噔了一下。会议室 B 里,苏薇打开着他的 PR,满屏红色批注,像一张被老师用红笔批改过的试卷。

他坐下时才发现自己手心全是汗,鼠标垫边缘被他指尖抠得发白。他本来以为 Review 只是“走流程”,现在才意识到:Review 是把你写的每一行拿到光下看,亮的地方会亮,脏的地方也会更脏。

苏薇把屏幕转向他:"我一条一条说,你听着。"

"第一,"她指着代码里的一行,"amount.doubleValue()——你知道 BigDecimal 转 double 会有什么问题吗?"

王志远摇了摇头。

"精度丢失。BigDecimal 存在的意义就是精确计算,你把它转成 double,等于把保险箱的门拆了。金额计算用 double,这是金融系统的大忌。"

王志远张了张嘴,想辩解一句“我只是格式化显示,又没拿 double 做运算”,但话到嘴边又咽了回去。他忽然意识到,自己连“显示”和“计算”的边界都没想清楚:既然 AmountUtils 是公共工具类,谁能保证它只在展示层用?

"第二,"苏薇往下滚了几行,"你改了 AmountUtils 的格式化方法,但你知道这个工具类被多少地方调用吗?"

王志远又摇了摇头。

苏薇按了一下快捷键,弹出一个调用链的窗口:"二十三个地方。订单模块、支付模块、退款模块、对账模块。你改了格式化逻辑,影响的不只是订单详情页——你跑过其他模块的测试吗?"

"我……跑了订单模块的。"

"只跑了订单模块的?"苏薇的眉毛挑了一下。"支付模块的单元测试跑了吗?"

"没有。"

"对账模块的呢?"

"……没有。"

苏薇深吸一口气,语速放慢了一点,但每个字都像钉子一样钉进来:"你动了一个公共工具类,就测了自己那一块,别的模块碰都没碰。这哪叫修 bug?这叫埋雷。"

她又往下翻了一页:"第三,也是最严重的——你的改法引入了新的 bug。"

她指着屏幕上的一段代码:

String formatted = String.format("%.2f", amount.doubleValue());

"当 amount 是 null 的时候,这行代码会怎样?"

王志远想了两秒钟:"……空指针异常?"

"对。NullPointerException。原来的代码虽然格式化不统一,但至少有 null 检查。你的改法把 null 检查干掉了。"苏薇把 PR 页面滚到最上面,指着她写的第一条评论,干脆利落地下了结论:"你这个 PR 一共引入了三个新问题:精度丢失、null 指针、以及把退款模块需要的四位小数改成了两位。"

苏薇合上笔记本,看着他:"我不是要骂你,新人犯错太正常了。但你得记住一件事——动手之前,先搞清楚你改的东西谁在用。改完之后,挨个测一遍。这是基本功,没有捷径。"

她站起来,走到门口又回头说了一句:"PR 打回了,重新改。这次别急,想清楚再动手。"

门关上了。会议室里只剩王志远一个人,对着满屏红色批注发呆。

四

回到工位上,王志远盯着屏幕发了十分钟的呆。

苏薇说的每一条都对,他无话可说:原本是个小 bug,他却制造了三个更大的问题。

他下意识朝陈锐那边看了一眼。陈锐正好也抬头,看见他,嘴角像是往上提了提,又立刻低下头去敲键盘,仿佛什么都没发生。王志远把视线收回来,觉得喉咙发紧。

他决定重新来过。但这次他不想再出丑了——他想快点把这个 bug 修好,证明自己不是废物。

他打开浏览器,在地址栏输入 chat.openai.com。这一次,对话框没有苏薇的语气,没有红色批注,也不会皱眉——它只会回答。

他把 bug 描述贴了进去。几秒钟后,ChatGPT 给出的核心建议很直接:显示层统一 setScale(2, RoundingMode.HALF_UP),加上 null 兜底,并用 toPlainString()。

public static String formatAmount(BigDecimal amount) {
    if (amount == null) return "0.00";
    return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
}

王志远眼睛一亮,几乎没怎么想就复制粘贴了过去。退款模块要四位?那就加参数、做重载——ChatGPT 也给了建议。

他甚至觉得自己“学到了”:看,AI 给的方案又专业又完整,还自带最佳实践。只要照着改、把测试跑绿,就不会再挨骂了吧?

半个小时后,他按 ChatGPT 的思路改完了代码。这次他学乖了,至少把订单和支付的测试都跑了一遍——全绿;退款也切到了四位小数的重载方法。

他觉得这次应该没问题了。

他重新提交 PR,还在描述里写了影响分析,自我感觉“这次稳了”。

五

第二天的 Code Review 会上,苏薇打开了他的新 PR。

"这次改得好多了。"苏薇说。

王志远心里一喜。

"但是——"苏薇顿了一下,"风格跟你昨天的完全不一样。变量名、注释、甚至 @Nullable 都像换了个人写。"

王志远心里一沉。他这才发现,代码也有“笔迹”。你可以抄别人的答案,但抄不出别人写字的习惯。

赵北辰一直没说话,这时候开口了:"志远,这段代码是你自己写的吗?"

王志远的脸一下子涨红了。赵北辰的眼神平和但认真,像是在等一个诚实的回答。

"……不全是。"他低下头,声音很小。"我用了 ChatGPT。"

会议室里又安静了几秒。马德华在角落里"嚯"了一声,陈锐的笑容扩大了一点。苏薇的表情倒是没什么变化——她大概已经猜到了。

赵北辰点了点头:"用 AI 工具不丢人,我自己也用。但我问你几个问题。"

"这个方法里用了 setScale(2, RoundingMode.HALF_UP)——HALF_UP 是什么意思?跟 HALF_EVEN 有什么区别?什么场景下该用哪个?"

王志远愣住了。他知道 HALF_UP 是四舍五入,但 HALF_EVEN 是什么?他完全没想过。

"退款模块你用了四位小数的重载方法——为什么是四位?对账的精度要求是怎么定的?你看过对账模块的文档吗?"

王志远摇了摇头。

"你在 PR 描述里列了影响范围,但实际上调用 AmountUtils 的模块比你写的多。你是怎么统计的?"

"我……搜了一下调用的地方。"

"搜了哪些关键词?"

"搜了方法名。"

"还有被包装、被间接调用的呢?"

王志远说不出话了。

赵北辰没有继续追问。他站起来,走到白板前,拿起马克笔画了一条线,线的左边写了"AI",右边写了"你"。

"AI 能帮你做什么?"他在左边写下几个词:生成代码、解释概念、提供思路。

"AI 不能帮你做什么?"他在右边写下:理解业务上下文、评估影响范围、为线上结果负责。

"ChatGPT 的方案单独看不错,但它不知道你们的业务上下文:退款为什么要四位、有没有特殊舍入规则、对账系统怎么校验……这些得你自己去读代码、读文档、问同事。"

他放下笔,转过身看着王志远:

"工具随便用,但脑子不能停。AI 写的每一行代码,你得能说出来为啥这么写。说不出来的,就别往上提。"

王志远的脸烧得厉害,但他把这句话一个字一个字地刻进了脑子里。

六

PR 又被打回了。

王志远回到工位上,没有再打开 ChatGPT。他把 IDE 里的代码全部回退到修改之前的状态,深吸一口气,决定从头来过。

这次他先去找赵北辰。

"赵哥,我想请教一下,怎么才能正确地定位这个 bug?我之前的方法好像不太对。"

赵北辰放下手里的茶杯,看了他一眼。那个眼神里有一点点欣慰——虽然很淡,但王志远捕捉到了。

"你之前的问题不是技术不行,是方法不对。"赵北辰说。"你一上来就想改代码,但你连 bug 的根本原因都没找到。你知道那个小数点后三位是在哪一步产生的吗?"

"我猜是格式化那一步……"

"猜?"赵北辰摇了摇头。"Debug 不能猜。来,我教你一个方法。"

他拉了把椅子坐到王志远旁边,在白板上画了一条横线,左端写"输入",右端写"输出",中间标了五个节点:

数据库 → DAO → Service → 格式化 → 前端显示

"数据从数据库出来,经过这五个环节到用户眼前。bug 是'偶尔出现三位小数'——注意'偶尔'这俩字,说明不是所有数据都有问题。你第一件事不是急着改代码,是先搞清楚这个三位小数到底是在哪一步冒出来的。"

"怎么找?"

"最笨也最可靠的方法——在每个环节打断点,看数据的变化。"

赵北辰帮他在 IntelliJ 里设置了五个断点,分别在 DAO 层查询结果返回、Service 层处理前后、格式化方法入口和出口。然后他找了一条会出现三位小数的测试数据,用 Debug 模式启动了服务。

那条数据是一个真实的订单号,来自客户反馈。王志远之前嫌麻烦,总觉得“偶尔”两个字意味着不好复现;现在他才懂,复现不了就只能猜,猜就只能乱改。断点一停下来,变量窗口里那串数字反而让人安心——至少它是确定的。

程序停在了第一个断点。赵北辰指着变量窗口:"看,数据库里存的是什么?"

王志远凑过去看:amount = 99.900,scale 是 3。

"数据库里存的就是三位小数。"赵北辰说。"所以问题不在格式化,而在数据写入的时候。某些场景下,写入数据库的金额就带了三位小数。"

王志远恍然大悟:"所以我之前改格式化方法,方向就错了?"

"方向也不能说全错——显示层做个兜底是应该的。但你不把源头堵住,数据库里的脏数据越攒越多,迟早在别的地方炸。"赵北辰说。"治标治本得一块儿来。"

他让王志远继续追查:什么场景下写入的金额会带三位小数?

王志远翻了半个小时的代码,终于找到了——订单创建的时候,如果用户使用了优惠券,折后金额的计算会经过一个乘法运算:

BigDecimal discount = totalAmount.multiply(couponRate);

couponRate 是一个三位小数的比例值,比如 0.855(八五五折)。totalAmount 乘以 0.855,结果的小数位数就可能超过两位。而写入数据库的时候,没有做 setScale 的统一处理。

"找到了!"王志远的声音比平时大了一倍,连隔壁工位的人都看了过来。

赵北辰嘴角微微上扬:"嗯。现在你知道根本原因了,再想想怎么改。"

七

这一次,王志远没有急着写代码。

他先在笔记本上列了一个清单:

  1. 根本原因:优惠券计算时,乘法运算导致小数位数不一致,写入数据库时未做精度统一
  2. 治本:在金额写入数据库之前,统一做 setScale(2, RoundingMode.HALF_UP)
  3. 治标:在显示层的格式化方法中,增加精度兜底逻辑
  4. 影响范围:需要检查所有写入金额的地方,不只是优惠券模块
  5. 存量数据:数据库里已有的三位小数数据,需要写一个数据修复脚本
  6. 测试:所有涉及金额的模块都要跑测试

清单写完,他又补了两行小字:“存量数据怎么修?”、“修之前先备份。”——他想起苏薇说的“埋雷”,以及赵北辰反复强调的影响范围。修 bug 不只是改一段代码,有时候还得把已经写进数据库的“历史”也处理干净,但这一步最容易把雷从地底挖出来当场炸。

他拿着这个清单去找赵北辰确认。赵北辰看了一遍,点了点头:"思路对了。第五条想到存量数据修复,不错,很多人会忘。"

然后他补了一条:"第七条——写完之后,自己做一次 Code Review。假装你是苏薇,用她的标准审一遍自己的代码。"

王志远苦笑了一下。

他花了一天半,按清单一条条改。这次他没让 ChatGPT 直接“代写”,只是拿它来问概念:RoundingMode 到底怎么选?问完他又去翻了官方文档,把常用的两种记下来:

  • HALF_UP:常见的“四舍五入”
  • HALF_EVEN:银行家舍入(遇到 5 时向最近的偶数舍入)

他还去找了对账模块的负责人,确认了对账用的是四位小数精度,以及为什么用四位——因为跨境支付涉及汇率换算,中间过程需要更高精度。

那位负责人顺口补了一句:“你们展示给用户是两位没问题,但内部对账别乱动精度,差一分钱都能对一天。”王志远听完背后一凉,突然理解了苏薇那句“你这叫埋雷”——雷不一定当场炸,但炸的时候往往最疼。

改完代码之后,他按照赵北辰说的,假装自己是苏薇,把代码从头到尾审了一遍。他甚至在心里模拟了苏薇的语气:"这个变量名是什么鬼?""null 检查呢?""测试用例覆盖了边界情况吗?"

他又花了半天时间补了几个测试用例,包括:金额为 null、金额为 0、金额为负数、优惠券比例为 0、优惠券比例为 1(全额折扣)。

最后他提交了第三版 PR。

八

这一次,苏薇的 Review 评论只有三条。

第一条是一个变量名的建议:discountedAmount 比 discAmount 更清晰。

第二条是一个测试用例的补充:建议加一个"金额极大值"的边界测试。

第三条是:

👍 比前两版好多了。改完上面两条就可以合了。

王志远盯着那个竖大拇指的 emoji 看了好几秒,然后把椅子往后一靠,长长地吐了一口气。

他改完那两条,苏薇批准了 PR。代码合入主分支的那一刻,CI 流水线跑了起来,绿色的对勾一个接一个地亮起来——单元测试通过、集成测试通过、代码扫描通过。

赵北辰路过他工位的时候,瞥了一眼他的屏幕,看到那一排绿色的对勾,说了一句:"不错。"

就两个字。但王志远觉得这两个字比任何表扬都重。

下班电梯里,陈锐阴阳怪气:"听说你那个 bug 改了三版?我第一次一版就过。"王志远心里一堵,但他想起赵北辰的话,又想起苏薇那个竖大拇指,深吸一口气,没接茬。

走到前台的时候,林秀秀正在收拾东西准备下班。她看到王志远,笑着问:"今天怎么样?脸色比昨天好多了。"

"今天……还行。"他想了想,补了一句,"修了一个 bug。改了三版才过。"

"三版?"林秀秀歪了一下头,"那说明第三版很好啊。"

王志远愣了一下,然后笑了。

他第一次发现,“第三版”这三个字也可以不是笑话。有人把它当成失败,有人把它当成进步。林秀秀一句轻飘飘的话,像把他从自责里拽出来一点点。

"对,第三版很好。"

九

晚上回到出租屋,王志远坐在床上,打开笔记本,在新的一页写下:

Day 5

第一个 bug 修了三版才过。说出来有点丢人,但赵哥说"新人犯错正常,不知道自己错在哪才不正常"。

最大的教训:改公共代码之前,先查清楚谁在调用它。我改了一个工具类,结果把三个模块都搞挂了。苏薇说话虽然直,但她说得对——改代码之前先搞清楚影响范围,这是基本功,没有捷径。

第二个教训:Debug 不能猜。复现不了的问题,最后都会变成更大的问题。

AI 可以用,但脑子不能停。ChatGPT 就像一本会说话的参考书,你可以翻它、问它,但不理解的代码不能提交。

苏薇其实人挺好的,就是说话太直了。陈锐说他一版就过了,不知道是不是真的。

林秀秀说得对——第三版很好。

他合上笔记本,躺在床上。手机里还留着白天的 ChatGPT 对话——确实有用,但他也更清楚:它像一本会说话的参考书,能帮你查概念、理思路;真正要交出去的答案,得自己想明白。

明天还有新的 bug 等着他。但他不那么怕了。


:::{admonition} 小胖的笔记 :class: tip

本章知识卡片:Debug 与 AI 辅助编程

1. Debug 四步 - 复现:找到可复现的数据与步骤,记录条件 - 画链路:把数据从输入到输出的关键节点画出来 - 打断点:盯住每个节点的数据变化,找“从对到错”的那一步 - 二分缩小:链路长就从中间切,逐步缩到最小范围

2. 修复检查清单 - 根本原因是否找到(不是表面现象)? - 影响范围是否评估(谁在调用、谁会受影响)? - 治标 + 治本是否一起做? - 存量数据是否需要修? - 测试是否覆盖边界? - 自己是否先 Review 一遍?

3. AI 使用原则 - AI 可以用来查概念、要思路、补文档;但上线结果你负责 - 解释不了的代码,不要提交

4. BigDecimal 备忘 - 金额计算不要用 float/double,用 BigDecimal - 展示层格式化建议 toPlainString(),并在必要时 setScale(...) - toString() 可能输出科学计数法(尤其在 scale 变化时),展示层要小心 - BigDecimal 不要用 new BigDecimal(double),要用字符串或 BigDecimal.valueOf(...) - 乘除法后要显式处理 scale/舍入规则,别把“默认行为”当成“业务规则”

:::