Chapter 09

错误信息、日志和调试英语

一句好的错误信息能让用户 30 秒内解决问题——错误英文是被严重低估的工程美德。

9.1 错误信息为什么是英语难题

错误信息有三个特殊约束,让它比一般技术文本更难写:

  1. 极短:通常一行内(终端宽度 80 字符)。每个词都必须有用。
  2. 面向陌生人:任何用户、任何水平都可能看到。不能假设上下文。
  3. 必须可执行:错误信息的目的是让用户做点什么。如果看完不知道下一步,就是失败的错误信息。

对比:

# 失败的错误信息
Error: Operation failed.
Something went wrong.
An error occurred.
500 Internal Server Error.
undefined is not a function.

# 优秀的错误信息(Rust 编译器风格)
error[E0382]: borrow of moved value: `s`
 --> src/main.rs:5:20
  |
3 |     let s = String::from("hello");
  |         - move occurs because `s` has type `String`, which does not implement the `Copy` trait
4 |     let s2 = s;
  |              - value moved here
5 |     println!("{}", s);
  |                    ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
4 |     let s2 = s.clone();
  |               ++++++++

第二个版本做对了 5 件事:(1) 错误码可搜索;(2) 标出代码位置;(3) 用类比解释概念;(4) 用箭头视觉标记;(5) 提供修复建议。

9.2 错误信息的标准结构

好的错误信息遵循 4 段结构:What / Where / Why / How

1. What failed   — 客观陈述发生了什么
2. Where         — 哪个文件 / 哪一行 / 哪个函数 / 哪个请求
3. Why           — 根本原因 (如果可推断)
4. How to fix    — 用户能做的下一步

例子(Stripe API 错误):

# What
Your card was declined.

# Where
charge id: ch_abc123, customer: cus_xyz789

# Why
Issuer responded with code 51 (insufficient_funds).

# How to fix
Try a different payment method, or contact your bank.

# 用户面给前 3 段,调试日志给全部 4 段。

四段在不同语言里的实现

# Go
return fmt.Errorf("parse config %q: %w", path, err)
//                ^What    ^Where        ^Why (wrapped)

# Python
raise ValueError(
    f"invalid email {email!r}: must contain '@' and a TLD"
    #     ^What       ^Where     ^Why
)

# JavaScript
throw new TypeError(
  `Expected User but got ${typeof input}. ` +
  `Did you forget to await the fetch?`
);

# Java
throw new IllegalArgumentException(
    "Port must be between 1 and 65535, got: " + port
);

9.3 actionable 原则

每一条错误信息都应当通过 "actionable test":用户读完,能明确知道下一步做什么

不 actionableactionable
Network errorConnection to api.example.com timed out after 30s. Check your network and retry.
Invalid inputField 'email' must contain '@'. Got: 'foo.com'
Permission deniedNeed write access to /var/lib/app. Run with sudo, or set APP_DATA_DIR to a writable path.
Operation failedFailed to upload chunk 3/10 to s3://bucket/key. Retry: yes (idempotent). See logs for the underlying S3 error.
No such fileConfig file not found at /etc/app/config.yaml. Create it from /etc/app/config.example.yaml or set APP_CONFIG.

9.4 错误信息的语态规则

规则 1:用主动语态描述事实

# Bad — 被动 + 模糊
"The file could not be opened."
"An error has occurred."

# Good — 主动 + 具体
"Cannot open '/etc/app.conf': permission denied"
"Failed to parse line 42: unexpected token ','"

规则 2:用陈述句描述状态

# Bad — 命令式 + 责怪用户
"Don't forget to set DATABASE_URL!"
"You must provide a valid email."

# Good — 中性陈述
"DATABASE_URL is not set. Set it in your environment or .env file."
"Email 'foo' is invalid: must contain '@' and a domain."

规则 3:避免敷衍词

# Bad
"Something went wrong."
"An unexpected error occurred."
"Oops! Try again later."

# Good
"Database query timed out after 5s."
"Got HTTP 503 from upstream service `payments`."

规则 4:给量化数据

# Bad
"File too large."
"Too many requests."

# Good
"File 4.2 GB exceeds the 100 MB limit."
"Rate limit exceeded: 1500 / 1000 req/min. Resets in 23s."

9.5 日志级别和写作语气

大多数日志库采用 RFC 5424 的 syslog 级别(或简化版):

级别含义写作语气例子
TRACE极细粒度,调试用当下时态,描述每一步entering parseHeader, buf=0x1234
DEBUG开发调试客观陈述,丰富上下文cache miss for key=user:42
INFO正常事件过去时,里程碑started server on :8080
WARN异常但不影响功能引起注意config file outdated; using defaults
ERROR失败但服务还活着具体原因 + 影响failed to send email to user@x: SMTP timeout
FATAL / CRITICAL不可恢复导致退出cannot bind to :80; exiting

