记一次 ClickHouse Decimal 类型丢精度的问题

现象:

  同事反馈一个 bug,说有部分数据值不对,有个金额明明修改的值是 16.06,但是再查的时候却是 16.05。我一查数据确实是 16.05 啊,“你复现一遍给我看看?”,结果亲眼看见 16.06 保存时变成了 16.05。interesting...🤔

 

背景:

  数据库是 ClickHouse,数据类型是 Decimal(18,2),ORM 使用的是 FreeSql 。

 

  很常规的功能,修改字段值。开始计划这么写的。

await freesql.Update<SettlementEntity>()
    .SetSource(batch)
    .UpdateColumns(a => a.ThirdPartyDeliveryFee)
    .ExecuteAffrowsAsync();

  但是执行时报错:There is no supertype for types Float64, Float64, Decimal(18, 2) because some of them have no lossless conversion to Decimal: While processing _CAST(if(Id IN (2094687040144789509, 2094687040144789511), _CAST(multiIf(Id = 2094687040144789509, 8.54, Id = 2094687040144789511, 9.7, ThirdPartyDeliveryFee), 'Nullable(Decimal(18, 2))'), ThirdPartyDeliveryFee), 'Nullable(Decimal(18, 2))'). (NO_COMMON_TYPE) (version 22.1.3.7 (official build))

  字段类型是 decimal? ,而且数据类型也是 Nullable(Decimal(18, 2))。开始不以为意,只觉得是 FreeSql 内部类型转换有 bug。于是改为执行原生 SQL 的写法。

ALTER TABLE Settlement
UPDATE ThirdPartyDeliveryFee = 16.06
IN PARTITION 20260830
WHERE ShopCode = '1234567' AND OrderNo = '4061706204038812230'

  执行完查询数据,却是 16.05。之前遇到批量更新数据,有一点滞后性,想着是不是命令没有执行完。

SELECT mutation_id, table, command, create_time, is_done, 
       latest_failed_part, latest_fail_reason
FROM system.mutations
WHERE table = 'Settlement'
ORDER BY create_time DESC
LIMIT 15;

  没有错误,命令也执行完了,执行了好几遍都是一样。反馈 bug 说有一部分数据是正确的,一部分数据不对,于是改一个数看看,16.66 改完立刻就查到正确值。wow~ ⊙o⊙😱

那我将精度改成 Decimal(18, 4) 呢?输入 16.06 确实得到 16.06,但是 输入 8.54 得到 8.5399。🤔🤔

  还得是 AI,虽然原生 SQL 写法也是 AI 写的,但是 AI 忽略了 ClickHouse 的 Decimal 精度问题,将 bug 反馈给 AI ,给出的分析是 ClickHouse 将 16.06 认成 Float64 类型,

ClickHouse 内部先存为 16.0599…,再隐式转 Decimal(18,2) 时做 截断16.05

  • 16.06 的最近双精度 → 16.0599… → 截断 → 16.05
  • 16.66 的最近双精度 → 16.6600… → 截断 → 16.66 ✓(碰巧没问题)

修复= toDecimal64('16.06', 2) — 字符串直接解析为 Decimal,不经过 Float64 中间值。

 

  再来点 AI 扩展解释:

最近双精度(nearest binary floating-point representation)是 IEEE754 浮点数标准自带的行为,所有语言 / 数据库(Java、Python、MySQL、PostgreSQL)只要用 double/float,都会这样。

当你写十进制字面量 16.06,编译器 / 解释器会去找能表达这个十进制数的最接近的二进制 double 值,也就是 16.059999999999998。 这个转换发生在SQL 引擎收到参数之前,不是 ClickHouse 发明的。

为什么不自动在后面补 0?

你心里期望的逻辑:

double 传入 16.059999999999998,CK 识别 “用户本意是 16.06”,自动补成 16.0600

但 ClickHouse(以及任何数据库)不能这么做,有几个根本原因:

1. 浮点数没有 “原始十进制语义”

IEEE754 double 里面只存二进制近似值,不保存你当初输入的十进制字符串。 当你把 16.06 转成 double 之后:

  • 丢失了原始信息:用户输入的是 16.06,不是 16.059999999999998
  • 数据库拿到的只有一串二进制,它无法区分两种场景
    • 场景 A:用户本来想写 16.06,经过浮点变成 16.059999999999998
    • 场景 B:用户本来想写 16.059999999999998

数据库没有魔法,猜不到你的原始意图,不能擅自把数字向上补 0

浮点数是近似值,不是 “带很多小数位的十进制”。

2. 转换规则:toDecimal 是数值转换,不是字符串解析

toDecimal(double_val, P, S) 的语义:

  1. 取 double 当前的真实数值
  2. 乘以 10^S
  3. 向零截断(truncate)得到整数,存入 Decimal 定点存储
  4. 再除以 10^S

它是数学数值运算,不是字符串解析,不会去猜测原始输入、不会自动补零。

对比:

  • toDecimal('16.06',18,4):字符串解析,知道原始十进制,补 0 得到 16.0600
  • toDecimal(16.06,18,4):先转 double 近似值,再做数值转换 ❌

3. 如果自动 “补 0 猜测用户意图”,会造成更严重的 bug

假设 CK 加了智能补 0 逻辑:只要浮点数很接近某个十进制小数就自动对齐。 那会出现灾难性问题: 比如真实业务中,真的有一个计算结果就是 16.059999999999998,系统却自作主张改成 16.06篡改真实计算结果。 数据库必须保证:相同输入 → 相同输出,不能凭猜测修改数值。 数据库的原则:不做隐式语义猜测

Decimal 本身是定点十进制: toDecimal('16.06',18,4) → 内部存储整数 160600,展示为 16.0600。 👉 Decimal 本身会补 0,但前提是输入是字符串 / 定点类型;浮点数输入这条路,在补 0 之前数值已经失真了。

 

  以上

FYI

posted @ 2026-09-13 15:01  原来是李  阅读(6)  评论(0)    收藏  举报