9.1 错误信息为什么是英语难题
错误信息有三个特殊约束,让它比一般技术文本更难写:
- 极短:通常一行内(终端宽度 80 字符)。每个词都必须有用。
- 面向陌生人:任何用户、任何水平都可能看到。不能假设上下文。
- 必须可执行:错误信息的目的是让用户做点什么。如果看完不知道下一步,就是失败的错误信息。
对比:
# 失败的错误信息
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":用户读完,能明确知道下一步做什么。
| 不 actionable | actionable |
|---|---|
Network error | Connection to api.example.com timed out after 30s. Check your network and retry. |
Invalid input | Field 'email' must contain '@'. Got: 'foo.com' |
Permission denied | Need write access to /var/lib/app. Run with sudo, or set APP_DATA_DIR to a writable path. |
Operation failed | Failed to upload chunk 3/10 to s3://bucket/key. Retry: yes (idempotent). See logs for the underlying S3 error. |
No such file | Config 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 条原则
- 不要写 "An error occurred"。 这等于没说。给具体原因。
- 给上下文。"Cannot open file" → "Cannot open '/etc/foo': permission denied"。
- 给修复建议。"port in use" → "port 8080 in use; try another with --port=N"。
- 不要责怪用户。"Invalid input" 比 "You entered the wrong thing" 中性。
- 错误码可搜。Rust 的 E0382、Postgres 的 23000 让用户能直接搜到 docs。
// rule
"Errors are part of your API." 错误信息和 SDK 的方法签名一样重要——它们决定了用户在出问题时多久能恢复。
9.10 本章小结
- 错误信息四段:What / Where / Why / How。
- actionable 原则:用户读完知道下一步做什么。
- 语态:主动陈述、给数据、不敷衍、不责怪。
- 日志级别:TRACE / DEBUG / INFO / WARN / ERROR / FATAL,每级有不同语气。
- 结构化日志的标准 key 词表(OpenTelemetry/ECS)。
- Stack Overflow 提问 6 段式:title → goal → behavior → MRE → tried → environment → question。
- 30 条真实错误的拆解 + 中文翻译陷阱。
下一章我们走出公司内部,进入海外开源协作的世界。