Go 基础体系 · 第 83/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 常用领域类型:UUID、Snowflake、Decimal、金额与可选值

本文以 Go 1.26.4 为基准,第三方示例固定使用 github.com/google/uuid v1.6.0github.com/shopspring/decimal v1.4.0。ID、金额和可选值会穿过 HTTP、JSON、消息、数据库和日志,任一层的精度、空值或排序不同,都可能形成长期数据错误。

本篇比较自增、UUID、Snowflake、Decimal 和整数金额;持久化格式、舍入与节点分配才是稳定协议。

1. 先写跨层契约,再选择 Go 类型

为一个字段选型前,先回答:是否全局唯一、是否需要大致有序、是否允许暴露生成速度、最大生命周期多长、能否离线生成、是否跨地域、在 JSON 中以数字还是字符串传输。金额还要回答币种、最小单位、内部计算精度、最终舍入模式和退款是否保持原分摊。

Go 类型只约束当前进程。type OrderID int64 能阻止把 UserID 误传给函数,却不能自动约束数据库列、JavaScript 数值和消息消费者。契约还应包含合法范围、文本格式、数据库类型、零值语义与安全分类。

type OrderID int64

func ParseOrderID(raw string) (OrderID, error) {
	id, err := strconv.ParseInt(raw, 10, 64)
	if err != nil {
		return 0, fmt.Errorf("parse order id %q: %w", raw, err)
	}
	if id <= 0 {
		return 0, errors.New("order id must be positive")
	}
	return OrderID(id), nil
}

不要用 0 同时表达“尚未生成”“匿名资源”和合法记录。若零值无效,就在解析、构造和持久化边界拒绝它;若调用方确实需要缺失状态,再使用显式 Optional 或指针。

2. 数据库自增 ID 的优势与边界

单库自增主键的优点很实际:实现简单、B-tree 插入局部性好、索引紧凑,数据库负责并发唯一性。事务回滚、序列缓存和失败插入会造成空洞,因此它只保证唯一和大致递增,不保证连续;业务不能用 MAX(id) 推导记录数量,也不能把缺号当删除审计。

分库后,各库序列会冲突。可以预分段、设置步长或增加全局发号服务,但这会引入容量、迁移和灾备协议。批量导入、双写迁移时还要避免旧序列追上已导入值。若 ID 出现在公网 URL,自增值会暴露规模并易于枚举,授权检查仍必须按当前主体执行。

数据库生成值通常要在同一语句中取回。PostgreSQL 使用 RETURNING,不要先插入再查 MAX(id)

func createOrder(ctx context.Context, db *sql.DB, customerID int64) (OrderID, error) {
	const query = `INSERT INTO orders (customer_id) VALUES ($1) RETURNING id`
	var id OrderID
	if err := db.QueryRowContext(ctx, query, customerID).Scan(&id); err != nil {
		return 0, fmt.Errorf("insert order: %w", err)
	}
	if id <= 0 {
		return 0, errors.New("database returned invalid order id")
	}
	return id, nil
}

函数接收请求的 context.Context,数据库驱动才能在截止时间到达时尝试取消。取消后事务结果可能未知,调用方不能未经幂等设计就盲目重试写入。

3. UUID 的结构、版本与可排序性

UUID 是 128 位标识,常以 36 字符十六进制文本展示。google/uuid v1.6.0uuid.New()/NewString() 生成随机型 UUID v4;随机空间足够大时碰撞概率极低,但它不是数学上的绝对不重复。解析外部输入应使用返回错误的 uuid.Parse,而不是会 panic 的 Must 变体。

func normalizeRequestID(raw string) (uuid.UUID, error) {
	id, err := uuid.Parse(raw)
	if err != nil {
		return uuid.Nil, fmt.Errorf("parse request id: %w", err)
	}
	if id == uuid.Nil {
		return uuid.Nil, errors.New("request id must not be nil uuid")
	}
	return id, nil
}

随机 UUID 在聚簇 B-tree 中会随机插入,可能增加页分裂和缓存压力。需要时间局部性时可评估 UUID v7,但必须先确认所用库版本 API、数据库索引行为、相同毫秒内排序语义和跨节点时钟假设。时间有序不等于严格单调,UUID 中包含时间也不应作为权威创建时间。

数据库优先使用原生 uuid 或 16 字节二进制列,而不是无约束 varchar(255)。文本比较大小、二进制布局和数据库扩展的排序规则要在迁移前压测。日志可记录完整请求 ID,但账号等资源 ID 是否属于个人数据仍要按安全策略判断。

4. Snowflake 位布局决定数值边界

