计费代码里的整数溢出:一次浮点转整型的事故复盘
Go 里 float64 转 int64 越界时是实现相关行为,在 amd64 上通常得到最小负数,于是"扣费"变成了"充值"。梳理饱和转换、入口限界、无符号字段三个防线。
按 token 数计费的链路,最后一步几乎都是同一个动作:把算出来的浮点数变成整型的额度。
quota := int64(float64(tokens) * price * ratio)
这一行在正常情况下毫无问题。问题出在不正常的输入上。
越界转换在 Go 里是实现相关的
Go 语言规范对越界的浮点转整型是这么说的:转换会成功,但结果值是实现相关的(implementation-dependent)。它不会 panic,不会报错,也不会返回一个”安全”的值。
在 amd64 上,越界的 float64 转 int64 得到的是 0x8000000000000000,也就是 -9223372036854775808——int64 的最小值。
写个程序验证:
package main
import (
"fmt"
"math"
)
func main() {
huge := math.MaxFloat64
fmt.Println(int64(huge)) // -9223372036854775808
fmt.Println(int64(math.Inf(1))) // -9223372036854775808
fmt.Println(int64(math.NaN())) // -9223372036854775808
}
三个都是同一个负数。
放到计费场景里,这意味着:一个本该扣掉巨额额度的请求,最终扣掉的是负的巨额额度——用户余额不降反升,凭空多出一大笔钱。而且整个过程没有任何错误日志,账面上看只是一笔数额奇怪的消费记录。
NaN 也走同一条路。上游返回一个非数字字段、或者算式里出现了 0/0、Inf-Inf,NaN 就会一路传到转换点,然后变成最小负数。
防线一:所有转换点走饱和运算
不要写裸的 int64(f)。集中一个函数,把三种异常输入都夹到边界内:
package quotamath
import "math"
// float64(math.MaxInt64) 会被舍入成 2^63,正好是不可表示的第一个值,
// 所以用 >= 判断上界是准确的。
const maxInt64AsFloat = float64(math.MaxInt64)
// QuotaFromFloat 向零截断,并把 NaN / ±Inf / 越界值夹到边界。
func QuotaFromFloat(f float64) int64 {
switch {
case math.IsNaN(f):
return 0
case f <= float64(math.MinInt64):
return math.MinInt64
case f >= maxInt64AsFloat:
return math.MaxInt64
default:
return int64(f)
}
}
// QuotaRound 四舍五入(half away from zero),同样带饱和。
// 先 Round 再夹,顺序不能反。
func QuotaRound(f float64) int64 {
return QuotaFromFloat(math.Round(f))
}
NaN 返回 0 而不是最小值,是因为 0 在计费语境下是安全的默认(不扣钱),而最小负数是灾难。
对应的测试:
func TestQuotaFromFloat(t *testing.T) {
cases := []struct {
name string
in float64
want int64
}{
{"normal", 1234.56, 1234},
{"negative truncates toward zero", -1234.56, -1234},
{"nan", math.NaN(), 0},
{"positive inf", math.Inf(1), math.MaxInt64},
{"negative inf", math.Inf(-1), math.MinInt64},
{"max float", math.MaxFloat64, math.MaxInt64},
{"just below 2^63", math.Nextafter(maxInt64AsFloat, 0), 9223372036854774784},
{"exact zero", 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := QuotaFromFloat(tc.in); got != tc.want {
t.Errorf("QuotaFromFloat(%v) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
有了这一层,即使上游数据完全失控,也不会产生一笔”倒贴”的账单。
防线二:把用户可控的乘数挡在入口
饱和转换是最后一道保险,不是第一道。真正该做的是让离谱的值根本进不到计算里。
计费公式里通常有几个用户直接可控的乘数:图片生成的张数 n、视频的时长 seconds、分辨率或画质的倍率、批处理的条数、max_tokens。每一个都需要在请求校验阶段就有明确上界,超了直接返回 400。
const maxImageN = 10
if req.N != nil && (*req.N < 1 || *req.N > maxImageN) {
return errors.New("n must be between 1 and 10")
}
容易被漏掉的是绕过标准 DTO 的路径。透传字段(比如塞进 extra["parameters"] 的原始 map)、任务类的 metadata、multipart 表单,都能携带同样的数量参数绕过校验。任何从这些路径读取乘数的适配器,都必须在本地再夹一次。
还有一类输入不来自客户端,而来自媒体元数据或上游返回:音频文件头里声明的时长、上游账单接口返回的扣费单位数。这些同样是不可信的,一个损坏的文件头可以声称自己有十万小时的时长。
防线三:无符号字段不能只判断 >= 0
这个坑很具体。假设字段类型是 *uint,客户端发来:
{ "n": 18446744073686646784 }
这个数小于 MaxUint64(18446744073709551615),所以 encoding/json 会正常解析,不报错。而它作为 int64 解释是 -22904832——一个包装过的负数。
于是这样的校验形同虚设:
if *req.N >= 0 { // 对无符号类型恒为真,编译器甚至可能警告
// 通过
}
后面只要有一次到 int64 的转换,负数就进了计费公式。
规则很简单:无符号字段必须有明确的上界,>= 0 不构成任何保护。
顺带一个相关的坑:omitempty 会吃掉显式的 0
请求结构体里,可选的标量字段如果用非指针类型加 omitempty,客户端显式传的 0 会在转发给上游时被丢掉:
type Request struct {
MaxTokens int `json:"max_tokens,omitempty"` // 客户端传 0,转发时字段消失
TopP float64 `json:"top_p,omitempty"` // 传 0.0 同样消失
Stream bool `json:"stream,omitempty"` // 传 false 同样消失
}
“字段不存在”和”字段值为零”是两种语义,非指针类型区分不了。正确写法是指针加 omitempty:
type Request struct {
MaxTokens *int `json:"max_tokens,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream *bool `json:"stream,omitempty"`
}
客户端没传 → 指针为 nil → 序列化时省略;传了 0 → 指针非 nil → 原样发出去。
这一条看起来和溢出无关,但它属于同一类问题:让不可信的输入在类型层面保有完整的表达能力,而不是在某个环节被悄悄改写。
把饱和事件记下来
夹断是一种异常,它意味着有请求送进来了一个不该存在的数值。静默夹断会让问题永远查不出来,所以每次触发都应该留痕:
func QuotaFromFloatChecked(f float64) (int64, bool) {
clamped := math.IsNaN(f) || f <= float64(math.MinInt64) || f >= maxInt64AsFloat
return QuotaFromFloat(f), clamped
}
调用方拿到 clamped == true 时写一条错误日志,最好带上请求 ID,并且在写消费流水的时候把这个标记一并存进去,让它在后台的日志详情里可见。
这样即使某次攻击或者某个上游 bug 触发了饱和,你也能在事后从审计记录里看到它发生过、发生在哪个请求上——而不是只看到一个数字对不上的账。
小结
三层防线,缺一不可:
- 入口限界:用户可控的每一个乘数,在校验阶段就有上界,超了返回 400。
- 饱和转换:所有 float → int 的转换集中到一个带 NaN/Inf/越界处理的函数,禁止裸转换。
- 审计留痕:夹断发生时必须记录,能关联到具体请求。
第一层挡住了绝大多数情况,第二层保证即使第一层漏了也不会产生倒贴,第三层保证漏了之后你能知道。计费代码的正确性不是靠某一行写对,而是靠这几层一起兜住。