《互联网产品的服务端架构·产品、流量与基础能力》6.2 错误码与用户可感知的失败

从大促零点一条把 SQL 报错返回给用户的事故写起,说明错误码为什么是产品契约而不是异常栈导出,如何区分用户可解决、系统可恢复与不该发生的失败,并用可重试标记约束客户端的自动重试行为;附错误码表校验与查询的 Go 实现和边界测试,避免局部故障被重试放大成全站事故。

大促零点,用户看到了一条数据库报错

「回声」大促零点,下单接口的 QPS 是平日的六倍。有一批请求撞上了库存扣减的唯一约束,处理函数把底层错误直接写进了响应:

HTTP/1.1 500 Internal Server Error
{"code":"INTERNAL","message":"pq: duplicate key value violates unique constraint \"stock_pkey\" (SQL: update stock set locked = locked + 1 where sku_id = 8812)"}

用户看到的是一段英文和 SQL。有人截图发到社交平台,配文是“这个 App 把我的订单号写进了数据库”。实际上那段文字里没有订单号,但有表名、列名、约束名和主键值;对攻击者来说,这是一张现成的库表结构图。

更糟的是客户端的重试策略。SDK 的默认规则是“5xx 自动重试三次,间隔 100 毫秒”。零点后十分钟内,大约四千次下单请求触发了十二万次重试;这些重试继续打到已经紧张的数据库连接池,库存服务开始超时,首页和商品详情也跟着 500。一次局部约束冲突,被错误码和重试策略放大成了全站故障。

错误码是产品契约,不是异常栈导出

错误响应至少要回答三个问题:发生了什么、用户能做什么、客户端要不要自动重试。这三个问题属于产品契约,不属于某个处理函数的实现细节。

团队后来把错误分成三类,每一类对应不同的 HTTP 状态、重试标记和展示方式。

类别谁来解决典型 codeHTTPRetryable用户看到什么
用户可解决用户ADDRESS_INVALID、COUPON_EXPIRED400 / 409 / 422否具体到字段的提示和修改入口
系统可恢复运维或依赖方RATE_LIMITED、UPSTREAM_TIMEOUT429 / 503 / 504是(带退避)“请稍后再试”,不暴露依赖名
不该发生研发ORDER_SAVE_FAILED、INTERNAL500否通用提示 + 可追踪的请求号

第一类是用户能自己处理的。地址缺门牌号、优惠券过期、库存不足,这些都需要具体文案,最好能定位到表单字段。它们的 Retryable 通常是 false,因为原样重试不会让地址变完整;只有 429 是例外,用户等一会儿再试确实可能成功。

第二类是系统暂时不可恢复,但重试有意义。上游超时、限流、依赖短暂不可用都属于这一类。Retryable: true 是一个承诺:客户端可以自动重试,但必须带退避和上限。服务端同时返回 Retry-After,让客户端知道等多久,而不是用 100 毫秒把对方再打一遍。

第三类是不该发生的。空指针、约束冲突、序列化失败,用户看不懂,也不该看懂。对外只给通用文案和请求号,对内把原始错误、堆栈和 trace ID 写进日志。Retryable 必须是 false:同一个请求再跑一次,大概率还是同样的 bug。

Retryable 还有一个常被忽略的前提:只有幂等操作才能安全地自动重试。一个没有 Idempotency-Key 的下单 POST,即使错误是 503,客户端自动重试也可能产生第二笔订单。所以错误码表不能脱离上一节的命令语义单独设计。

失败场景:一条 500 截图和十二万次重试

把时间线拆开看,这次事故有三层后果。

第一层是体验。用户在下单页看到一段英文和 SQL,不知道订单有没有成功,于是反复点击“提交”。每一次点击都是一次新的 POST,没有幂等键,服务端无法判断它们是不是同一个购买意图。

第二层是信息泄露。错误文本里有表名 stock、列名 locked、约束名 stock_pkey 和主键值 8812。攻击者可以用这些信息推断分库分表规则、字段命名习惯,甚至构造更精准的注入探测。即使没有注入漏洞,这种泄露也违反了最小暴露原则。

第三层是雪崩。SDK 把所有 5xx 都当成“ transient ”,自动重试三次。四千次原始请求变成十二万次数据库访问,连接池被占满,原本健康的接口开始超时,超时又触发更多重试。错误码里的 Retryable 没有被认真设计,客户端的默认策略就替服务端做了决定。