典型 Snowflake 把一个正 int64 划为时间差、节点号和同毫秒序列,例如 41+10+12 位,并保留符号位。41 位毫秒约覆盖 69 年;10 位允许 1024 个节点;12 位允许单节点每毫秒 4096 个序号。位数不是行业固定协议,epoch、位宽与编码方式必须被版本化并跨语言共享。

0 | timestamp since custom epoch (41) | worker (10) | sequence (12)

最大值不能靠直觉。时间差超过 41 位后继续左移会污染节点位;worker 越界会与其他节点重叠;同毫秒序列耗尽后必须等待下一毫秒或受控失败。采用有符号 int64 时还要保证最高位不被置一,否则数据库排序与部分语言解释会变化。

const (
	workerBits   = 10
	sequenceBits = 12
	maxWorker    = int64(1<<workerBits - 1)
	maxSequence  = int64(1<<sequenceBits - 1)
)

func composeID(elapsedMS, worker, sequence int64) (int64, error) {
	if elapsedMS < 0 || elapsedMS >= 1<<41 {
		return 0, errors.New("timestamp is outside 41-bit range")
	}
	if worker < 0 || worker > maxWorker {
		return 0, errors.New("worker id is outside 10-bit range")
	}
	if sequence < 0 || sequence > maxSequence {
		return 0, errors.New("sequence is outside 12-bit range")
	}
	return elapsedMS<<(workerBits+sequenceBits) |
		worker<<sequenceBits | sequence, nil
}

解析 ID 只能得到编码时采用的局部信息,不能证明请求真实发生时间、机器身份或访问权限。攻击者能构造位模式合法但从未签发的 ID。

5. 节点号分配与时钟回拨

Snowflake 的难点不是位运算,而是运维。每个同时运行的生成器必须拥有唯一 worker ID,并在租约失效后停止生成;把容器序号截断到 10 位或对主机名取模会产生冲突。可由部署系统静态分配,或由一致存储发放带租约的节点号。租约续期失败时宁可拒绝生成,也不要在不确定所有权下继续。

生成器内部的 lastMSsequence 是共享状态,需要 mutex 或单所有者 goroutine。mutex 应作为非嵌入值字段,生成器本身不能在使用后复制。时钟回拨策略有三类:短回拨等待到 lastMS;立即返回可分类错误;使用逻辑时间继续但承担与真实时间偏离。静默把负差值编码进去最危险。

等待必须有 context 和上限,不能持有租约或数据库连接无限睡眠:

func waitUntil(ctx context.Context, target time.Time) error {
	delay := time.Until(target)
	if delay <= 0 {
		return nil
	}
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return context.Cause(ctx)
	}
}

机器必须有时钟同步与偏差告警。指标至少记录当前 worker、每毫秒序列耗尽次数、回拨幅度、等待时长、拒绝数和距离 epoch 上限的剩余时间。

6. JavaScript 的 53 位边界与 JSON 契约

JavaScript 普通 Number 只能精确表示到 2^53-1。Snowflake 常超过此值;Go 把 int64 编码为 JSON 数字后,浏览器或某些网关解析再序列化可能悄悄改值。最稳妥的公开契约是字符串,在 OpenAPI 中也声明 type: string 和格式约束。

type OrderResponse struct {
	ID       string `json:"id"`
	Amount   string `json:"amount"`
	Currency string `json:"currency"`
}

func newOrderResponse(id int64, amount decimal.Decimal, currency string) OrderResponse {
	return OrderResponse{
		ID:       strconv.FormatInt(id, 10),
		Amount:   amount.StringFixed(2),
		Currency: currency,
	}
}

不要一部分端点返回数字、另一部分返回字符串;客户端生成代码会变成联合类型。接受输入时限制十进制数字长度、拒绝符号和前导空白,并在转换前做范围校验。日志字段可保留字符串,避免日志后端也用浮点解析。

7. 金额模型:整数最小单位还是 Decimal

单一币种、固定小数位且运算以加减为主时,int64 最小单位最简单。例如人民币分或日元元。必须同时保存币种,因为 100 在不同币种下含义不同;还要校验相加币种一致。int64 乘法可能在除法前溢出,不能因最终结果较小就忽略中间值。

type Money struct {
	Minor    int64
	Currency string
}

func (m Money) Add(other Money) (Money, error) {
	if m.Currency == "" || m.Currency != other.Currency {
		return Money{}, errors.New("money currencies do not match")
	}
	if (other.Minor > 0 && m.Minor > math.MaxInt64-other.Minor) ||
		(other.Minor < 0 && m.Minor < math.MinInt64-other.Minor) {
		return Money{}, errors.New("money addition overflows int64")
	}
	return Money{Minor: m.Minor + other.Minor, Currency: m.Currency}, nil
}

