第二章 第一个 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 的统一处理。
"找到了!"王志远的声音比平时大了一倍,连隔壁工位的人都看了过来。
赵北辰嘴角微微上扬:"嗯。现在你知道根本原因了,再想想怎么改。"
七
这一次,王志远没有急着写代码。
他先在笔记本上列了一个清单:
- 根本原因:优惠券计算时,乘法运算导致小数位数不一致,写入数据库时未做精度统一
- 治本:在金额写入数据库之前,统一做
setScale(2, RoundingMode.HALF_UP) - 治标:在显示层的格式化方法中,增加精度兜底逻辑
- 影响范围:需要检查所有写入金额的地方,不只是优惠券模块
- 存量数据:数据库里已有的三位小数数据,需要写一个数据修复脚本
- 测试:所有涉及金额的模块都要跑测试
清单写完,他又补了两行小字:“存量数据怎么修?”、“修之前先备份。”——他想起苏薇说的“埋雷”,以及赵北辰反复强调的影响范围。修 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/舍入规则,别把“默认行为”当成“业务规则”
:::