修复分三步。对外,所有 500 只返回 INTERNAL 和请求号;约束冲突映射成 ORDER_DUPLICATED,返回 409 且不可重试;限流和上游超时返回 503/504,Retryable: true 并带 Retry-After。对内,原始错误只进日志,同时把请求号返回给用户,客服可以用它定位。对客户端,SDK 只对明确标记 Retryable 的错误重试,并且必须带指数退避。

用 Validate 把错误码表变成启动门禁

错误码一旦发出去,就是客户端要解析的契约。靠代码评审记住“不要写 SQL”不可靠,更稳的办法是把规则写成启动时执行的校验。

// file: internal/api/errors.go
// tier: L1
// verify: go test ./internal/api/

package api

import (
	"fmt"
	"strings"
)

// ErrorCode 是一个对外可见的错误契约。
// 它不带内部错误,也不带堆栈;那些只进日志。
type ErrorCode struct {
	Code        string
	HTTPStatus  int
	UserMessage string
	Retryable   bool
}

// internalWords 是用户文案里不允许出现的实现细节。
// 检查不区分大小写,"SQL" 和 "sql" 都算泄漏。
var internalWords = []string{"sql", "nil", "panic", "stack", "goroutine"}

// Lookup 按 code 精确查找。找不到时返回错误,
// 不允许调用方把未知 code 猜成 500。
func Lookup(codes []ErrorCode, code string) (ErrorCode, error) {
	for _, c := range codes {
		if c.Code == code {
			return c, nil
		}
	}
	return ErrorCode{}, fmt.Errorf("error code %q not found", code)
}

// Validate 检查整张错误码表。
// 任何一条不合格都会让启动失败,而不是等到线上第一次报错。
func Validate(codes []ErrorCode) error {
	seen := make(map[string]bool, len(codes))
	for i, c := range codes {
		if !validCode(c.Code) {
			return fmt.Errorf("codes[%d]: invalid code %q", i, c.Code)
		}
		if seen[c.Code] {
			return fmt.Errorf("codes[%d]: duplicate code %q", i, c.Code)
		}
		seen[c.Code] = true
		if c.HTTPStatus < 400 || c.HTTPStatus > 599 {
			return fmt.Errorf("codes[%d] %s: status %d out of 400..599", i, c.Code, c.HTTPStatus)
		}
		if strings.TrimSpace(c.UserMessage) == "" {
			return fmt.Errorf("codes[%d] %s: empty user message", i, c.Code)
		}
		lower := strings.ToLower(c.UserMessage)
		for _, word := range internalWords {
			if strings.Contains(lower, word) {
				return fmt.Errorf("codes[%d] %s: user message contains %q", i, c.Code, word)
			}
		}
	}
	return nil
}

// validCode 要求首字符是大写字母,其余只允许大写字母、数字和下划线。
func validCode(code string) bool {
	if code == "" || code[0] < 'A' || code[0] > 'Z' {
		return false
	}
	for i := 0; i < len(code); i++ {
		c := code[i]
		if (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
			continue
		}
		return false
	}
	return true
}

ErrorCode 只有四个字段,刻意不包含内部错误。内部错误带堆栈、带 SQL、带依赖地址,它属于日志;对外响应只保留稳定的 code、HTTP 状态、用户文案和重试标记。把两者放进同一个结构体,迟早会有人为了“方便排查”把 err.Error() 塞进 UserMessage。

Validate 在启动时检查四件事。code 必须是大写字母开头的“大写下划线”形式,客户端可以据此做稳定的字符串匹配;code 不能重复,否则同一错误在不同模块会返回不同文案;HTTP 状态必须在 400 到 599 之间,避免把业务错误写成 200;用户文案不能为空,也不能包含 sql、nil、panic、stack、goroutine 这些实现词。

内部词检查不区分大小写。SQL、Sql、sql 都会命中;NIL 也一样。这是一个便宜的静态门禁,不是万能药:它挡不住“连接池耗尽”这种同样不该给用户看的词,所以文案仍然需要产品和技术一起评审。

Lookup 只做精确匹配。找不到 code 时返回错误,而不是回退到 500。回退看起来“兼容”,实际会把拼错的 code 悄悄变成一个通用错误,监控里看不出是哪条契约失效了。

这段代码没有检查“文案是否真的可操作”,也没有检查 code 的语义是否和 HTTP 状态一致。那些需要评审和端到端测试;Validate 负责的是那些可以机械判定的底线。