级别选择的常见错误

# 错误:把 INFO 当 DEBUG 用,日志爆炸
INFO  fetched user
INFO  parsed JSON
INFO  applied filter

# 正确:开发用 DEBUG,生产里程碑用 INFO
DEBUG fetched user id=42 (took 12ms)
DEBUG parsed JSON (5 fields)
INFO  request completed: GET /users/42 -> 200 (15ms)

# 错误:把 WARN 当 ERROR 用
WARN  user not found     # 用户输错 id 不是 warning
ERROR connection refused # 这个才是 error 或更高

# 正确
INFO  user not found: id=42
ERROR upstream connection refused: payments-service:9000

结构化日志的英文 key 命名

# 推荐 snake_case 或 camelCase(项目内统一)
log.info("request completed",
    request_id="...",
    user_id=42,
    method="GET",
    path="/users/42",
    status=200,
    duration_ms=15,
    bytes=1024,
)

# 标准 key 词表(OpenTelemetry / ECS 兼容)
- service.name        - 服务名
- trace.id            - 追踪 id
- span.id             - 跨 id
- http.method         - HTTP 方法
- http.status_code    - 状态码
- http.url            - URL
- user.id             - 用户 id
- error.type          - 错误类型
- error.message       - 错误信息
- error.stack         - 堆栈
- duration_ms         - 时长
- db.statement        - SQL
- net.peer.name       - 远端主机

9.6 Stack Overflow 提问的优秀格式

Stack Overflow 的金句:"The quality of your question is more important than your code." 一份好提问 30 分钟得到答案,差提问 3 天没人理。

SO 提问标准结构

### Title (<= 80 字符)
具体技术 + 行为 + 错误。例:
"PostgreSQL trigger doesn't fire when row is inserted via COPY"

### 1. What you're trying to do
(一段,1-3 句)
"I'm building a CDC pipeline that needs to react to every row
inserted into a `events` table. I have a BEFORE INSERT trigger
that writes to a sibling table."

### 2. What happens
(含具体 error message + 复现步骤)
"When I insert via INSERT, the trigger fires. When I insert via
COPY FROM STDIN, the trigger does not fire. No error message —
the rows just don't appear in the sibling table."

### 3. Minimal reproducible example (MRE)
```sql
CREATE TABLE events (id int, data text);
CREATE TABLE events_audit (id int, copied_at timestamp);

CREATE OR REPLACE FUNCTION audit_event() RETURNS trigger AS $$
BEGIN
  INSERT INTO events_audit VALUES (NEW.id, now());
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_trg BEFORE INSERT ON events
  FOR EACH ROW EXECUTE FUNCTION audit_event();

-- This works: events_audit gets a row
INSERT INTO events VALUES (1, 'a');

-- This DOES NOT work: events_audit stays empty
COPY events FROM STDIN;
2  b
\.
```

### 4. What I tried
- Read the docs at https://www.postgresql.org/docs/16/sql-copy.html
- Tried adding `WHEN (TRUE)` to the trigger — no effect
- Searched for "COPY trigger" — found old answers from 2010,
  but they say it should work now

### 5. Environment
- PostgreSQL 16.2 on Ubuntu 22.04
- psql 16.2

### 6. Question
Is there a flag I need to set, or does COPY genuinely bypass
row-level triggers in modern Postgres?

SO 提问的禁忌

# Bad title
"please help"
"my code doesn't work"
"PostgreSQL question"
"Why won't this work??"

# Bad body
"Can someone help me with the following?
<500 行代码 dump>
Thanks!"

# Bad tone
"This is urgent!!!"
"I've been struggling for 5 days, please someone help."

# 没有 MRE
"My production code does X but I can't share it."

# 没说自己尝试过什么
(懒得搜索的提问会被关闭)

9.7 30 个真实错误信息拆解

JavaScript / TypeScript(10 条)

1. TypeError: Cannot read properties of undefined (reading 'name')
   = 试图访问 undefined.name;通常是某个对象在你期望的位置不存在。
   修:先检查对象存在 (obj?.name) 或上游代码为何返回 undefined。

2. SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
   = 你以为收到的是 JSON,实际收到了 HTML(很可能是 404 页)。
   修:检查 URL 是否对,或后端是否抛错返回了 HTML 错误页。

3. ReferenceError: foo is not defined
   = 用了未声明的变量。
   修:检查拼写 / import / scope。

4. TypeError: x is not a function
   = 调用了非函数的东西。
   修:console.log(typeof x) 看看实际是什么。

5. Error: ECONNREFUSED 127.0.0.1:5432
   = TCP 连接被拒,目标没在监听。
   修:服务没启动?端口错了?防火墙?

6. UnhandledPromiseRejection: Error: ...
   = Promise 抛错没人 catch。Node 18+ 会让进程崩。
   修:加 try/catch 或 .catch()。

