尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

彻底解决雪花算法ID精度丢失:前后端数据交互的精度陷阱与实战方案

彻底解决雪花算法ID精度丢失:前后端数据交互的精度陷阱与实战方案 1. 项目概述一个看似简单却普遍存在的“精度陷阱”最近在做一个用户中心模块的后端接口联调前端同事跑过来找我说用户列表里新创建的用户ID点进去看详情怎么和列表里显示的ID不一样我一看列表里显示的是“1486983637414830080”点进详情页浏览器地址栏和接口返回的ID却变成了“1486983637414830000”。最后几位莫名其妙变成了“0000”。这问题我太熟悉了又是一个典型的“雪花算法ID精度丢失”问题。这可不是什么冷门bug只要你用的是Java或类似语言的后端搭配JavaScript前端并且用雪花算法生成分布式ID几乎百分之百会踩到这个坑。表面上看这只是ID显示错误但深究下去它可能导致用户无法正常查询、数据关联错误甚至引发一些隐蔽的业务逻辑故障。今天我们就来彻底拆解这个问题从根源上理解它为什么发生以及给出前后端不同视角的、可落地的解决方案。2. 问题根源为什么一个“数字”会变要解决问题首先得搞清楚问题出在哪。很多人第一反应是“数据传输出错了”但其实问题发生在更底层的数据表示和解析环节。核心矛盾在于JavaScript或者说遵循ECMAScript标准的语言如前端常用的TypeScript中的Number类型与Java中的Long类型对整数的处理能力有本质区别。2.1 后端视角雪花算法与Java Long类型雪花算法Snowflake生成的ID是一个64位的长整型long在Java中用Long类型表示。这个64位的空间非常大可以表示的范围是-2^63到2^63-1即大约从-9.22e18到9.22e18。一个典型的雪花ID比如1486983637414830080它完全在这个范围内在Java世界里它是一个精确的、完整的整数。当我们使用Spring Boot等框架时控制器Controller返回一个包含这个Long类型ID的对象。框架默认使用Jackson或类似的库将对象序列化为JSON。对于Long类型的值Jackson会直接将其作为一个JSON数字Number输出。所以在HTTP响应的JSON body里你看到的就是{ id: 1486983637414830080, name: 张三 }注意这里的1486983637414830080在JSON中是一个没有引号的数字。到目前为止后端的工作是完全正确且无损的。2.2 前端视角JavaScript的Number类型与精度限制问题就出在前端接收到这个JSON字符串并用JSON.parse()将其转换为JavaScript对象的那一刻。JavaScript只有一种数值类型Number。它遵循IEEE 754标准的双精度浮点数格式。这意味着所有数字包括整数都是以64位二进制浮点数的形式存储的。IEEE 754双精度浮点数能精确表示的整数范围是有限的。具体来说是-2^53 1到2^53 - 1即-9007199254740991到9007199254740991大约±9e15。一旦整数超过这个范围我们称之为“安全整数”范围JavaScript就无法保证其精度会发生“四舍五入”到最近的可表示值。让我们对比一下Java Long 最大值9,223,372,036,854,775,807(约9.22e18)JavaScript 安全整数最大值9,007,199,254,740,991(约9.01e15)可以看到JavaScript的安全整数范围比Java的Long类型范围小了整整三个数量级一个典型的18位或19位的雪花ID如1.48e18已经远远超出了JavaScript的安全整数范围。所以当JSON.parse()遇到数字1486983637414830080时它试图将其装入一个Number类型的变量中。由于该数字超出了安全整数范围JavaScript引擎会将其近似为最接近的可表示浮点数即1486983637414830000。这就导致了末尾几位本例中是“080”丢失变成了“000”。关键点精度丢失发生在JSON解析阶段而不是网络传输或后端序列化阶段。传输的字符串是精确的“1486983637414830080”但解析后内存中的JavaScript对象属性值已经变成了不精确的1486983637414830000。2.3 问题复现与验证你可以在浏览器的开发者工具控制台轻松复现这个问题// 模拟后端返回的JSON字符串 const jsonString {id: 1486983637414830080}; const obj JSON.parse(jsonString); console.log(obj.id); // 输出1486983637414830000 console.log(obj.id 1486983637414830080); // 输出false console.log(obj.id.toString()); // 输出1486983637414830000可以看到解析后的值已经改变并且与原值不相等。如果此时前端用这个“失真”的ID作为参数再发请求给后端例如查询用户详情GET /user/1486983637414830000后端很可能因为找不到对应ID而返回404错误。3. 解决方案全景从临时修补到根治策略理解了根源解决方案就清晰了。我们的目标就一点确保ID从后端生成到前端展示再传回后端的整个闭环中始终保持其原始的、精确的字符串形式。下面我们从易到难从临时方案到根治方案逐一分析。3.1 方案一前端处理治标快速止血当问题突然出现需要快速上线修复时可以从前端入手。核心思路是阻止JSON.parse将大数字自动转换为Number。方法A使用JSON-bigint库这是一个专门处理JSON中大数字的库。它可以在解析时将超出安全整数范围的数字自动转换为JavaScript的BigInt类型ES2020引入可以表示任意精度的整数。安装npm install json-bigint # 或 yarn add json-bigint在请求拦截器如axios中使用import JSONBig from json-bigint; import axios from axios; // 创建一个使用 JSONBig 解析响应数据的 axios 实例 const apiClient axios.create({ baseURL: /api, transformResponse: [function (data) { // 尝试用 JSONBig 解析如果失败则 fallback 到普通 JSON.parse try { return JSONBig.parse(data); } catch (e) { // 如果数据不是JSON或解析失败尝试原样返回或使用默认解析 try { return JSON.parse(data); } catch (e2) { return data; } } }] }); // 使用这个实例发送请求 apiClient.get(/user/1).then(response { console.log(response.data.id); // 现在是一个 BigInt: 1486983637414830080n console.log(response.data.id.toString()); // 转换为字符串: 1486983637414830080 });注意事项BigInt类型不能直接与普通Number进行运算或比较需要先转换。在模板中显示时通常需要调用.toString()方法。如果要将包含BigInt的对象再用JSON.stringify发送给后端需要自定义序列化因为默认的JSON.stringify不处理BigInt会报错。通常我们会在发送前将ID字段转为字符串。方法B自定义JSON解析正则替换如果不想引入新库可以在接收到原始响应文本后手动处理axios.get(/api/user/1).then(response { // response.data 此时是字符串 const rawJsonString response.data; // 使用正则匹配所有可能的大数字这里简化匹配实际可能需要更严谨的正则 const processedJsonString rawJsonString.replace(/id:\s*(\d{15,})/g, id: $1); const data JSON.parse(processedJsonString); console.log(data.id); // 现在是字符串类型 });这种方法比较“野路子”正则写得不严谨容易误伤其他数据且性能不如专用库。仅作应急参考。前端方案的优缺点优点改动快无需后端配合适合紧急修复。缺点侵入性强需要修改所有网络请求的解析逻辑。不一致性如果前端应用有多处数据来源如不同的axios实例、fetch直接调用、WebSocket消息容易遗漏。复杂度转移将BigInt的处理复杂度留在了前端在运算、展示、传参时都需要额外小心。无法根治如果其他系统如移动端、第三方调用方也调用你的API它们同样会面临此问题。3.2 方案二后端处理治本推荐方案更优雅和根本的解决方案是在后端将问题“消灭在萌芽状态”。既然问题出在JSON序列化时把大数字当作Number类型那我们就不让它以数字形式出现在JSON中。核心思路是将Long类型的ID在序列化为JSON时强制转换为字符串。方法A全局配置Jackson序列化规则Spring Boot这是最常用、最彻底的方法。通过配置Jackson的ObjectMapper告诉它当序列化Long或BigInteger类型时如果数值超过JavaScript的安全整数范围就将其序列化为字符串。创建Jackson配置类import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.math.BigInteger; Configuration public class JacksonConfig { Bean public ObjectMapper objectMapper() { ObjectMapper objectMapper new ObjectMapper(); SimpleModule simpleModule new SimpleModule(); // 将Long类型序列化为字符串 simpleModule.addSerializer(Long.class, ToStringSerializer.instance); // 将Long的基本类型也序列化为字符串避免null值问题但需注意基础类型默认值0也会被转字符串 simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance); // 如果项目中使用了BigInteger也一并处理 simpleModule.addSerializer(BigInteger.class, ToStringSerializer.instance); objectMapper.registerModule(simpleModule); return objectMapper; } }配置后所有返回的JSON中Long类型的字段都会变成带引号的字符串{ id: 1486983637414830080, name: 张三 }更精细化的配置仅对超出范围的Long转字符串上面的配置会把所有Long都转字符串包括一些小的、在安全范围内的ID比如自增ID1,2,3。这虽然功能上没问题但可能不符合一些严格的接口规范要求数字类型就是数字。我们可以自定义序列化器实现更精细的控制import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import java.io.IOException; public class CustomLongSerializer extends JsonSerializerLong { // JavaScript安全整数最大值 private static final long MAX_SAFE_INTEGER 9007199254740991L; // JavaScript安全整数最小值 private static final long MIN_SAFE_INTEGER -9007199254740991L; Override public void serialize(Long value, JsonGenerator gen, SerializerProvider serializers) throws IOException { // 如果数值在安全整数范围内按数字输出否则按字符串输出 if (value ! null (value MAX_SAFE_INTEGER || value MIN_SAFE_INTEGER)) { gen.writeString(value.toString()); } else { gen.writeNumber(value); } } }然后在配置中使用这个自定义序列化器simpleModule.addSerializer(Long.class, new CustomLongSerializer());方法B使用注解进行局部控制如果不想全局修改或者只有部分字段需要处理可以使用Jackson的JsonSerialize注解。在实体类的字段上使用import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; public class User { JsonSerialize(using ToStringSerializer.class) private Long id; private String name; // getters and setters... }在类的级别上使用JsonSerialize public class User { // 这种方式不常用通常还是注解在字段上更清晰。 }注解方式的优点是灵活、精准。缺点是如果有很多实体类都有Long类型的ID需要逐个添加维护成本高。方法C在DTO/VO层直接使用String类型这是一种“物理”隔离方案。在后端返回给前端的对象DTO或VO中将ID字段定义为String类型。在服务层或控制器层将Long类型的ID转换为String。public class UserVO { private String id; // 使用String类型 private String name; // 构造方法或Setter中完成转换 public UserVO(Long id, String name) { this.id id ! null ? id.toString() : null; this.name name; } // getters and setters... }这种方案非常直观和彻底完全避免了序列化框架的配置问题。但增加了类型转换的代码并且需要确保团队在所有对外接口上都遵守这一规范。后端方案的优缺点优点一劳永逸一次配置所有接口生效。对前端透明前端无需任何特殊处理收到的ID就是字符串完全无精度问题。标准化符合RESTful API设计中对“标识符”常作为字符串处理的惯例例如URL中的路径参数。缺点历史接口兼容性如果已有大量前端代码依赖ID为数字类型修改后端会导致前端代码报错例如对ID进行数学运算。需要前后端协同升级。类型混淆在某些强类型检查的场景如TypeScript接口定义需要将类型从number改为string。3.3 方案三数据库与ID生成策略的考量源头思考有时我们也可以从问题源头——ID生成策略本身去思考。雪花算法生成的ID之所以这么大64位是为了保证分布式下的全局唯一和高性能。但如果我们业务的并发量没那么高或者可以接受一些限制是否有其他选择使用更短的ID生成方案缩短位数自定义雪花算法减少时间戳或工作位生成53位以内的ID使其处于JavaScript安全整数范围内。但这会牺牲ID的容量或唯一性保障时间。其他算法如UUID36位字符串、NanoID短字符串、基于Redis/数据库的自增ID数字但通常较小。这些ID本身就是字符串或小数字没有精度问题。但需要评估其是否满足分布式、有序、可读性等业务需求。使用字符串类型的数据库主键如果从数据库设计开始就将主键字段定义为VARCHAR并存储字符串格式的雪花ID如将Long转为String再存储。这样从数据库到Java实体再到JSONID始终是字符串彻底规避类型问题。但需要注意字符串索引的性能和存储空间会比数字索引稍差。实操心得对于绝大多数中大型互联网项目“后端全局配置Jackson将Long转为String”是经验证的最佳实践。它平衡了改动成本、彻底性和规范性。在项目初期就做好这个配置能为后续开发省去无数麻烦。对于存量项目如果前端改造困难可以先用JsonSerialize注解对新增接口进行局部处理逐步迁移。4. 实战配置与避坑指南理论讲完了我们来点“硬货”。以最推荐的Spring Boot全局配置方案为例详细走一遍流程并分享几个我踩过的坑。4.1 Spring Boot 2.x/3.x 完整配置示例假设我们有一个Spring Boot 2.7.x的项目。创建配置类JacksonConfiguration.javapackage com.yourproject.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import java.math.BigInteger; Configuration public class JacksonConfiguration { Bean Primary // 确保这个ObjectMapper被优先使用 ConditionalOnMissingBean(ObjectMapper.class) // 避免覆盖用户自定义的Bean public ObjectMapper objectMapper() { ObjectMapper objectMapper new ObjectMapper(); SimpleModule simpleModule new SimpleModule(); // 方案1所有Long/BigInteger转String激进但彻底 simpleModule.addSerializer(Long.class, ToStringSerializer.instance); simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance); simpleModule.addSerializer(BigInteger.class, ToStringSerializer.instance); // 方案2使用自定义序列化器推荐更智能 // simpleModule.addSerializer(Long.class, new CustomLongSerializer()); // simpleModule.addSerializer(Long.TYPE, new CustomLongSerializer()); objectMapper.registerModule(simpleModule); // 可选美化输出方便调试 // objectMapper.enable(SerializationFeature.INDENT_OUTPUT); // 可选忽略null值减少传输数据量 // objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); return objectMapper; } }自定义序列化器CustomLongSerializer.javapackage com.yourproject.config.serializer; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import java.io.IOException; public class CustomLongSerializer extends JsonSerializerLong { private static final long JS_MAX_SAFE_INTEGER 9007199254740991L; private static final long JS_MIN_SAFE_INTEGER -9007199254740991L; Override public void serialize(Long value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value null) { gen.writeNull(); return; } // 核心逻辑超出安全范围的转字符串范围内的保持数字 if (value JS_MAX_SAFE_INTEGER || value JS_MIN_SAFE_INTEGER) { gen.writeString(value.toString()); } else { gen.writeNumber(value); } } }记得在配置类中注释掉方案1启用方案2的代码。测试接口package com.yourproject.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; RestController RequestMapping(/test) public class TestController { GetMapping(/id) public MapString, Object testId() { MapString, Object map new HashMap(); // 一个超出安全范围的雪花ID map.put(bigId, 1486983637414830080L); // 一个在安全范围内的普通ID map.put(smallId, 123L); map.put(message, 测试ID序列化); return map; } }启动应用并访问GET /test/id使用方案1全部转String的输出{ bigId: 1486983637414830080, smallId: 123, message: 测试ID序列化 }使用方案2自定义序列化器的输出{ bigId: 1486983637414830080, smallId: 123, message: 测试ID序列化 }可以看到方案2更智能小数字保持了JSON Number类型大数字才转为String。4.2 常见坑点与解决方案坑点1配置不生效ID还是数字可能原因1项目中存在多个ObjectMapperBean你的配置类没有被Primary注解或者被其他自动配置如Spring MVC的MappingJackson2HttpMessageConverter覆盖了。解决确保你的配置类有Primary注解。或者更直接地通过实现WebMvcConfigurer接口来配置消息转换器Configuration public class WebConfig implements WebMvcConfigurer { Override public void configureMessageConverters(ListHttpMessageConverter? converters) { // 找到Jackson消息转换器并替换其中的ObjectMapper for (HttpMessageConverter? converter : converters) { if (converter instanceof MappingJackson2HttpMessageConverter) { MappingJackson2HttpMessageConverter jacksonConverter (MappingJackson2HttpMessageConverter) converter; jacksonConverter.setObjectMapper(customObjectMapper()); // 使用你自定义的ObjectMapper } } } Bean public ObjectMapper customObjectMapper() { // ... 你的自定义ObjectMapper配置 } }可能原因2使用了ResponseBody或RestController但序列化是由其他库如Fastjson完成的。检查项目依赖。解决统一使用Jackson。如果必须用Fastjson需要配置Fastjson的序列化器原理类似。坑点2Swagger/OpenAPI文档显示类型错误配置后接口返回的ID类型变成了string但Swagger文档可能还显示为integer或number。解决需要在实体类字段上使用Swagger的注解显式声明类型。import io.swagger.v3.oas.annotations.media.Schema; public class User { Schema(type string, format int64, description 用户ID字符串类型以避免前端精度丢失) private Long id; // ... }typestring告诉Swagger这是字符串formatint64提示它原本是个64位整数。坑点3前端类型定义TypeScript需要同步修改后端ID变成字符串后前端的TypeScript接口定义需要从number改为string。// 修改前 interface User { id: number; name: string; } // 修改后 interface User { id: string; // 注意这里 name: string; }如果不改TypeScript类型检查会报错或者导致一些基于number类型的工具函数出错。坑点4MyBatis等ORM框架查询结果映射如果你的实体类ID是Long但数据库查出来或MyBatis的TypeHandler处理完已经是String可能会报类型转换错误。解决确保ORM框架的映射类型一致。如果数据库存的是字符串实体类也应该用String。如果数据库存的是BIGINTMyBatis默认会映射为Long无需担心。坑点5ID作为URL路径参数或查询参数当ID作为字符串类型后在URL中传递是没问题的/user/1486983637414830080。但需要注意如果后端接口参数定义为PathVariable Long idSpring会尝试将字符串转换为Long这是成功的。但如果ID字符串超出了Long的解析范围概率极低会有问题。更严谨的做法是接收端也用String类型接收在Service层再按需转换。GetMapping(/{id}) public UserVO getUser(PathVariable String id) { // 用String接收 // 在服务层如果需要数值比较或计算再转为Long或BigInteger // Long longId Long.parseLong(id); // ... }5. 扩展思考与最佳实践解决了基本问题我们还可以想得更远一点。精度丢失问题只是分布式系统前后端数据交互中类型鸿沟的一个缩影。类似的还有时间日期Java Date vs JS Date/String、大金额BigDecimal精度、枚举值等。建立前后端协作规范ID规范明确所有唯一标识符数据库主键、业务编号在API中均以字符串形式传递。时间规范统一使用ISO 8601格式的字符串如2023-10-27T10:30:00Z而非时间戳数字。时间戳也可能存在精度问题如毫秒级时间戳已超过安全整数范围。金额规范涉及金融计算时使用字符串传递金额或约定以分为单位的整数避免浮点数精度问题。将这些规范写入项目接口文档如Swagger描述和前后端开发约定中。API网关或中间件的统一处理在大型架构中可以在API网关层或一个公共的序列化中间件中对所有出站响应进行“消毒”处理自动将可能出问题的数字类型转为字符串。这样对业务代码无侵入但技术复杂度较高。测试策略单元测试在序列化工具类的测试中加入对大数字ID的序列化/反序列化断言。接口测试在API自动化测试用例中专门测试返回大数字ID的接口验证响应JSON中该字段是否为字符串类型。前端E2E测试模拟用户操作创建数据后验证列表页和详情页显示的ID是否一致。监控与告警虽然概率很低但如果因为某种原因如配置被覆盖、使用了不规范的接口导致大数字ID再次以数字形式泄露到前端可以尝试在前端进行监控。例如在全局的响应拦截器中检查数值类型的ID字段是否超过安全范围并上报日志或发出告警。最后一点个人体会这个问题之所以经典是因为它触及了异构系统集成的本质——对数据理解的差异。作为后端开发者我们不能假设调用方前端、移动端、第三方和自己有完全相同的数据处理能力。设计API时要有“防御性”思维优先选择兼容性最好、歧义最少的数据表示形式比如用字符串传ID和日期。这看似增加了微小的数据传输开销却换来了系统的健壮性和开发者体验的大幅提升。从项目一开始就定好这些规范比后期到处打补丁要轻松得多。
返回列表