测试:内部词、重复码、越界状态

错误码表的测试应该覆盖三类问题:单条记录不合法、整张表出现重复、查询未知 code。下面这组测试把每一类都固定下来。

// file: internal/api/errors_test.go
// tier: L1
// verify: go test ./internal/api/

package api

import (
	"strings"
	"testing"
)

func validTable() []ErrorCode {
	return []ErrorCode{
		{Code: "ORDER_NOT_FOUND", HTTPStatus: 404, UserMessage: "订单不存在,请检查后重试", Retryable: false},
		{Code: "RATE_LIMITED", HTTPStatus: 429, UserMessage: "操作太频繁,请稍后再试", Retryable: true},
		{Code: "UPSTREAM_TIMEOUT", HTTPStatus: 503, UserMessage: "服务暂时不可用,请稍后再试", Retryable: true},
	}
}

func TestValidateAcceptsContractTable(t *testing.T) {
	if err := Validate(validTable()); err != nil {
		t.Fatalf("Validate() = %v, want nil", err)
	}
}

func TestValidateRejectsBadRows(t *testing.T) {
	cases := []struct {
		name string
		row  ErrorCode
		want string
	}{
		{
			name: "lowercase code",
			row:  ErrorCode{Code: "order_not_found", HTTPStatus: 404, UserMessage: "订单不存在"},
			want: "invalid code",
		},
		{
			name: "code with dash",
			row:  ErrorCode{Code: "ORDER-NOT-FOUND", HTTPStatus: 404, UserMessage: "订单不存在"},
			want: "invalid code",
		},
		{
			name: "empty code",
			row:  ErrorCode{Code: "", HTTPStatus: 404, UserMessage: "订单不存在"},
			want: "invalid code",
		},
		{
			name: "status below 400",
			row:  ErrorCode{Code: "ORDER_OK", HTTPStatus: 200, UserMessage: "订单正常"},
			want: "out of 400..599",
		},
		{
			name: "status above 599",
			row:  ErrorCode{Code: "ORDER_WEIRD", HTTPStatus: 600, UserMessage: "订单异常"},
			want: "out of 400..599",
		},
		{
			name: "empty message",
			row:  ErrorCode{Code: "ORDER_EMPTY", HTTPStatus: 400, UserMessage: "   "},
			want: "empty user message",
		},
		{
			name: "leaks sql",
			row:  ErrorCode{Code: "ORDER_SAVE_FAILED", HTTPStatus: 500, UserMessage: "insert into orders failed: sql error"},
			want: "contains",
		},
		{
			name: "leaks nil in caps",
			row:  ErrorCode{Code: "ORDER_NIL", HTTPStatus: 500, UserMessage: "order pointer is NIL"},
			want: "contains",
		},
		{
			name: "leaks goroutine",
			row:  ErrorCode{Code: "ORDER_CRASH", HTTPStatus: 500, UserMessage: "goroutine 42 panic"},
			want: "contains",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := Validate([]ErrorCode{tc.row})
			if err == nil {
				t.Fatalf("Validate(%+v) = nil, want error", tc.row)
			}
			if !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("error %q does not contain %q", err, tc.want)
			}
		})
	}
}

func TestValidateRejectsDuplicateCode(t *testing.T) {
	codes := append(validTable(), ErrorCode{
		Code:        "RATE_LIMITED",
		HTTPStatus:  429,
		UserMessage: "请求过多",
	})
	err := Validate(codes)
	if err == nil || !strings.Contains(err.Error(), "duplicate") {
		t.Fatalf("Validate() = %v, want duplicate error", err)
	}
}

func TestLookupFoundAndMissing(t *testing.T) {
	codes := validTable()

	got, err := Lookup(codes, "RATE_LIMITED")
	if err != nil {
		t.Fatalf("Lookup() error: %v", err)
	}
	if got.HTTPStatus != 429 || !got.Retryable {
		t.Fatalf("Lookup() = %+v, want 429 retryable", got)
	}

	if _, err := Lookup(codes, "NOT_A_CODE"); err == nil {
		t.Fatal("unknown code should return an error")
	}
}

TestValidateAcceptsContractTable 先给出一张合法的表:404 的“订单不存在”、429 的“操作太频繁”、503 的“服务暂时不可用”。它保证校验器不会误伤正常配置。

