leyizuo的博客

计费代码里的整数溢出:一次浮点转整型的事故复盘

Go 里 float64 转 int64 越界时是实现相关行为,在 amd64 上通常得到最小负数,于是"扣费"变成了"充值"。梳理饱和转换、入口限界、无符号字段三个防线。

Go 计费 安全

按 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 触发了饱和,你也能在事后从审计记录里看到它发生过、发生在哪个请求上——而不是只看到一个数字对不上的账。

小结

三层防线,缺一不可:

  1. 入口限界:用户可控的每一个乘数,在校验阶段就有上界,超了返回 400。
  2. 饱和转换:所有 float → int 的转换集中到一个带 NaN/Inf/越界处理的函数,禁止裸转换。
  3. 审计留痕:夹断发生时必须记录,能关联到具体请求。

第一层挡住了绝大多数情况,第二层保证即使第一层漏了也不会产生倒贴,第三层保证漏了之后你能知道。计费代码的正确性不是靠某一行写对,而是靠这几层一起兜住。