跨币种汇率、税率、比例分摊或可变精度商品适合 Decimal。不要用 float64 累计财务金额:0.1 无法用二进制浮点精确表示,误差经过求和、比较和舍入会进入账务。展示统计若允许误差可以用浮点,但要与账本值隔离。

8. shopspring/decimal 的表示和不可变操作

decimal.Decimal 可理解为整数系数乘以 10 的指数。AddMulRound 返回新值,不会原地修改接收者。NewFromString 能保留文本十进制语义;NewFromFloat 接收的已经是二进制近似值,只适合输入本就来自浮点的场景。

func checkout(unitPrice, taxRate string, quantity int64) (decimal.Decimal, error) {
	price, err := decimal.NewFromString(unitPrice)
	if err != nil {
		return decimal.Zero, fmt.Errorf("parse unit price: %w", err)
	}
	tax, err := decimal.NewFromString(taxRate)
	if err != nil {
		return decimal.Zero, fmt.Errorf("parse tax rate: %w", err)
	}
	if price.IsNegative() || tax.IsNegative() || quantity <= 0 {
		return decimal.Zero, errors.New("price, tax and quantity must be positive")
	}
	subtotal := price.Mul(decimal.NewFromInt(quantity))
	return subtotal.Mul(decimal.NewFromInt(1).Add(tax)).RoundBank(2), nil
}

Decimal 不是无限资源。恶意输入带几十万位小数会造成大整数分配与 CPU 压力;解析前限制字符串长度、小数位和绝对值。除法要确认除数非零,并规定计算精度。库的全局精度变量会形成可变全局状态,不应由请求动态修改。

9. 舍入、税额与分摊必须成为领域规则

“保留两位”仍不完整。常见规则包括四舍五入、银行家舍入、向零、向正无穷;正负金额的结果也可能不同。税是逐行舍入后相加,还是先汇总再舍入,会产生可见差额。退款应使用原订单保存的税额与分摊结果,不能用当前规则重新计算。

把 100 分平均分给 3 项时,商是 33、余数是 1。确定性算法先按权重计算向下份额,再按稳定顺序把余数逐个加一,结果为 34、33、33。稳定顺序可用行号或不可变 ID;map 遍历顺序不能承担财务规则。

func splitEvenly(total int64, parts int) ([]int64, error) {
	if total < 0 || parts <= 0 {
		return nil, errors.New("invalid split arguments")
	}
	result := make([]int64, parts)
	base := total / int64(parts)
	remainder := total % int64(parts)
	for i := range result {
		result[i] = base
		if int64(i) < remainder {
			result[i]++
		}
	}
	return result, nil
}

测试必须断言各项之和恒等于原值、每项非负、最大差不超过一,并固定余数归属顺序。

10. SQL 精度、扫描与事务所有权

整数金额用 BIGINT 并增加业务范围约束;Decimal 使用明确的 NUMERIC(precision, scale)NUMERIC(19,4) 表示总共 19 位、其中 4 位小数,不是“整数 19 位再加 4 位”。数据库可能在写入时舍入或拒绝超精度值,应用应先按同一规则验证,仍要把数据库错误返回。

驱动对 Decimal 的支持不同。最稳妥的边界通常是发送规范十进制字符串,并扫描为字符串后调用 decimal.NewFromString。不要扫描到 float64 再转 Decimal。事务由创建它的函数负责 commit/rollback,错误只处理一次:

func transfer(ctx context.Context, db *sql.DB, amount Money) error {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin transfer: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	// 两条带条件的 UPDATE 必须检查 RowsAffected,示例省略具体账户字段。
	if err := applyTransfer(ctx, tx, amount); err != nil {
		return fmt.Errorf("apply transfer: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit transfer: %w", err)
	}
	return nil
}

这里回滚错误在已提交或原错误存在时没有更好处理位置,生产代码若需要区分回滚失败,应显式保存并组合错误。提交返回网络错误时结果可能未知,依靠唯一业务幂等键查询最终状态。

11. 缺失、null、零值和 Optional

更新 API 常有四种状态:字段未出现、显式 null、出现零值、出现非零值。*int64 只能区分 nil 与有值,无法单独表达未出现和 null;sql.NullInt64 用于数据库扫描方便,却会把存储细节扩散到领域和 JSON。

可以为具体边界设计 Optional,记录 SetNull 和值,但不要把一个复杂泛型类型强推到所有层:

type Optional[T any] struct {
	Value T
	Set   bool
	Null  bool
}

func (o *Optional[T]) UnmarshalJSON(data []byte) error {
	o.Set = true
	if bytes.Equal(data, []byte("null")) {
		o.Null = true
		var zero T
		o.Value = zero
		return nil
	}
	o.Null = false
	if err := json.Unmarshal(data, &o.Value); err != nil {
		return fmt.Errorf("decode optional value: %w", err)
	}
	return nil
}