TestValidateRejectsBadRows 用表驱动覆盖九种坏行:小写 code、带横线的 code、空 code、200 状态、600 状态、空白文案、泄露 SQL、泄露大写 NIL、泄露 goroutine。每个用例都检查错误信息里包含预期的关键词,而不是比较完整字符串,这样以后调整措辞不会让测试变成维护负担。

TestValidateRejectsDuplicateCode 把一条重复的 RATE_LIMITED 追加到合法表后面,验证重复检查生效。重复 code 是最隐蔽的问题之一:单看每一行都合法,只有整张表一起校验才能发现。

TestLookupFoundAndMissing 同时验证命中和未命中。命中时检查状态码和 Retryable 是否原样返回;未命中时要求返回错误。这个测试防的是“未知 code 默认 500”的偷懒实现。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/api/ -count=1 -run 'TestValidate|TestLookup' -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestValidateAcceptsContractTable
--- PASS: TestValidateAcceptsContractTable (0.00s)
=== RUN   TestValidateRejectsBadRows
=== RUN   TestValidateRejectsBadRows/lowercase_code
=== RUN   TestValidateRejectsBadRows/code_with_dash
=== RUN   TestValidateRejectsBadRows/empty_code
=== RUN   TestValidateRejectsBadRows/status_below_400
=== RUN   TestValidateRejectsBadRows/status_above_599
=== RUN   TestValidateRejectsBadRows/empty_message
=== RUN   TestValidateRejectsBadRows/leaks_sql
=== RUN   TestValidateRejectsBadRows/leaks_nil_in_caps
=== RUN   TestValidateRejectsBadRows/leaks_goroutine
--- PASS: TestValidateRejectsBadRows (0.00s)
    --- PASS: TestValidateRejectsBadRows/lowercase_code (0.00s)
    --- PASS: TestValidateRejectsBadRows/code_with_dash (0.00s)
    --- PASS: TestValidateRejectsBadRows/empty_code (0.00s)
    --- PASS: TestValidateRejectsBadRows/status_below_400 (0.00s)
    --- PASS: TestValidateRejectsBadRows/status_above_599 (0.00s)
    --- PASS: TestValidateRejectsBadRows/empty_message (0.00s)
    --- PASS: TestValidateRejectsBadRows/leaks_sql (0.00s)
    --- PASS: TestValidateRejectsBadRows/leaks_nil_in_caps (0.00s)
    --- PASS: TestValidateRejectsBadRows/leaks_goroutine (0.00s)
=== RUN   TestValidateRejectsDuplicateCode
--- PASS: TestValidateRejectsDuplicateCode (0.00s)
=== RUN   TestLookupFoundAndMissing
--- PASS: TestLookupFoundAndMissing (0.00s)
PASS
ok  	webapp/internal/api	0.293s

-run 'TestValidate|TestLookup' 只匹配本节的测试,避免分类器和版本测试混进输出。0.293s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。

小结

错误码是产品契约:它告诉用户发生了什么、能做什么,也告诉客户端要不要重试。把错误分成“用户可解决、系统可恢复、不该发生”三类,比维护一张几百行的 code 清单更容易执行。

代码层面,ErrorCode 把对外字段和内部错误分开,Validate 在启动时检查命名、唯一性、状态范围和内部词,Lookup 对未知 code 显式报错。测试守住九种坏行、重复 code 和未命中查询,让这张表在发布前就能被证伪。

阅读导航:上一节:6.1 资源、命令与产品语义 · 下一节:6.3 版本演进与兼容窗口 。

常见错误与边界

  • 把 err.Error() 直接返回给用户。SQL、表名、堆栈和依赖地址都会泄露,体验也很糟。
  • 所有 5xx 都标记为可重试。没有幂等键的写操作会被客户端自动重放到第二次下单。
  • 用 200 返回业务错误。网关、监控和客户端 SDK 都会把它当成成功,错误率失真。
  • code 用小写或带横线。客户端做字符串匹配时容易出现大小写分叉,跨语言解析也不稳定。
  • 未知 code 回退成 500。拼写错误被悄悄吞掉,契约失效时监控没有信号。
  • 用户文案写“系统异常”。没有可执行信息,用户只能反复重试,客服也无法判断。
  • 只校验单条记录,不校验整张表。重复 code 在单行上看不出来,只有全表校验才能发现。
  • 把内部词检查当成文案评审的替代品。它只能挡住少数实现词,不能保证提示可操作。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查