线上告警里出现了一条很奇怪的记录:某个金额字段的值被写成了Infinity,紧接着接口返回 500,前端整页白屏。翻代码看,计算逻辑写得规规矩矩,除法、乘法、格式化都在,唯独漏了一个判断——Double.isInfinite。这不是什么罕见事故,Double类型的参数值返回无穷大,几乎是每个写过浮点运算的后端都踩过一次的坑,区别只在于你是被前端同学发现,还是被线上监控发现。
这篇内容围绕Double无穷大问题展开,把isInfinite这个看起来只有一行的方法,拆到 IEEE 754 的位布局层面去讲清楚:无穷大是怎么产生的、isInfinite/isNaN/isFinite三者判定边界差在哪、JSON 序列化为什么一遇到Infinity就出问题、数据库落库时不同存储引擎的态度为什么完全相反、以及一套从入口校验到出口兜底的防御方案怎么落地。不管你是刚接触浮点类型的初学者,还是已经写过几年业务代码、被NaN和Infinity反复折腾过的老手,都能从里面找到可以直接抄作业的配置和代码。
1. 问题全景:无穷大到底是从哪条路径混进来的
很多人第一次看到Double.isInfinite这个 API 时的反应是“这玩意儿谁会用到”。等到线上真的出现Infinity,才发现它不是边缘功能,而是浮点数规范里一个必须存在的、正常得不能再正常的取值。搞清楚它从哪来,比记住怎么判断重要得多。
1.1 IEEE 754 里的三类特殊值:这是规范,不是 bug
Double在内存里占 64 位,按 IEEE 754 双精度规范拆成三段:1 位符号位、11 位指数位、52 位尾数位。指数位的取值范围是 0 到 2047,其中 0 和 2047 是保留值,正常有限数的指数落在 1 到 2046 之间,实际指数需要减去偏移量 1023。
保留值就对应了那几类“非有限数”:
| 指数位(11 位) | 尾数位(52 位) | 含义 | 十进制示例 |
|---|---|---|---|
| 全 0 | 全 0 | 正负零 | 0.0/-0.0 |
| 全 0 | 非 0 | 非规格化数 | 极小的有限值 |
| 1 ~ 2046 | 任意 | 规格化有限数 | 1.5、-3.14 |
| 全 1(2047) | 全 0 | 正负无穷大 | Infinity/-Infinity |
| 全 1(2047) | 非 0 | NaN(非数) | NaN |
这个设计的意图很明确:浮点运算不应该因为结果超出可表示范围就崩溃或者返回一个“未定义”的垃圾值,它需要一个能继续参与后续运算的、语义清晰的结果。所以你写1.0 / 0.0,编译器不会拦你,运行时也不会抛ArithmeticException,它老老实实返回Infinity。整数除法1 / 0抛异常,浮点除法1.0 / 0.0返回无穷大,这两者的差异不是语言设计者的疏忽,而是两套数值体系的根本区别。
把位模式具体化一下会更直观。Double.POSITIVE_INFINITY的 64 位二进制是符号位 0、指数位 11 个 1、尾数 52 个 0,写成十六进制就是0x7FF0000000000000L;负无穷大把符号位改成 1,即0xFFF0000000000000L。你在调试器里看到Infinity,本质上是看到了这个特定的位模式。
注意:
Double.doubleToLongBits和Double.doubleToRawLongBits对 NaN 的处理不一样,前者会把所有 NaN 归一化成同一个位模式,后者保留原始位。做位级比较时选错方法,会出现“两个看起来都是 NaN 的值比较结果不一致”的怪现象。
1.2 参数变成无穷大的五条常见路径
我在实际项目里归纳下来,Double返回无穷大基本逃不出下面五条路径,按出现频率从高到低排:
第一条,浮点除法没做除数判断。这是最经典的一条。业务代码里算转化率、算平均值、算折扣,除数来自另一个字段,那个字段是 0 的时候,a / b直接返回Infinity或-Infinity(取决于被除数的符号),如果被除数也是 0,则是0.0 / 0.0 = NaN。整个过程没有任何异常抛出,值就这么静悄悄地流到了下游。
第二条,累加或乘法溢出。Double.MAX_VALUE约等于1.7976931348623157E308,这个数字大到离谱,但在某些场景下真的会被突破:比如复利计算、指数增长模型、概率连乘、以及把纳秒时间戳平方之类的误操作。1e308 * 10的结果就是Infinity,不报错,不告警。
第三条,字符串解析。Double.parseDouble("Infinity")会正常返回Infinity,Double.parseDouble("1e400")同样返回Infinity,两者都不抛异常。如果你的入参来自用户输入、第三方接口或者配置文件,对方传了一个超出范围的值,解析这一关是拦不住的。
第四条,JSON 反序列化。这是最隐蔽的一条。外部系统传过来一个1e400,反序列化框架按double类型去解析,溢出成Infinity,框架不报错,你的 DTO 里就安安静静躺着一个无穷大。等到这个对象被再次序列化发出去,问题才在另一端爆出来。
第五条,类型转换。BigDecimal转double时,如果数值超出double的表示范围,doubleValue()返回Infinity;float的最大值远小于double,但从float转double不会溢出,反过来double转float溢出则得到Infinity。这类转换往往藏在工具类里,不注意根本想不到。
1.3 为什么这个检查总是“事后才想起”
开发环境里数据干净,测试用例里除数都是精心挑过的非零值,单元测试覆盖率再高也覆盖不到“除数为 0 且被除数也是 0”这种组合。等到线上遇到一条脏数据,Infinity顺着调用链一路往下走,可能在序列化层才炸,也可能落库时才炸,还可能落到前端渲染时才炸,位置漂移得厉害,排查成本成倍上升。
更麻烦的是,Infinity在很多环节是“静默通过”的。日志里打印出来是字符串Infinity,肉眼扫过去容易当成普通文本忽略;数据库在非严格模式下可能把它转成 0 或者直接截断;某些前端框架的JSON.parse会直接抛语法错误,报错信息指向的是整个响应体,跟那个Infinity字段八竿子打不着。这些特性叠加起来,就造成了“检查总是补在最晚的那一次事故之后”的局面。
2. 核心原理拆解:isInfinite 到底在判什么
搞清楚了来源,接下来得把判定逻辑本身吃透。isInfinite的实现只有一行位运算,但它和isNaN、isFinite之间的边界关系,恰恰是最容易写错的地方。
2.1 从位模式手推一次判定过程
以 Java 为例,Double.isInfinite(double v)的内部逻辑可以理解为:取这个double的 64 位表示,判断指数位是否全为 1 且尾数位是否全为 0。换成代码等价形式就是:
public static boolean isInfinite(double v) { return (v == Double.POSITIVE_INFINITY) || (v == Double.NEGATIVE_INFINITY); }JDK 里当然不会这么写,但语义完全一致。理解这一点的价值在于:isInfinite的判断是精确的位级比较,不存在精度误差。1e308 * 10得到的Infinity和字面量Double.POSITIVE_INFINITY是同一个位模式,比较结果为true,这一点可以放心。
有个细节值得单独拎出来:Infinity参与算术运算的规则和数学直觉基本一致,但有几处例外需要记住。Infinity + 1 = Infinity,Infinity - Infinity = NaN,Infinity * 0 = NaN,Infinity / Infinity = NaN,1 / Infinity = 0.0(带符号)。也就是说,无穷大一旦进入运算链,很可能在下一次运算里退化成NaN,这时候只判isInfinite就漏了。这也是后面要强调“用isFinite一次判干净”的原因。
2.2 isInfinite、isNaN、isFinite 的判定边界对照
这三个方法的名字很像,作用范围却互相咬合,我把它们在各种取值下的返回值整理成一张表,建议直接存下来当速查用:
| 输入值 | isInfinite | isNaN | isFinite(Java 8+) |
|---|---|---|---|
0.0 | false | false | true |
-0.0 | false | false | true |
1.5 | false | false | true |
Double.MAX_VALUE | false | false | true |
Double.POSITIVE_INFINITY | true | false | false |
Double.NEGATIVE_INFINITY | true | false | false |
Double.NaN | false | true | false |
null(装箱类型) | NPE | NPE | NPE |
从表里能读出两个关键结论。第一,isInfinite和isNaN是互斥的,一个值不可能同时为无穷大和 NaN,所以“想用一个判断覆盖所有异常值”是不成立的。第二,Double.isFinite才是那个“一次判掉所有非正常值”的方法,它等价于!isInfinite(v) && !isNaN(v)。
这里有个版本坑必须提:Double.isFinite(double)是 Java 8 才加入的静态方法。如果你的项目还在 Java 7 或者更早的版本上,编译器会直接报找不到符号。老项目里常见的替代写法是自己封装一个工具方法:
public static boolean isFinite(double v) { return !Double.isNaN(v) && !Double.isInfinite(v); }顺序不能反,!Double.isInfinite(v) && !Double.isNaN(v)结果一样,但从语义清晰度上,先判 NaN 更符合“排除异常”的思维习惯。另外,Float有完全对应的Float.isFinite,同样从 Java 8 开始提供。
2.3 装箱类型的三个隐性陷阱
Double是包装类,double是基本类型,两者在无穷大判断上的行为差异,是 bug 的高发区。
陷阱一:null 引发的空指针。Double d = null; d.isInfinite();直接 NPE。DTO 字段用包装类型接收外部参数时,这个风险始终存在。判断之前必须先做 null 检查,或者干脆用Objects.requireNonNullElse之类的工具兜一个默认值再判。我见过一个真实案例,接口在参数缺失时返回 500,排查了两小时,最后定位到就是对一个可能为 null 的Double调了isInfinite()。
陷阱二:==比较的是引用。两个Double对象用==比较,比较的是对象地址,不是数值。虽然Double.valueOf在部分区间有缓存,但Double的缓存范围没有规范保证,把==用在数值比较上属于纯粹的错误用法。要比较数值,用doubleValue()拆箱后比,或者用Double.compare(a, b) == 0。
陷阱三:equals对特殊值的处理反直觉。Double.valueOf(Double.NaN).equals(Double.valueOf(Double.NaN))返回true,而Double.NaN == Double.NaN返回false。反过来,Double.valueOf(0.0).equals(Double.valueOf(-0.0))返回false,而0.0 == -0.0返回true。这两组结果完全相反,原因在于equals走的是位比较,==走的是数值比较。做集合去重或者 Map 的 key 时,这个差异会直接导致“看起来相同的值被当成两个”。
提示:需要位级严格比较时用
Double.compare,它的行为与equals一致(NaN 等于 NaN,0.0 大于 -0.0);需要数值语义比较时先拆箱再用==。选哪个取决于你的业务场景,但千万别混着用。
3. 各环节的表现差异:同一条数据,五个地方五种反应
Infinity最坑人的地方不在产生,而在传递。同一个无穷大值,在 Java 内存里、JSON 序列化时、数据库落库时、前端解析时,表现完全不同。把这条链路摸清楚,排查效率能提升一大截。
3.1 Java 内存侧:一切都是静默的
在 JVM 内部,Infinity就是一个普通的double值,参与运算、传递、比较都不会有任何异常。System.out.println(Double.POSITIVE_INFINITY)输出字符串Infinity,String.valueOf同理,Double.toString也是。这种“静默”是排查困难的核心原因——没有任何错误信号,日志里也不会出现堆栈。
一个常被忽略的点是日志采集。如果你的日志平台按数值类型索引某个字段(比如把amount映射成double类型),突然进来一条Infinity的记录,索引写入可能直接失败或者把该字段的类型推断搞乱,后续正常的数值记录也跟着受影响。这类问题排查起来极其费劲,因为错误现场在日志系统,根因在业务代码。
3.2 JSON 序列化:这里是第一个大炸点
JSON 规范(RFC 8259)明确规定数值不能是Infinity或NaN,只允许有限数。所以任何序列化框架遇到这两个值,都必须在“输出合法 JSON”和“忠实表达原值”之间做取舍,不同框架的选择不一样:
| 框架 | 默认行为 | 结果 |
|---|---|---|
| Jackson | 开启QUOTE_NON_NUMERIC_NUMBERS,把非有限数输出成带引号的字符串 | {"a":"Infinity"},字段类型从数字变成字符串 |
| Jackson(关闭该特性) | 输出裸字面量 | {"a":Infinity},严格解析器直接报语法错误 |
| Gson | 较新版本对非有限数抛IllegalArgumentException | 序列化直接失败,整个响应中断 |
| 其他框架 | 版本间行为不一致,有的输出裸字面量,有的转 0 | 不可预期 |
Jackson 的默认行为看起来“安全”,实则埋了另一颗雷:前端拿到的"Infinity"是个字符串,如果前端做了typeof判断、或者直接拿去做数值运算,"Infinity" * 2得到NaN,parseFloat("Infinity")得到Infinity,整条数据链路继续污染。而且前端如果用 TypeScript 定义了number类型,运行时会拿到一个字符串,类型系统完全失效。
关闭QUOTE_NON_NUMERIC_NUMBERS更危险。JSON.parse('{"a":Infinity}')会抛SyntaxError: Unexpected token I,而且这个错误是在解析整个响应体时抛的,报错位置指向的是响应开头,很难定位到具体字段。前端同学看到的就是“接口挂了”,后端同学看到的就是“接口返回 200 正常”,双方各执一词。
3.3 数据库落库:MySQL 拒绝,PostgreSQL 接受
数据库这一层的差异非常典型,值得单独说。
MySQL 的DOUBLE字段遇到Infinity,在严格模式下直接报Out of range value for column,整个INSERT或UPDATE失败;在非严格模式下会写入 0 并产生一条 warning,数据被静默篡改。两种结果都不好,前者让业务中断,后者让数据失真且难以追溯。
PostgreSQL 的double precision则完整支持特殊值,Infinity、-Infinity、NaN都能正常存储,甚至连字面量'Infinity'::double precision都能直接写。这种差异意味着:同一个 ORM 映射,从 MySQL 迁到 PostgreSQL,原先被数据库挡住的Infinity突然能写进去了,下游的统计报表开始出现无意义的极值。这类迁移事故我见过不止一次。
| 数据库 | Infinity写入行为 | 风险 |
|---|---|---|
| MySQL(严格模式) | 报错Out of range value | 业务中断 |
| MySQL(非严格模式) | 转成 0 + warning | 数据静默失真 |
| PostgreSQL | 正常存储 | 污染下游统计与聚合 |
| 大多数时序数据库 | 通常拒绝或转 null | 依实现而定 |
注意:做数据迁移或者多数据库兼容时,务必在写入前统一做有限性校验。不能依赖数据库帮你兜底,因为不同库的兜底方式完全相反。
3.4 前端 JavaScript:isFinite 和 Number.isFinite 不是一回事
前端拿到Infinity之后,最常见的判断方式是全局isFinite()。这个方法有个致命特性:它会先做类型转换。isFinite('123')返回true,isFinite(null)返回true(null转成 0),isFinite('')也返回true,isFinite('Infinity')这才返回false。也就是说,如果后端把Infinity序列化成了字符串"Infinity",全局isFinite判出来是false,看起来“碰巧对了”,但同一段代码遇到null或空字符串时会给出完全错误的结论。
Number.isFinite()不做类型转换,只对真正的number类型且有限的值返回true。Number.isFinite('123')返回false,Number.isFinite(null)返回false。这才是做数据校验该用的方法。
// 不要这样 if (!isFinite(value)) { /* null、''、'123' 都会被误伤 */ } // 应该这样 if (typeof value === 'number' && !Number.isFinite(value)) { // 精确命中 Infinity、-Infinity、NaN }另外,前端做数值展示时,Infinity.toFixed(2)返回字符串"Infinity",Intl.NumberFormat格式化Infinity会输出"∞",这些表现都不会报错,只是静静地显示一个不该出现的符号。用户看到页面上一个无穷大符号,投诉电话就打过来了。
4. 落地实操:一套从入口到出口的三层防御
原理讲完,回到最实际的问题:代码怎么写。我的做法是分三层——入口拦截、过程防抖、出口兜底。三层不必同时上,但至少要有两层,单靠一层迟早会漏。
4.1 第一层:在参数入口就把非有限数挡掉
对外暴露的接口,入参校验是第一道闸门。Java 生态里最直接的方式是自定义一个校验注解,配合 Bean Validation 使用。思路是:先判 null(允许为空的情况),再判Double.isFinite,一次覆盖Infinity、-Infinity、NaN三种异常值。
@Documented @Constraint(validatedBy = FiniteNumberValidator.class) @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface FiniteNumber { String message() default "数值必须是有限数"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; boolean allowNull() default false; }对应的校验器实现:
public class FiniteNumberValidator implements ConstraintValidator<FiniteNumber, Double> { private boolean allowNull; @Override public void initialize(FiniteNumber annotation) { this.allowNull = annotation.allowNull(); } @Override public boolean isValid(Double value, ConstraintValidatorContext context) { if (value == null) { return allowNull; } // 一次判掉 Infinity、-Infinity、NaN return Double.isFinite(value); } }这里选择Double.isFinite而不是Double.isInfinite,原因是NaN同样是非法的业务值,用isInfinite只能挡住一半。如果项目还在 Java 8 以下,换成!value.isNaN() && !value.isInfinite()即可。
除了注解式校验,还有个更省事的做法:在 DTO 的 setter 里做统一处理。这种写法侵入性更强但覆盖面更广,适合那些没有引入校验框架的老项目。
public void setAmount(Double amount) { if (amount != null && !Double.isFinite(amount)) { throw new IllegalArgumentException("金额字段非法: " + amount); } this.amount = amount; }提示:对方传
1e400这种字符串时,如果框架直接把1e400解析成Infinity再赋值,setter 里是能拦住的;但如果框架先做字符串校验再解析,可能抛出的是另一类异常。两种路径的报错信息要统一,否则前端拿到的错误提示五花八门。
4.2 第二层:在计算过程中掐断源头
入口校验只能挡住外部传入的值,内部计算产生的Infinity还得靠过程防护。核心原则就一条:任何除法、开方、对数、幂运算之前,先检查操作数会不会导致非有限结果。
最容易出问题的是除法。业务代码里大量存在a / b这种写法,b来自另一个字段、另一个查询结果或者某个配置项。我的习惯是封装一个安全除法工具:
public static double safeDivide(double dividend, double divisor, double defaultValue) { if (divisor == 0.0) { return defaultValue; } double result = dividend / divisor; return Double.isFinite(result) ? result : defaultValue; }divisor == 0.0这个判断能同时覆盖+0.0和-0.0,因为0.0 == -0.0为true。后面的isFinite是双保险,用来兜住Infinity / Infinity这类虽然除数为非零、但结果依然是 NaN 的情况。
金额、汇率、比例这类对精度敏感的场景,根本不应该用double。BigDecimal的优势不只是精度,还在于它对非法运算的处理更直接:BigDecimal除以零会抛ArithmeticException,而不是返回一个无穷大。这种“失败得快”的特性在业务代码里是好事,错误当场暴露,不会顺着调用链往下污染。
// 金额计算用 BigDecimal,除零直接抛异常 BigDecimal result = amount.divide(rate, 4, RoundingMode.HALF_UP);但BigDecimal转double这一步要小心。new BigDecimal("1e400").doubleValue()返回Infinity,Double.parseDouble那种“静默溢出”的问题在这里同样存在。凡是跨类型转换的边界,都要补一次isFinite检查。
累加场景的溢出相对少见,但也有。比如统计某个指标的累计值,几亿条记录加起来理论上有溢出的可能。这种场景要么用BigDecimal累加,要么在每次累加后检查结果,超过阈值就截断并告警,别让Infinity悄悄混进汇总结果。
4.3 第三层:在序列化出口做统一兜底
前面两层都做了,理论上不会再有Infinity流出去。但工程实践里永远有“理论上不会”的情况——反射赋值、反序列化、框架内部转换,总有绕开 setter 的路径。所以出口兜底是必要的,我一般会配一个全局的序列化处理。
以 Jackson 为例,最干净的做法是注册一个自定义序列化器,把非有限数统一转成null或者抛出业务异常,而不是让它变异成字符串:
public class FiniteDoubleSerializer extends JsonSerializer<Double> { @Override public void serialize(Double value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); return; } if (!Double.isFinite(value)) { // 方案 A:输出 null,前端按空值处理 gen.writeNull(); // 方案 B:记录告警日志后输出 null // Logger.warn("发现非有限数: {}", value); return; } gen.writeNumber(value); } }注册方式有两种。全局注册影响面大但省事:
SimpleModule module = new SimpleModule(); module.addSerializer(Double.class, new FiniteDoubleSerializer()); module.addSerializer(Double.TYPE, new FiniteDoubleSerializer()); objectMapper.registerModule(module);局部注册更精准,只挂在会出问题的字段上:
public class OrderDTO { @JsonSerialize(using = FiniteDoubleSerializer.class) private Double amount; }两个都要注册的原因在于:Double.class管的是包装类型字段,Double.TYPE管的是基本类型double字段。只注册一个的话,另一种字段依然走默认序列化器,问题照旧。
如果你不想写自定义序列化器,Jackson 也提供了现成配置:
// 关闭“非数字值写成字符串”的特性,改为抛异常 objectMapper.configure(JsonWriteFeature.WRITE_NAN_AS_STRINGS.mappedFeature(), false); objectMapper.enable(SerializationFeature.FAIL_ON_SELF_REFERENCES);但坦率说,直接抛异常会让整个接口失败,对于“个别字段异常”这种场景太重了。宁可输出null加上一条告警日志,让业务先跑通,再回头修根因。这是我踩过几次坑之后的取舍:出口兜底的目标是“不让脏数据污染下游”,不是“让整个请求失败”。
4.4 一个完整的防御链路示例
把三层串起来,一个订单金额计算的完整流程大概长这样:
@Service public class OrderAmountService { public OrderAmountVO calculate(OrderCalcRequest request) { // 第一层:入口已由 @FiniteNumber 注解拦截 double unitPrice = request.getUnitPrice(); double quantity = request.getQuantity(); double discountRate = request.getDiscountRate(); // 第二层:计算过程防护 double rawAmount = safeMultiply(unitPrice, quantity); double finalAmount = safeDivide(rawAmount, discountRate, rawAmount); if (!Double.isFinite(finalAmount)) { log.warn("金额计算出现非有限数, unitPrice={}, quantity={}, discountRate={}", unitPrice, quantity, discountRate); throw new BizException("金额计算异常,请核对参数"); } // 第三层:出口序列化器兜底 return OrderAmountVO.builder() .amount(finalAmount) .build(); } private static double safeMultiply(double a, double b) { double r = a * b; return Double.isFinite(r) ? r : 0.0; } }日志那一行是关键。出问题时,只看“金额计算异常”这五个字排查起来很痛苦,把三个入参打出来,能直接定位是哪个参数导致的问题。日志里的Infinity会以字符串形式打印,扫描日志时用关键词Infinity搜索,往往能一次性把同类问题全捞出来。
4.5 监控与排查:怎么快速定位到具体字段
系统上线后,Infinity还会不会出现,取决于数据质量。我的做法是加一个轻量的埋点:凡是走过safeDivide、safeMultiply这类工具方法并且触发了兜底逻辑的,就上报一次计数。
private static double safeDivide(double dividend, double divisor, double defaultValue) { if (divisor == 0.0) { Metrics.counter("calc.divide.by.zero").increment(); return defaultValue; } double result = dividend / divisor; if (!Double.isFinite(result)) { Metrics.counter("calc.infinite.result").increment(); log.warn("除法产生非有限数, dividend={}, divisor={}", dividend, divisor); return defaultValue; } return result; }有了这个计数,能力就很强了:某个接口的calc.divide.by.zero突然飙升,说明上游数据里出现了大量零值;某个服务的calc.infinite.result有增长,说明出现了数值溢出,可能是业务量涨了,也可能是某次代码改动引入了新的乘数。这种主动发现比等着用户投诉要高效得多。
5. 常见问题与排查实录
下面这些是我和身边同行实际遇到过的场景,整理成速查表,遇到问题时按图索骥能省不少时间。
5.1 高频问题速查表
| 现象 | 最可能的原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 接口 500,日志报 JSON 解析错误 | 序列化输出了裸Infinity字面量 | 抓包看响应体,搜索Infinity | 关闭裸输出,配自定义序列化器 |
前端页面显示∞或Infinity | 后端输出了字符串"Infinity" | 检查 Jackson 的QUOTE_NON_NUMERIC_NUMBERS | 统一转null,前端展示层兜默认值 |
数据库报Out of range value | MySQL 严格模式拒绝Infinity | 看 SQL 日志和字段值 | 写库前做isFinite校验 |
| 聚合报表出现极端大值 | PostgreSQL 存了Infinity | 查WHERE col = 'Infinity'::float8 | 清洗历史数据,加写入校验 |
| 单元测试本地通过、线上失败 | 本地数据干净,线上有零值或脏数据 | 复现线上入参 | 补边界用例,别只测正常值 |
两个值equals为 false 但看起来一样 | 0.0和-0.0的位比较差异 | 用Double.compare复核 | 统一用位级比较,注意符号位 |
| 日志平台字段类型冲突 | 采集到Infinity破坏了数值类型推断 | 查索引 mapping | 日志输出前格式化,或改字符串字段 |
5.2 三个真实排查过程的还原
案例一:白屏事故。某系统的报表接口,前端整页白屏,控制台报SyntaxError: Unexpected token I in JSON at position 128。后端日志里接口返回 200,响应体看起来“正常”。把响应体贴到在线 JSON 校验工具里,发现第 128 个字符位置是一个裸的Infinity。根因是序列化框架的配置在版本升级时被重置了,QUOTE_NON_NUMERIC_NUMBERS被关掉,非有限数直接输出的裸字面量。修复方案是配置项显式写死,并且加了一条序列化单测。
案例二:数据迁移后的统计失真。系统从一种数据库迁到另一种,迁移后月度统计报表的“平均客单价”变成了一个天文数字。排查发现源库会把Infinity转成 0 或报错,目标库原样存下来,聚合函数AVG一算,均值直接被拉爆。处理方式是先跑一遍清洗 SQL 把非有限值更新成null,再在写入层加校验。
案例三:偶发的空指针。接口偶发 500,堆栈指向d.isInfinite()那一行。字段声明是Double,大部分请求里都有值,偶尔某个渠道不传这个参数,就是null。修复很简单,加 null 判断或者用Objects.requireNonNullElse兜底。但这个案例的价值在于提醒:包装类型的每个方法调用前,都要先问一句“它可能是 null 吗”。
5.3 几个容易被忽略的避坑点
Double.toString(Double.POSITIVE_INFINITY)返回的是"Infinity",而Double.toString(Double.NaN)返回"NaN"。这两个字符串都会被原样写进日志,做日志检索时用grep "Infinity"能命中,但大小写敏感,别写成infinity。
序列化配置的优先级问题也值得注意。如果你同时用了全局ObjectMapper和@JsonSerialize注解,注解的优先级更高,会覆盖全局配置。项目里如果有多个ObjectMapper实例(比如一个给 HTTP 用、一个给缓存用),很容易出现“这个接口正常、那个接口异常”的情况,排查时先确认走的是哪个实例。
还有一个隐蔽点:使用String.format("%.2f", value)格式化Infinity,结果是"Infinity",不会抛异常也不会返回空。做金额格式化时如果用这种方式拼接 SQL,就会生成INSERT INTO t VALUES (Infinity)这样的非法语句。格式化之前必须做有限性判断,这是硬性要求。
6. 我在这个坑里攒下的几点经验
isInfinite这个 API 本身没什么复杂度,难的是把它放在对的位置、对的时机。我一直以来的做法是:默认认为“任何double都可能是非有限的”,除非能明确证明它不可能。这个前提听起来悲观,但它能让你在写每一行浮点运算时多问一句“除数为零会怎样”。
工具方法不要怕多。safeDivide、safeMultiply、requireFinite这几个方法在我参与的项目里几乎都会出现,代码量不到五十行,省下的排查时间却难以估量。把它们放在公共工具包里,新人也能直接用,比在代码评审时反复强调“记得判零”有效得多。
测试用例的写法也得改。别只写assertThat(divide(10, 2)).isEqualTo(5),补上divide(10, 0)、divide(0, 0)、divide(Double.MAX_VALUE, 0.0001)这几组,让非有限数在单元测试阶段就暴露出来。0.0 / 0.0会得到NaN而不是Infinity,这一组尤其容易漏。
数据库字段设计上,如果业务上根本不允许出现极端值,可以考虑用DECIMAL类型替代DOUBLE,从存储层面就堵住可能性。DECIMAL的数值范围有明确上界,超范围直接报错,不会像DOUBLE那样悄悄变成无穷大。
最后分享一个小技巧:在开发和测试环境的序列化配置里,把WRITE_NAN_AS_STRINGS关掉,让非有限数在联调阶段就直接暴露成语法错误。生产环境再打开兜底逻辑输出null。这样既能保证线上可用,又能在开发阶段第一时间发现问题,比等到上线后再排查高效得多。