无符号整数参与减法,负数结果为什么报错
两个字段都不会存负数,不代表它们相减也不会出现负数。MySQL 报 BIGINT UNSIGNED value is out of range 时,表里的原始值可能完全合法,出错的是表达式结果的类型。
配额、库存累计量、已发放与已使用计数都容易遇到这个问题。UNSIGNED 约束了存储范围,却没有替差值预留符号位。排查应从操作数类型和会话模式开始,而不是先把所有异常行改成零。
合法数据,也能让一条 SELECT 失败
在 MySQL 8.4 的隔离实验库创建配额表:
CREATE TABLE usage_quota (
account_id BIGINT NOT NULL,
granted BIGINT UNSIGNED NOT NULL,
consumed BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (account_id)
) ENGINE=InnoDB;
INSERT INTO usage_quota (account_id, granted, consumed) VALUES
(101, 100, 20),
(102, 10, 15);
SET @saved_unsigned_mode = @@SESSION.sql_mode;
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION';
-- 单独执行;第二行差值为负,预期表达式越界。
SELECT account_id, granted - consumed AS remaining
FROM usage_quota
ORDER BY account_id;
101 的差值是 80,102 是 -5。默认整数减法只要有无符号操作数,结果通常仍按无符号类型处理,负值无法放进这份结果范围,所以整条查询失败。严格模式不是这次 SELECT 表达式溢出的开关,单纯关掉严格写入限制并不能把无符号结果变成有符号结果。
日志里若只留下 SELECT remaining,还会让人误以为 remaining 是一列坏数据。应该保留实际减法表达式,以及 SHOW CREATE TABLE usage_quota 和 @@SESSION.sql_mode,三者一起才能说明推导过程。
只想找超额账户时,根本不需要先计算一个可能越界的差值:
SELECT account_id, granted, consumed
FROM usage_quota
WHERE consumed > granted;
这个条件直接比较两个合法非负计数。不要把它改成 granted - consumed < 0 再期待过滤器帮忙兜住算术错误;需要先产生差值,才能判断它是否小于零。
改模式和改表达式,是两种作用范围
NO_UNSIGNED_SUBTRACTION 会让整数减法的结果使用有符号类型。可以在当前实验连接观察它:
SET SESSION sql_mode =
'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION,NO_UNSIGNED_SUBTRACTION';
SELECT account_id, granted - consumed AS remaining
FROM usage_quota
ORDER BY account_id;
这次可以得到 80、-5。它适合已有应用明确约定这种会话语义的情况,但改一次模式影响的不只是这一条查询。连接池中有些物理连接设置了、有些没有,错误就会呈现为偶发。迁移工具、报表和后台任务也要遵守同一约定。
局部修复则是在算术发生之前显式选定表达式类型。若业务已证明两个计数都不超过有符号 BIGINT 上界,可以先分别转为有符号再相减:
SELECT account_id,
CAST(granted AS SIGNED) - CAST(consumed AS SIGNED) AS remaining
FROM usage_quota
ORDER BY account_id;
顺序不能反过来。CAST(granted - consumed AS SIGNED) 要先执行里面的减法,原来的错误仍可能在转换之前发生。外面再套 COALESCE、GREATEST 或其他函数,也不能保证已经失败的子表达式变成一个安全值。
整个无符号范围,不能硬塞回 SIGNED
BIGINT UNSIGNED 的最大值是 18446744073709551615,有符号 BIGINT 最大值只有 9223372036854775807。如果源列确实使用了更大的值,强制转 SIGNED 不再是通用修复,它可能改变原来合法的数值含义。
INSERT INTO usage_quota (account_id, granted, consumed)
VALUES (103, 18446744073709551615, 1);
SELECT account_id, granted, CAST(granted AS SIGNED) AS forced_signed
FROM usage_quota
WHERE account_id = 103;
这份边界输入用来提醒转换范围,不能因为前两行的小数字都正确,就认为所有生产数据都能采用同一写法。对原值需要完整保留的差值计算,可以把操作数先转为容纳它们的精确十进制类型:
SELECT account_id,
CAST(granted AS DECIMAL(21,0)) -
CAST(consumed AS DECIMAL(21,0)) AS remaining
FROM usage_quota
ORDER BY account_id;
21 位整数精度足以容纳单个 20 位无符号值及这里的有符号差值。这份选择建立在“两项相减”的范围分析上;如果后面又乘上费率、汇总海量计数,应重新分析中间结果,不能把 21 当成所有公式都安全的魔法数字。
驱动读取也要跟着调整。数据库返回了精确大整数差值,应用却用只能容纳有符号 64 位的变量接收,仍然会在下一层溢出。跨 JSON 接口给 JavaScript 使用时,还需决定用十进制字符串还是其他明确编码,避免大整数精度被默认数字类型吞掉。
用两端边界证明转换,而不只测一个负数
只测 10-15,只能证明小负数能产生,不能证明修复保留了全范围输入。再加两条记录,让最大值分别出现在减数和两边相等的位置:
INSERT INTO usage_quota (account_id, granted, consumed) VALUES
(104, 0, 18446744073709551615),
(105, 18446744073709551615, 18446744073709551615);
SELECT account_id,
CAST(granted AS DECIMAL(21,0)) -
CAST(consumed AS DECIMAL(21,0)) AS remaining
FROM usage_quota
WHERE account_id IN (103, 104, 105)
ORDER BY account_id;
103 应得到最大无符号值减一,104 得到最大值的负数,105 得到零。105 尤其能抓出“先转有符号再盲目计算”的误判:某些非法转换碰巧在两边抵消,最终零值看起来正确,过程却已经不具备普遍意义。应同时核对原始操作数和结果,不能只靠一个相等样本通过测试。
错误发生在查询投影、筛选条件还是更新赋值,也要分别定位。把可疑表达式单独放在指定主键的一行上,确认是哪一组输入触发;随后检查相同公式是否还出现在排序、聚合或视图中。只改API展示查询,后台报表仍使用旧公式,问题就只是换了入口继续出现。
查询能显示负值,不表示无符号列能保存它
启用 NO_UNSIGNED_SUBTRACTION 只是改变表达式推导,不会修改目标列定义。假如把负差写回无符号计数列,在严格模式下仍应失败。
-- 当前会话保持严格模式及 NO_UNSIGNED_SUBTRACTION。
-- 单独执行;预期写入负值到无符号列而失败。
UPDATE usage_quota
SET granted = granted - consumed
WHERE account_id = 102;
SELECT account_id, granted, consumed
FROM usage_quota
WHERE account_id = 102;
该行应保持 10 和 15。这份表模型把发放量与累计使用量分开保存,差值只是查询结果;将差值回填发放量还会破坏字段含义。修复类型错误时顺手改写业务计数,往往比最初的查询失败更难恢复。
也不要为了避免负数就直接 GREATEST(差值, 0)。如果业务允许超额,负值正是有用信号;如果业务不允许超额,写入侧需要检查配额并处理并发,而不是在报表里把异常显示成零。只有产品明确要求展示“可用余量最低为零”时,才在已经安全计算出的有符号或十进制差值上做展示裁剪,并保留真实超额信息。
修复最终应落在实际用途:只筛超额就比较原列,需要差值就选择足够的计算范围,需要防超额则检查写入协议。表达式错误可以用几字节的类型变化解决,计数含义不能跟着一起偷偷变化。
SET SESSION sql_mode = @saved_unsigned_mode;