ES 不支持 decimal,如何避免丢失精度?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
底层存储机制:面试官表面上问的是你有几种招数,实际上想看的是你是否清楚 ES 的数值类型底层是怎么存的——为什么它天生就存不了
BigDecimal这种任意精度小数。 -
全链路精度意识:精度丢失不只发生在 ES 里,从 Java 序列化成 JSON、到 ES 解析存储、再到前端 JS 接收,每一环都可能踩坑。能讲全链路的人,一定是真踩过坑的。
-
方案权衡能力:整数化、
scaled_float、keyword三种方案各有代价,选型时能不能结合业务(要不要范围查询、要不要聚合、小数位固不固定)做取舍,一眼就能区分谁是 “背答案”、谁是 “真懂”。
核心答案
先说结论:ES 的数值类型里确实没有 decimal,float/double 底层是 IEEE 754 浮点数,只有约 15~17 位十进制有效数字,存金额必丢精度。主流解法有三个,按推荐度排序:
| 方案 | 做法 | 适用场景 | 局限 |
|---|---|---|---|
| 整数化存储(推荐) | 金额以 “分” 为单位存 long |
支付、订单、账务 | 查询展示时需应用层换算 |
scaled_float |
指定 scaling_factor,ES 自动缩放成整数存 |
小数位数固定的场景(汇率、重量、评分) | 入参仍按 double 解析,有效位数受限 |
keyword 字符串 |
原样存字符串 | 只精确匹配、不参与数值计算 | 无法做范围查询和数值聚合 |
一句话:能转整数就转整数,转不了用 scaled_float 兜底,纯展示就上 keyword。
深度解析
一、精度到底是怎么丢的
很多人以为精度是 ES 弄丢的,其实不止。看一眼这条完整链路:
上面这条链路里,每一环的 “漏点” 我拆开说:
- 第一环,JSON 序列化:
BigDecimal序列化成 JSON 数字本身不丢(Jackson 会保留原始位数),但 JSON 只是个文本协议,问题出在接收方怎么解析它。 - 第二环,ES 存储:ES 的
double类型按 IEEE 754 双精度存,尾数 52 位加 1 位隐含位,共 53 位二进制,换算成十进制大约 15~17 位有效数字(至少保证 15 位往返无损)。超过这个位数,ES 解析 JSON 时直接截断,12.34567890123456789 存进去就变味了。 - 第三环,前端展示:就算 ES 存的是精确的
long,返回给前端时 JS 的Number同样是 53 位尾数,超过 2^53 - 1(约 9 * 10^15)的整数,JS 解析就歪了。所以大额订单号、雪花 ID 这类值,返回时也得转字符串。
我把这条链路讲清楚,是因为面试时能把 “三环” 都说出来的人,面试官印象分直接拉满。
二、首选方案:金额转最小单位,存 long
这是生产环境最稳的做法,支付宝、微信支付的金额字段在底层协议里也是按 “分” 的整数传的,思路一脉相承。
long 的范围是 -2^63 ~ 2^63-1,约 9.2 * 10^18,对应到 “分”,足够存下 9 千万亿亿元的金额,业务上根本花不完。
Java 侧的转换代码:
// 写入前:元转分(用 longValueExact,遇到非整数位数直接抛异常,避免静默截断)
BigDecimal amount = new BigDecimal("12.34");
long cents = amount.movePointRight(2).longValueExact();
// 查询后:分转元(scale 传 2,保证小数位对齐)
BigDecimal restored = BigDecimal.valueOf(cents, 2);
两个细节值得说:
movePointRight(2)把小数点右移两位,等价于乘 100,但比multiply更直观,且不会引入额外的 scale 问题。- 用
longValueExact()而不是longValue(),前者遇到 “12.345” 这种转不成整数分的情况会抛ArithmeticException,快速暴露脏数据;后者会静默截断成 1234,金额凭空少半分钱,这种 bug 上线了查起来要命。
三、次选方案:scaled_float
如果业务上字段天生就是小数,比如商品重量 1.75 kg、汇率 6.8912,硬转整数别扭,这时候 scaled_float 登场:
PUT /products
{
"mappings": {
"properties": {
"price": {
"type": "scaled_float",
"scaling_factor": 100
}
}
}
}
它的原理说白了就一句话:写入时 ES 把你的值乘以 scaling_factor,四舍五入到最接近的整数后按 long 存储。比如 12.345 乘以 100 四舍五入存成 1235,查询时再除回去,对外用起来和 double 一模一样。所以它本质上是 “ES 帮你做了整数化”,范围查询、排序、聚合全都能用,而且底层存储和计算都走整数,省磁盘,效率也不差。
但有个坑要提醒:scaled_float 的入参仍然是先按 double 解析的。也就是说,它的有效数字还是受 53 位尾数限制,只是对 “小数位数固定” 的场景(比如永远两位小数)等价于精确存储。你要是拿它存 20 位有效数字的科学数据,照样丢。scaling_factor 的选择就按业务最小精度来:金额到分用 100,到厘用 1000。
四、兜底方案:keyword 字符串
把数值原样存成字符串,精度绝对不丢,精确匹配(term/terms 查询)、去重聚合都没问题。代价也很明显:范围查询做不了,avg/sum 这类数值聚合也做不了。
它适合的场景很窄:只需要按值精确检索、或者纯粹当展示字段回显的,比如银行卡号、某些对账单号。想对 keyword 做 “金额大于 100” 的查询?ES 会按字典序比,"9" 比 "100" 大,结果直接错给你看。
五、聚合时的精度暗坑
这块当年坑过我一个同事。就算你字段设计对了,聚合环节还有暗雷:
- ES 的
sum聚合对 double 字段内部用了 Kahan 补偿求和算法来减小累积误差(实现类就叫CompensatedSum,sum/stats/avg聚合都在用),但这只是 “减小”,不是消除,浮点误差依旧存在。 - 还有一个大多数人不知道的细节:就算字段是
long,sum聚合内部也是按 double 累积的,返回值同样是 double。好在金额量级通常远小于 2^53(约 9 千万亿分),这个范围内 double 是能精确表示每个整数的,所以整数化存储后聚合结果依然可靠,只是拿结果做二次计算时记得走BigDecimal。 - 聚合结果返回的是 double,如果你存的是 “分”,聚合出来的 123456 代表 1234.56 元,换算时别用浮点除法
123456 / 100.0一路算到底,先转BigDecimal再动小数点,稳。
面试高频追问
- 追问一:
scaled_float的scaling_factor设置特别大(比如 10^10)会怎样?
可用数值范围会随之缩小,因为底层 long 的总位数是固定的,缩放因子占了小数位,整数位就少了。业务上按真实最小精度设置即可,别贪。
- 追问二:线上已经有 double 字段在存金额了,怎么迁移?
新建正确类型的索引,用 reindex API 迁移历史数据,迁移脚本里做精度校验(迁移前后金额对账),再通过别名切换流量。老索引别急着删,留一段时间回滚用。
- 追问三:为什么不用 text 类型存金额?
text 会分词,12.34 会被拆成 “12” 和 “34” 两个 token,精确匹配都保证不了,比 keyword 还离谱。
常见面试变体
- “ES 有哪些数值类型?各自的范围是什么?”
- “
scaled_float和double的区别?” - “订单表的金额字段,你的 mapping 会怎么设计?”
记忆口诀
金额进 ES,整数是王道;固定小数位,scaled_float 保;纯展示匹配,keyword 找。
总结
ES 没有原生 decimal,double 只有 15~17 位有效数字,存金额必丢精度。首选方案是业务侧转最小单位整数存 long,小数位固定的用 scaled_float(底层也是 long),纯展示字段用 keyword。再把 JSON 序列化、前端 JS 大数这几个链路漏点补上,这道题在面试官那里基本就是满分答案。
