DECIMAL 的舍入发生在赋值还是查询时
三条明细各是 0.3333,合计应该显示多少?先把每行存入两位小数列再相加,会得到 0.99;保留四位再汇总、最后舍入,则得到 1.00。两边都使用 DECIMAL,仍然能相差一分钱。
DECIMAL 保证的是规定精度范围内的十进制定点计算,不会替业务决定在哪一步舍入。定位差额时,要把输入、表达式、列赋值和最终展示这几个时点拆开,别一看到数据库用了 DECIMAL(18,2) 就宣布精度问题已经排除。
从一张同时保留原始因子的明细表开始
以下实验使用 MySQL 8.4 和 InnoDB。单价保留四位,数量保留三位,已确认的行金额保留两位。line_amount 是明确保存的金额快照,不假设它会随着数量更新而自动重算。
CREATE TABLE decimal_bill_line (
id BIGINT NOT NULL,
bill_id BIGINT NOT NULL,
quantity DECIMAL(12,3) NOT NULL,
unit_price DECIMAL(12,4) NOT NULL,
line_amount DECIMAL(20,2) NOT NULL,
PRIMARY KEY (id),
KEY idx_bill_line (bill_id, id),
CONSTRAINT ck_quantity_positive CHECK (quantity > 0)
) ENGINE=InnoDB;
INSERT INTO decimal_bill_line
(id, bill_id, quantity, unit_price, line_amount)
VALUES
(1, 100, 1.000, 0.3333, 0.3333),
(2, 100, 1.000, 0.3333, 0.3333),
(3, 100, 1.000, 0.3333, 0.3333);
SHOW WARNINGS;
SELECT id, quantity, unit_price,
quantity * unit_price AS unrounded_amount,
line_amount
FROM decimal_bill_line
WHERE bill_id = 100
ORDER BY id;
这里故意把四位小数写入两位金额列。赋值时会发生舍入,line_amount 保存为 0.33。SHOW WARNINGS 中可能出现的截断说明,指向小数部分被舍入,不等于该行被拒绝。
严格模式也不会把所有小数位减少都变成错误。它能阻止超出整数范围等非法写入,却允许按目标小数位舍入并产生说明。若业务要求“输入超过两位就必须拒绝”,需要在原始输入阶段验证,不能只靠严格模式和 DECIMAL(20,2)。
现在对同一张表做两种合计:
SELECT
SUM(line_amount) AS sum_of_stored_lines,
ROUND(SUM(quantity * unit_price), 2) AS rounded_after_sum,
ROUND(SUM(quantity * unit_price), 2) - SUM(line_amount) AS difference
FROM decimal_bill_line
WHERE bill_id = 100;
预期得到 0.99、1.00、0.01。这不是二进制浮点误差;整段计算完全可以发生在精确十进制路径。差额来自舍入顺序,调整驱动编码或把列再加两位,都不会自动统一已经执行的业务算法。
行金额如果是对用户逐行确认的结算单位,合计通常就应来自已确认行。若合同规定整单汇总后再舍入,就要另外定义显示行与整单金额如何对应,以及余差分配在哪一项。这里没有一个数据库参数能替代那份规定。
差额方向也不是固定的
前三行都向下舍入,所以先存行金额比整单计算少一分钱。换成每行 0.3350,情况就会反过来:每行保存为 0.34,三行合计 1.02;原始乘积合计 1.0050,最后舍入为 1.01。不要给系统补一条“总额一律加一分”的规则,那只对刚好选中的样本有效。
排障报告应把两种计算方式并排展示,保留单据编号和差额符号。先筛出存在差异的单据,再沿着对应行的单价、数量和金额快照追查,比全表重新计算后直接覆盖更可控。
SELECT bill_id,
SUM(line_amount) AS confirmed_line_sum,
ROUND(SUM(quantity * unit_price), 2) AS recomputed_total,
SUM(line_amount) - ROUND(SUM(quantity * unit_price), 2) AS residual
FROM decimal_bill_line
GROUP BY bill_id
HAVING residual <> 0;
这份查询只是对照工具,没有判断哪一种总额正确。若合同采用逐行金额,第一列合计就是要保留的结果,第二种算法出现差异不构成修复理由;若整单金额先确认,则需要记录余差分配,而不是让查询每次临时猜一行吸收差额。
处理退款时还要让负值进入同一套边界测试。ROUND(-2.5) 得到 -3,不是简单把正向金额做整数截断。上游若使用另一种半值规则,正数普通样本可能都对得上,直到出现负数或恰好半分才暴露差异。规则名字要写清楚,不能只用“保留两位”四个字概括。
验收还应覆盖同一份输入经过驱动读出再写回的过程。读取为字符串后只做展示,通常不会改变值;读取后转成二进制浮点,再经过前端计算和序列化,则已经经过另一条算术路径。对账证据应标记最后一次精确值出现在哪一层,别把最终数据库列当成整条链路的精度证明。
小数位与整数位,是两种不同限制
DECIMAL(M,D) 的 M 是总位数,D 是小数位数,整数部分最多 M-D 位。DECIMAL(6,2) 不是允许六位整数再加两位小数,而是最多四位整数。
CREATE TABLE decimal_range_sample (
id INT NOT NULL,
amount DECIMAL(6,2) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
SET @saved_decimal_mode = @@SESSION.sql_mode;
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
INSERT INTO decimal_range_sample (id, amount)
VALUES (1, 12.345);
SHOW WARNINGS;
-- 单独执行;预期超出该列整数范围而失败。
INSERT INTO decimal_range_sample (id, amount)
VALUES (2, 10000.00);
SELECT id, amount FROM decimal_range_sample ORDER BY id;
第一行保存为 12.35,第二行不能用“只需要少保留几位小数”来处理。超出范围与小数舍入必须分别计入验收。若关闭严格模式,越界值可能被调整到合法边界并留下警告,这也不是业务金额被正确保存。
还要考虑舍入本身把数字推过上界的情况:一个看似不到一万元、但带额外小数位的值,舍入到两位后可能跨过 9999.99。列宽评估应按最终可存值范围进行,不能只数输入小数点左边当前有几位。
把范围列扩大也需要回看中间计算。单价乘数量、税额计算、汇总和接口序列化各有自己的类型,最终列够宽不能证明每个中间结果都没有丢失信息。与其把所有列一律改为最大精度,不如根据业务上界和计算步骤明确各阶段的范围。
ROUND 的输入类型仍然重要
直接写 2.5 是精确数值字面量,写成 25E-1 则进入近似数值路径。对近似数值,ROUND 的细节受底层库影响,许多系统会采用接近偶数的舍入;不能把它当成跨环境固定的金额规则。
SELECT ROUND(2.5) AS exact_positive,
ROUND(-2.5) AS exact_negative,
ROUND(25E-1) AS approximate_value;
精确正、负数分别得到 3 和 -3,半值向远离零的方向舍入。第三项应在目标运行环境观察,不能因为某台机器上是 2 就把这个结果写进跨系统协议。
而插入精确类型列时,目标列又有自己的赋值规则:
CREATE TABLE decimal_integer_sample (
id INT NOT NULL,
value DECIMAL(6,0) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
INSERT INTO decimal_integer_sample (id, value) VALUES
(1, 2.5),
(2, 25E-1),
(3, -2.5);
SHOW WARNINGS;
SELECT id, value
FROM decimal_integer_sample
ORDER BY id;
预期保存为 3、3、-3。这就是为什么“先 SELECT 算出来的值”和“存入列以后再 SELECT 的值”可能不同:两次观察跨越了一次类型赋值。判断时要保留目标列定义,单独拿表达式截图不足以解释最终行。
应用把值变成浮点以后,数据库无法替它还原
数据库列是精确类型,应用仍可能先用 double 或 JavaScript Number 做计算,再把近似结果送过来。驱动最终成功写入,不表示前一段计算精确。反过来,直接绑定十进制对象或规范十进制字符串,也应明确它在 SQL 中的使用方式;把字符值拿来参与普通数值表达式,可能触发近似类型转换。
较清楚的接口合同是:金额文本只允许规定格式,入口解析为十进制类型,按明确规则处理精度和范围,绑定到对应的精确列。需要在 SQL 中使用外部文本参与计算时,先显式转换为期望的 DECIMAL 类型,再进行算术,别让字符串隐式转换替你选类型。
SELECT
CAST('0.3333' AS DECIMAL(12,4)) *
CAST('3.000' AS DECIMAL(12,3)) AS exact_expression;
显式转换同样不是无限精度工具。如果转换目标本身只有两位小数,信息仍然会在那一步减少。应把舍入位置写成看得见的代码,而不是让它藏在某次临时变量赋值里。
已经只存两位,增加列精度不会长回丢掉的位数
把 line_amount 从两位改成四位,现有 0.33 只会成为四位表达的同一个值,不会自动变回 0.3333。若原始单价、数量或折扣因子没有保留,后续无法仅凭金额列恢复计算前的数值。
本文保留 quantity 和 unit_price,所以能对照重算;真实账单若还包含阶梯价、活动折扣与规则版本,需要把重放计算所需的输入和版本一起留住。当前商品价格不是历史成交价格,拿它回算过去的单据,会把舍入排障变成另一种数据错误。
查询也不应把重算值无条件覆盖已经确认的行金额。先输出差额,判断它属于舍入政策、历史规则还是异常,再决定哪些数据允许修复。数据库能准确算出 0.01,但这分钱该放在哪一行,需要业务规则给出答案。
SET SESSION sql_mode = @saved_decimal_mode;