大促零点,用户看到了一条数据库报错
「回声」大促零点,下单接口的 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 状态、重试标记和展示方式。
| 类别 | 谁来解决 | 典型 code | HTTP | Retryable | 用户看到什么 |
|---|---|---|---|---|---|
| 用户可解决 | 用户 | ADDRESS_INVALID、COUPON_EXPIRED | 400 / 409 / 422 | 否 | 具体到字段的提示和修改入口 |
| 系统可恢复 | 运维或依赖方 | RATE_LIMITED、UPSTREAM_TIMEOUT | 429 / 503 / 504 | 是(带退避) | “请稍后再试”,不暴露依赖名 |
| 不该发生 | 研发 | ORDER_SAVE_FAILED、INTERNAL | 500 | 否 | 通用提示 + 可追踪的请求号 |
第一类是用户能自己处理的。地址缺门牌号、优惠券过期、库存不足,这些都需要具体文案,最好能定位到表单字段。它们的 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 在单行上看不出来,只有全表校验才能发现。
- 把内部词检查当成文案评审的替代品。它只能挡住少数实现词,不能保证提示可操作。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。