7. Module not found: Can't resolve './foo' in '/src'
   = webpack 找不到模块。
   修:路径错?大小写错(macOS 不敏感但 Linux CI 敏感)?

8. RangeError: Maximum call stack size exceeded
   = 递归过深 / 死循环。
   修:检查递归终止条件。

9. CORS error: No 'Access-Control-Allow-Origin' header
   = 浏览器跨域被阻。
   修:后端加 CORS header,或用同源代理。

10. ENOSPC: System limit for number of file watchers reached
    = inotify watcher 用尽(macOS / Linux dev 常见)。
    修:sysctl fs.inotify.max_user_watches=524288

Go(5 条)

11. panic: runtime error: invalid memory address or nil pointer dereference
    = 解引用 nil 指针。
    修:在 deref 前 if x != nil 检查。

12. panic: send on closed channel
    = 向已关闭的 channel 发送。
    修:发送方不要在 close 后继续 send;用 select+ok 模式。

13. fatal error: concurrent map writes
    = 多 goroutine 同时写 map(map 本身非并发安全)。
    修:用 sync.Mutex 或 sync.Map。

14. dial tcp 127.0.0.1:6379: connect: connection refused
    = 同 ECONNREFUSED,Redis 没起?

15. context deadline exceeded
    = 上下文超时。
    修:增加 timeout,或检查上游是否真的需要这么久。

Python(5 条)

16. AttributeError: 'NoneType' object has no attribute 'foo'
    = 在 None 上访问属性。
    修:检查为何对象是 None。

17. KeyError: 'foo'
    = dict 里没有键 'foo'。
    修:用 .get('foo', default) 或先 in 检查。

18. ImportError: cannot import name 'X' from 'Y'
    = 模块里没这个名字(或循环 import)。
    修:检查模块版本 / 是否有 __all__ 限制。

19. RecursionError: maximum recursion depth exceeded
    = 递归过深。
    修:sys.setrecursionlimit() 或改用迭代。

20. ssl.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED]
    = 证书校验失败(macOS 上 Python 自带证书过期是常见原因)。
    修:/Applications/Python\ 3.x/Install\ Certificates.command

Linux / 网络(5 条)

21. bash: command not found
    = 命令不在 PATH 里。
    修:echo $PATH,或用绝对路径。

22. Permission denied (publickey)
    = SSH 公钥认证失败。
    修:ssh-add ~/.ssh/id_rsa;或 ~/.ssh/config 配 IdentityFile。

23. address already in use: bind
    = 端口被占用。
    修:lsof -i :8080 找出占用进程。

24. No route to host
    = 路由不通(网络层面)。
    修:检查 firewall / VPN / 路由表。

25. Disk quota exceeded
    = 磁盘配额满(不一定是 df 显示的"总满")。
    修:quota -u $(whoami) 查;清理或申请扩容。

HTTP / 数据库(5 条)

26. 502 Bad Gateway
    = 反向代理收到的 upstream 响应无效。
    修:检查后端服务是否在跑,nginx error.log。

27. 504 Gateway Timeout
    = 反向代理等 upstream 超时。
    修:增加 proxy_read_timeout,或修慢的接口。

28. 401 Unauthorized
    = 没登录 / token 错。
    29. 403 Forbidden
    = 已登录但没权限。注意 401 和 403 的区别。

30. ERROR 1062 (23000): Duplicate entry 'foo' for key 'PRIMARY'
    = MySQL 主键重复。
    修:INSERT ... ON DUPLICATE KEY UPDATE,或检查为何重复。

9.8 中文翻译陷阱

很多中国程序员把英文错误信息直接逐字翻译成中文给同事看,造成误解。常见翻译陷阱:

英文糟糕的直译准确的中文
resource exhausted资源筋疲力尽资源耗尽
operation timed out操作超时了操作超时(time 是动词)
not implemented没有实现未实现 / 暂不支持
method not allowed方法不允许不允许此 HTTP 方法
too many open files打开文件太多了已打开的文件数超限
connection refused连接拒绝连接被拒(目标未监听)
broken pipe坏管道管道断开(对端先关了)
bus error总线错误总线错(对齐 / mmap 越界)

9.9 写出好错误信息的 5 条原则

  1. 不要写 "An error occurred"。 这等于没说。给具体原因。
  2. 给上下文。"Cannot open file" → "Cannot open '/etc/foo': permission denied"。
  3. 给修复建议。"port in use" → "port 8080 in use; try another with --port=N"。
  4. 不要责怪用户。"Invalid input" 比 "You entered the wrong thing" 中性。
  5. 错误码可搜。Rust 的 E0382、Postgres 的 23000 让用户能直接搜到 docs。
// rule

"Errors are part of your API." 错误信息和 SDK 的方法签名一样重要——它们决定了用户在出问题时多久能恢复。

9.10 本章小结

下一章我们走出公司内部,进入海外开源协作的世界。