需要注意:若整个字段未出现,UnmarshalJSON 不会被调用,Set 保持 false。进入领域层后应尽快把 patch 转为明确命令,避免后续代码反复猜状态。输出 API 的 omitempty、空数组和 null 也应写进契约测试。

12. 并发、所有权与不可变值

UUID 和 Decimal 值适合按值传递,但包含 slice、map 的领域结构仍可能共享底层数据。构造函数接收批量金额或 ID 时应复制 slice,返回集合快照也要复制。Snowflake 生成器的锁、上次时间与序列属于同一对象,必须用指针接收者且禁止复制;可以加入 noCopy 供静态工具发现误用,但文档契约同样重要。

不要为每次生成另开 goroutine。一个短 mutex 临界区通常足够,若同毫秒耗尽需要等待,应释放锁后等待,再重新获取并二次检查,避免阻塞其他能观察取消的调用。并发安全只说明数据竞争被控制,不说明 worker ID 租约仍有效。

金额对象尽量不可变。缓存中存储 []Money 时拷贝容器;如果 Money 内含指针或自定义大整数,还要确认库是否会暴露可变内部表示。依赖升级用 race 和契约测试验证,不根据“看起来像值类型”推断所有权。

13. 失败诊断、安全与迁移

解析错误应记录字段名、输入长度和错误类别,不要把完整支付备注、令牌或超长恶意输入写入日志。指标区分格式错误、范围错误、时钟回拨、节点租约失效、Decimal 超精度、数据库约束与请求取消。ID 生成失败不是自动降级为随机数的理由,两套格式混用会破坏排序和数据库契约。

ID 不等于秘密,也不等于授权。UUID 难猜只是降低枚举便利,服务仍需检查租户与资源归属。错误响应避免透露“此 ID 存在但你无权访问”。外部提供的 ID 在进入查询前要限制长度与字符集,批量 ID 数量要设上限,防止巨大 IN 条件和内存占用。

从整数 ID 迁移 UUID 时,先增加新列并回填,建立唯一索引;双写并校验;读路径支持新键并观测;所有消费者完成迁移后再停止旧键。不要一次部署同时改变数据库主键、JSON 类型和消息 schema。金额 scale 迁移同样需要离线校验总额守恒。

14. 测试与性能验证

表驱动测试覆盖 ID 的空串、符号、最大值、溢出、UUID Nil、大小写和非法长度;Snowflake 用可注入时钟测试同毫秒递增、序列耗尽、回拨、epoch 前后与并发唯一性。金额测试使用字符串期望值,不用 float 比较;覆盖负数、零、最大位数、不同币种、各种舍入和分摊守恒。

func TestSplitEvenly(t *testing.T) {
	tests := []struct {
		name  string
		total int64
		parts int
		want  []int64
	}{
		{name: "remainder", total: 100, parts: 3, want: []int64{34, 33, 33}},
		{name: "exact", total: 12, parts: 3, want: []int64{4, 4, 4}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := splitEvenly(tt.total, tt.parts)
			if err != nil {
				t.Fatal(err)
			}
			if !slices.Equal(got, tt.want) {
				t.Errorf("split = %v, want %v", got, tt.want)
			}
		})
	}
}

性能测试分别测生成、解析、数据库索引和 JSON,不只比较 ns/op。UUID 文本会增加带宽,Decimal 运算会分配大整数,Snowflake mutex 会在热点争用;是否重要必须由真实吞吐、B/op、P99 和数据库页行为证明。

go test ./...
go test -race ./...
go test -bench=. -benchmem ./...
go vet ./...

15. 生产选型清单

  • 单库内部主键优先考虑数据库自增;跨节点离线生成可选 UUID;确需紧凑、趋势有序且能运维节点租约时再用 Snowflake。
  • 所有大整数 ID 在 JSON 中使用字符串,数据库列和跨语言协议固定范围与格式。
  • 固定 scale 的单币种账本优先最小单位整数;比例、税率与多精度计算使用 Decimal,但最终入账仍有明确 scale。
  • 舍入时机、模式、负数行为和余数分配是版本化领域规则,不能散落在 handler。
  • Optional 只在确实存在多状态的边界使用,进入领域层后转换为明确命令。
  • 生成器、事务和后台租约都有唯一所有者;取消表示停止等待,不擅自假设外部写入已回滚。
  • 对长度、位数、批量数量和绝对值设上限;ID 难猜不能替代租户授权。
  • 升级 google/uuidshopspring/decimal 前运行格式、舍入、SQL 和性能契约测试。

系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。