ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

订单号变负数?一次由类型转换引发的灵异Bug排查实录

订单号变负数?一次由类型转换引发的灵异Bug排查实录 独立站的老问题刚刚缓过一口气结果又被一个订单号整崩溃了。这个Bug我整整排查了一周前后端、数据库、运维全都被拉下水最后定位到根因的那一刻我盯着代码愣了两分钟——一个不起眼的类型转换。这类问题最坑的地方在于表象完全随机日志看起来毫无规律但只要触发条件满足它就像定时炸弹一样准时爆炸。今天把这周的完整排查过程、根因链路和最终修复方案写出来希望对正在被“灵异Bug”折磨的人有点参考价值。1. 灵异Bug的开端订单号突然变成负数1.1 现象描述命中场景后金额和订单号全部错乱先描述一下业务背景。我们这边有个订单对账系统每天凌晨跑一次任务把前一天的全部订单汇总成报表推送给财务和运营侧。整个链路是业务库 - 数据同步服务 - 报表服务 - 前端展示。上线了大半年一直很稳定直到有一天运营同学在群里发了一张截图说报表里出现了一批异常订单金额直接拉到了九千多亿订单号变成了负数。负数订单号这东西正常业务逻辑里根本不可能出现所以第一反应是“数据被污染了”。当时我做的第一件事是去业务库里查原始订单结果业务库里的订单号、金额、时间戳全部正常。订单号是雪花算法生成的Long型ID金额是decimal(10,2)没毛病。这时候问题范围开始收窄既然源头数据是好的那污染一定发生在同步或者报表处理环节。我赶紧去看同步服务的日志结果日志里根本没有报错反而安静得不像话。这就是这种Bug最折磨人的地方——表面上看一切正常但输出结果就是不对。更诡异的是不是所有订单都出错。随机挑了几笔出问题的订单发现它们的订单号都有一个共同特点超过2147483647也就是Java里Integer的最大值。而那些小于这个值的订单一切正常。当时我心里已经隐约有了预感但还是不敢确定因为代码review了好几遍从来没在这个链路里看到过Integer类型。这个现象持续了大概三天每天凌晨跑批都会随机出问题运营那边催得紧测试环境又复现不出来。测试环境的数据量小订单号基本都在Integer范围内这就是典型的“生产环境才能触发、测试环境永远正常”的隐蔽缺陷。1.2 定位边界先确认这是数据问题还是代码问题排查这种问题第一步永远是划分边界。我当时拉了一个简单的检查清单源头数据是否正常业务库直接查询中间传输过程是否正常同步服务的日志和落库数据消费端处理是否正常报表服务的入参和出参前端展示是否正常接口返回值和页面渲染经过第一轮检查源头数据正常前端接口返回值里就已经是负数了。所以问题锁定在同步服务到报表服务这一段大概率跟代码逻辑有关而不是数据库层面的问题。这里要补充一个经验遇到偶发性数据异常不要急着改代码先把“数据在哪一步开始变坏”这个节点找到。找到这个节点排查范围就能缩小80%。我当时就是通过对比业务库原始数据和报表服务收到的数据发现数据在同步服务转换环节就已经坏了根本不用继续往后端和前端查。很多人在这一步会浪费大量时间在无关环节上比如检查SQL、检查索引、检查数据库配置实际上都是白费功夫。2. 排查过程从数据库到日志一个个排除2.1 第一轮排查数据库字段类型和SQL核对确定问题在同步服务之后我先把同步服务的代码完整过了一遍。这个服务的逻辑不复杂从业务库里查询订单数据做一次轻度的字段映射然后通过HTTP调用发给报表服务。代码用的是Java数据库连接用的是MyBatis字段映射写在一个DTO里。我盯着那个DTO看了半天所有字段类型都是Long、BigDecimal、String根本没有Integer。那这个负数到底是从哪冒出来的为了搞清楚这个问题我在同步服务里加了一行日志把查询出来的原始订单号打印出来。结果发现同步服务从数据库查出来的订单号明明是正常的但通过HTTP发送出去的JSON里订单号变成了负数。到这里问题已经非常清晰了一定是序列化和反序列化这个环节出了幺蛾子。我赶紧去翻报表服务接收请求的Controller发现接收对象里有一个字段是Integer orderId而同步服务发送过来的JSON里订单号是一个超过Integer范围的长数字。这个发现让我整个人都有点崩。因为理论上Java在做JSON反序列化时如果JSON里的数字超出了Integer的范围应该直接抛异常才对怎么会静默转换成一个负数呢除非用的JSON库有自己的“宽容处理”规则。我们项目里用的Fastjson它在某些版本里对整数溢出不会直接报错而是会做一个类型转换把超范围的值截断最终变成负数。这就是整个Bug的核心机制。这里顺便讲一个排查思路当怀疑是数据转换问题时一定不要只看业务代码还要看框架代码的配置和版本。序列化库的版本差异、配置项差异比如Fastjson的Feature开关、Jackson的DeserializationFeature都可能导致行为完全不同。我们当时用的Fastjson版本比较老旧新版本里已经对这种溢出做了更严格的校验老版本则是“能转就转转不了就截断”安全性和友好度都差很多。2.2 第二轮排查接口层和网关层的传参对比确认报表服务接收对象里是Integer之后我又去查了网关层和其他中间环节。因为同步服务发请求不是直连报表服务中间还经过了一层API网关网关会对请求做一些参数校验和转发。我当时担心是不是网关层把参数类型给改了比如把Long改成了Integer于是在网关层加了一段打印专门记录请求体的原始JSON。结果网关层原样透传没有任何改动这就彻底证明了问题出在报表服务的反序列化过程中。排查到这里其实已经可以进行修复了。但为了搞清楚为什么之前一周都没发现我还是决定把完整的调用链日志翻出来复盘整个事件发生的全过程。前面说过这个问题有一个触发条件订单号超过Integer范围。但更棘手的是同步服务发送的订单号明明是正常的Long为什么报表服务收到后就变成了Integer关键在于报表服务里定义的那个接收DTO它里面写着Integer orderId。哪怕发送端是Long一旦接收端声明成了IntegerJSON库就会按照接收端的类型去解析这就必然会触发溢出问题。这其实是前后端联调和微服务接口对接时一个非常容易踩的坑你发送的数据类型和你声明的接收类型不一致框架不会提醒你数据就会被静默转换。尤其在使用Fastjson这类“宽容型”序列化库时这种错误更容易被隐藏。如果用Jackson默认配置下反序列化超范围的整数会直接抛MismatchedInputException反而能提前发现。所以从这里也能看出选型的重要性不同JSON库的容错策略直接决定了这种Bug是“静默发生”还是“直接报错”。我给你整理一个对比表方便你理解不同JSON库在整数溢出场景下的行为差异JSON库整数溢出行为是否可配置推荐配置Fastjson老版本静默截断转成负数或最大值部分可配置升级新版本开启严格模式Fastjson新版本默认cast失败抛异常是打开CheckTypeJackson默认抛MismatchedInputException是关闭ALLOW_COERCION_OF_SCALARSGson抛JsonSyntaxException否无2.3 第三轮排查日志里的JSON解析现场在修复之前我还做了一件事找日志里的JSON解析记录。因为Fastjson在解析时会有内部的parse过程如果我们开了Debug日志是可以看到解析前后的类型变化。不过老项目里的日志级别是Info看不到那么细所以这轮排查主要靠人工模拟。我写了一个简单的测试类模拟同步服务发送的超大订单号走一遍报表服务接收发送端构造一个Long类型的订单号2458888812345678901L通过HTTP发送JSON数据是{orderId:2458888812345678901}接收端DTO里声明的是private Integer orderId结果显示orderId变成了-1838538091看到这个结果整个根因链已经完整了。Fastjson的TypeUtils.castToInt方法在处理超过Integer范围的数字时会做二进制截断处理。简单来说Long类型的二进制表示有64位Integer只有32位强制转换时只保留低32位高位直接丢弃。如果高32位里有非零数据低32位解析出来就有可能是负数。这个原理跟Java里的基本类型强转一模一样。这就是那种“道理你都懂但就是容易忽略”的坑。Java开发规范里对类型转换有明确要求不允许隐式缩小基本类型不允许在DTO里使用与接口约定不一致的类型。但实际开发过程中很少会有人对每个字段都做严格检查。尤其是订单号这种字段平时用Long用习惯了很少有人会意识到接收方可能把它定义成了Integer。3. 灵异现场重现类型转换的底层原理3.1 为什么整洁的代码里会藏着一个类型转换陷阱到这个环节我需要交代一下报表服务接收订单号的DTO并不仅仅在接收请求时使用后面还有一个存库逻辑。原本的代码流程是报表服务收到订单数据 - 解析成DTO对象 - 把DTO字段映射到数据库实体 - 插入报表库。由于DTO里订单号已经被反序列化成了负数后面映射到数据库实体时负数也会跟着写进库里所以最终报表库里存的数据就是错的。这就是为什么运营在报表页面看到的订单号是负数——因为源头入库时就已经错了。这里最大的教训是Bug真正发生的位置往往不是数据被观察到出错的时刻而是在更早的某个毫秒级操作里。我们排查人员看到的脏数据其实是“结果”不是“原因”。如果从一开始就沿着数据流向逐层检查一定能在某个环节找到“数据发生了突变”的节点。可惜当时经验不够前两三天基本在瞎忙靠SQL查来查去反复核对数据库记录就是没往DTO字段类型这个方向想。还要提一个细节为什么测试环境复现不出来因为测试环境的订单号大多在个位数到百万级别全部落在Integer范围内根本不会触发溢出。这种“只有生产环境才能触发”的Bug本质上还是测试数据构造的问题。在做集成测试和冒烟测试时如果不考虑边界值尤其是Long字段的边界值很容易漏掉这类问题。我在后面会专门写一节关于测试数据的注意事项这一点非常重要。3.2 Java类型转换的三种典型坑包装类缓存、强制转换、JSON反序列化既然根因已经定位到了类型转换我顺便把Java里跟类型转换相关的典型坑全部梳理了一遍。这是一个经验值很高的内容我建议你哪怕这次用不到也先收藏着说不定哪天就能救命。第一个坑是包装类缓存。Integer默认缓存了-128到127之间的数值当你用Integer.valueOf(100)和Integer.valueOf(100)做比较时因为缓存命中结果是true。但当你用Integer.valueOf(200)和Integer.valueOf(200)比较时缓存未命中会创建新对象结果是false。这个坑在日常开发里非常隐蔽尤其是做判等的时候很多人会随手写if (a b)根本没意识到这是一个对象比较而不是数值比较。第二个坑是强制转换。Java里(int) longValue不会自动检查溢出只会静默截断。比如(int) 3000000000L结果是-1294967296。这个原理跟Fastjson的截断机制完全一致。很多人以为强制转换是安全的其实它比隐式转换更危险因为编译器不会给任何提示。如果你在写代码时确实需要做类型转换一定要先判断数值范围再做转换至少用Math.toIntExact()这种带溢出检查的方法兜底。第三个坑就是这次踩到的JSON反序列化。接收端DTO字段类型不匹配序列化库静默帮你转成了错误值。这个坑在前后端分离项目、微服务接口对接项目里尤其常见因为发送方和接收方的开发往往不是同一个人接口文档定义的是Long但接收方代码里声明的却是Integer如果没有人仔细核对问题就会在流量高峰期突然爆发。3.3 参数计算与根因链从2147483647到-2147483648为了把根因链讲透我跑了一组具体的计算。雪花算法生成的订单号比如2458888812345678901。这个数字的二进制表示是64位我们取其低32位来分析Long原始值二进制简化0010001000110000100010000000000000000000000101011101011001010101低32位000000000000000000000000101011101011001010101但Fastjson的castToInt实现里对超出Integer范围的数值直接通过(int) longValue处理这导致结果变成-1838538091这个计算过程其实很简单Java中int是32位有符号整数范围是[-2147483648, 2147483647]。任何超过这个范围的long在强制转换成int的时候都会把高32位全部砍掉只保留低32位。由于最高位是符号位如果低32位最高位恰好是1转换后的结果就是负数。这正是负数的来源。所以整个根因链可以概括为业务库订单号是Long没问题同步服务发送端是Long没问题报表服务接收端DTO声明为Integer这里出问题了Fastjson反序列化时按Integer类型解析超大Long发生溢出截断截断后的负数订单号写入了报表库最终展示在页面上这条链路上只要中间任何一个环节做了严格的类型校验或者接收端DTO类型跟发送端一致问题都不会发生。但现实没有如果这种不起眼的类型不匹配制造了一次为期一周的“灵异事件”。4. 问题修复与防御式改造4.1 最小修复把Integer改成Long并重放数据紧急修复的方案很简单把报表服务接收DTO里的orderId从Integer改成Long。改完这个字段之后重新部署服务再做一次全量数据重放。重放的时候要注意历史脏数据也要一并处理。我们当时的做法是先停掉跑批任务防止新的脏数据继续写入把所有订单数据的快照重新从业务库同步一遍同步之前删除报表库里的历史错误数据或者通过逻辑判断只更新错误订单等全量重放完成后再开启跑批任务这里要补充一个细节不要直接在原表上UPDATE修复因为你不知道哪些数据受到过污染。最稳妥的做法是删掉报表库的数据重新从源头拉取并计算。如果订单数据量特别大可以只删除已发现异常的订单然后单独补偿这些订单的数据。但前提是你对“哪些订单异常”有完整的判断条件比如订单号为负数、金额超过理论最大值等。修复完成后我再用之前测试的模拟数据跑了一遍订单号已经能正确接收负数问题消失数字精度也正常了。这时候运营侧反馈的“九千亿金额”问题基本解除。因为金额字段其实也受到了牵连——订单号错了之后关联查询错乱了导致金额汇总时把几笔正常订单的金额加到了一个异常桶里。4.2 彻底改造统一ID字段类型规范、序列化配置、单元测试紧急修复只是止血真正要做的是防止同样的问题再次发生。我做了一套防御式改造分三层第一层是规范层面在项目组内推行接口字段类型强制规范所有涉及ID的字段一律使用Long或者String禁止使用Integer。为什么不用Integer因为任何表主键、分布式ID、雪花ID阈值都非常容易超过Integer最大值。凡是用数字类型的ID都默认用Long。如果前端展示有精度担忧可以在接口层转成String返回但内部处理和数据库存储必须是Long。第二层是框架配置层面对项目里的Fastjson配置做一次全面排查升级到安全版本并开启严格类型检查。Fastjson有一个Feature.ErrorOnEnumCast之类的能力虽然不完全覆盖整数溢出场景但新版本对类型转换的校验加强了很多。更关键的配置是ParserConfig.getGlobalInstance().setSafeMode(true)开启安全模式后Fastjson会拒绝部分危险的反序列化行为包括某些自动类型转换。其实我们后续把大部分接口的JSON库从Fastjson切换到了Jackson因为Jackson在类型不匹配时默认抛异常更容易在开发阶段发现问题。切换过程中也踩了一些兼容性的坑比如日期格式、空值处理规则不一样但整体上还是值得的。如果你项目里没有历史包袱我建议一开始就选Jackson踩坑率低很多。第三层是测试层面给接收DTO写单元测试专门覆盖订单号超过Integer最大值的场景。这个测试必须有两个方向发送端是Long类型接收端是Long类型确保正常解析如果接收端错误定义为Integer测试应该立刻失败并报警测试数据的构造也要注意不能只用正常业务数据。要专门造边界值比如2147483647、2147483648、Long.MAX_VALUE。这几个数字在测试里必须出现因为它们是类型转换最容易出问题的边界。很多时候Bug就藏在一条看不见的边界线上。4.3 从修Bug到修规范前后端字段类型也要对齐这个问题还牵扯到另一个层面前后端接口对接的规范。我们的前同事在做接口联调时经常遇到一个问题后端文档里写的是Long类型但在前端代码里展示时发现精度丢失。因为JavaScript的Number类型是64位双精度浮点数超过Number.MAX_SAFE_INTEGER也就是9007199254740991时整数精度就无法保证了。所以很多架构规范里都要求超过16位的ID在传给前端时统一转成字符串。这次事件中虽然问题出在后端到后端的传输但前后端之间的类型一致性同样值得注意。我遇到过不少项目后端返回Long型ID前端拿到后精度丢失最后传回后端就变成另外一个数字导致数据查不到。这种问题更常见而且更难排查因为前端展示看起来非常正常只有到细节操作时才暴露。所以行业里会约定前端展示层ID用String后端存储和内部传输用Long所有跨服务调用的DTO和接口文档必须严格对齐字段类型。还有一点是接口文档的维护。很多团队用Swagger/OpenAPI来管理接口文档但如果字段类型定义错误接口文档本身也会误导人。我们在这次修复中专门检查了报表服务所有对外接口的Schema定义确保orderId字段的类型是int64而不是int32。工具只是辅助关键还是人要对字段类型负责。5. 常见问题与排查技巧实录5.1 那些容易被误判为灵异Bug的类型转换场景这次排查完之后我回顾了一下过往经历发现类型转换引发的Bug其实特别多只是很多场景不会立刻联想到类型上。我把几个经典场景列出来如果你遇到类似现象可以优先怀疑类型问题金额汇总偶尔多出一大截。这往往是某一笔金额在JSON反序列化时被转换成了浮点类型由于浮点精度问题导致金额出现微小偏差累加久了就变成大误差。状态值变成负数或极大数。这往往是枚举值或状态码字段的类型定义不一致比如接收端是byte、发送端是int超出范围就出问题。ID查询偶尔查不到数据。这可能是ID在传输过程中精度丢失或者类型截断导致实际查询的ID跟源数据不一致。时间戳错乱。时间戳如果被定义成Integer在2038年之后就没法用了但这属于后患型问题平时不触发一旦触发就是大面积瘫痪。另外还要提一个跟类型转换类似的场景字符编码问题。有时你在日志里看到一堆乱码会把它们当成编码转换问题其实有可能是字段类型不对打印的时候就异常了。排查时要分清现象背后的本质不要只看表层。5.2 一线排查常规误区我这一周里踩了不少坑也看到一起排查的同事踩坑。我把这些误区梳理一下算是给大家做一次减法希望你们遇到类似问题时能少走弯路误区一是“疯狂打日志”。日志打得多确实能看到更多细节但如果不带着假设去打日志只会收获大量无效信息。我前两天的日志基本白打因为方向错了。打日志一定要带着验证目标去设计比如你想确认“订单号在哪个环节发生变化”那就要在同步服务发送前打一次、报表服务接收后打一次两边对比而不是随机在代码里加几行print。误区二是“对着数据库反复查”。遇到脏数据大家习惯性去数据库里查来查去想从数据特征里发现规律。这个方法不是不行但它只能发现“数据确实错了”并不能告诉你“数据在哪一步开始错的”。更高效的做法是沿着数据流从源头开始逐节点校验。每经过一个节点做一次数据快照把出错节点精确定位到某一个函数甚至某一行。误区三是“测试环境复现不了就放弃”。测试环境复现不了不代表问题不存在只代表触发条件没有达到。这时候要主动分析生产环境和测试环境的差异比如数据量、并发量、字段取值范围、依赖组件版本。大多数“生产环境专用Bug”都是测试数据没构造到位导致的特别是边界值缺失。误区四是“一旦定位到就立刻改”。定位到根因之后不要急着改代码先把修复方案的影响面想清楚。比如我把Integer改成Long之后会不会影响其他地方对这个DTO的引用会不会影响数据库表结构会不会影响前端展示这些都要提前评估。本次修复中我们把DTO字段从Integer改Long所有引用该字段的方法都要重新编译部署如果只改了单个服务很可能导致下游或上游服务类型不匹配出现新的问题。5.3 把Bug消灭在生命周期前段的规范建议最后聊一聊Bug生命周期的问题。一个Bug从提交到修复会经历“发现”、“定位”、“修复”、“验证”、“回归”这几个阶段。类型转换类Bug的高发环节往往在“集成测试”和“代码评审”里漏掉的因为单测和冒烟测试很少覆盖边界值。因此我强烈建议把以下几件事写进团队规范凡是超过int范围的ID字段代码评审时必须有人提出质疑为什么用Integer必须给出合理解释所有RPC接口的DTO字段类型必须以接口文档为准禁止自行修改JSON序列化库统一选型禁止各个服务用不同的库和版本防止行为不一致单元测试必须包含整数边界值测试把2147483647、2147483648、9223372036854775807这些关键数放到测试用例里联调环境的数据造数工具必须有“极大数据”选项不能只用正常数据如果团队里能做到这几条我相信很多“灵异Bug”在开发阶段就会被拦截下来根本不会留到生产环境去制造惊吓。这次排查的过程确实很煎熬但收获也很大。我后来养成了一个习惯写任何DTO、任何接口实体类时都会对每个数字字段多想一想——这个字段真的可能超过Integer范围吗这个字段真的适合用浮点类型吗有没有可能被未来数据打脸另外我还养成了一个习惯所有的接口联调文档我都会自己核对一遍字段类型不放心的话就双端同步打印请求体和响应体直接对比字段名和类型。这个习惯帮我避免了好几次潜在的类型转换问题也让我对“类型不安全”有了更高的警惕。如果你正在排查一个怎么都定位不到的灵异Bug给你一个非常具体的建议把所有数字类型的字段列一张清单逐一检查它们的声明类型和实际值的取值范围。很多看似玄学的问题本质上都是类型不匹配、精度溢出或者隐式转换导致的。先怀疑类型再怀疑逻辑能省下不少时间。
RELATED READING

延伸阅读

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