go-backend-handbook 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/21-complete-project.md +902 -0
- package/22-database-deep-dive.md +826 -0
- package/23-debugging-and-performance.md +851 -0
- package/24-api-contract-and-testing.md +658 -0
- package/25-production-operations.md +594 -0
- package/README.md +39 -0
- package/package.json +1 -1
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
# 第 21 章 完整实战:从需求到上线实现 mini-coin
|
|
2
|
+
|
|
3
|
+
> 这一章不是知识点索引,而是一条可以照着走完的开发主线。
|
|
4
|
+
> 你会从一句需求开始,依次完成 API 契约、领域模型、MySQL 表、事务、幂等、HTTP 接口、缓存、消息、测试、容器化、监控和发布。代码与表名全部属于虚构项目 `mini-coin`。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 21.1 需求不是“写一个充值接口”
|
|
9
|
+
|
|
10
|
+
先把一句模糊需求改写成可以验收的行为:
|
|
11
|
+
|
|
12
|
+
> 用户可以给账户充值;同一个请求重复提交只能成功一次;金额只能为正整数分;冻结账户不能充值;成功后能查询余额;接口要能处理并发;失败要返回稳定错误码。
|
|
13
|
+
|
|
14
|
+
这里已经包含六类信息:
|
|
15
|
+
|
|
16
|
+
| 类型 | 示例 | 如果不写清楚会怎样 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| 功能 | 充值、查余额 | 开发完才发现少功能 |
|
|
19
|
+
| 规则 | 金额 > 0、单位分 | 前端传元、后端按分,金额差 100 倍 |
|
|
20
|
+
| 幂等 | 同一 `request_id` 不重复加钱 | 用户重试导致重复充值 |
|
|
21
|
+
| 状态 | 冻结账户拒绝 | 业务状态被绕过 |
|
|
22
|
+
| 非功能 | 并发、超时、监控 | 单测通过,上线出错 |
|
|
23
|
+
| 可观测 | 请求 ID、错误码、指标 | 出问题只会说“接口挂了” |
|
|
24
|
+
|
|
25
|
+
### 先写验收用例
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
GIVEN 账户 u-1001 余额 100 分,状态正常
|
|
29
|
+
WHEN 提交 req_id=r-1、amount=50
|
|
30
|
+
THEN 返回余额 150 分
|
|
31
|
+
AND 同一 req_id 再提交一次
|
|
32
|
+
THEN 仍返回余额 150 分,不能变成 200 分
|
|
33
|
+
|
|
34
|
+
GIVEN 账户不存在
|
|
35
|
+
WHEN 充值
|
|
36
|
+
THEN 返回 NOT_FOUND,不创建“幽灵”订单
|
|
37
|
+
|
|
38
|
+
GIVEN 账户已冻结
|
|
39
|
+
WHEN 充值
|
|
40
|
+
THEN 返回 ACCOUNT_FROZEN,余额不变
|
|
41
|
+
|
|
42
|
+
GIVEN 20 个并发请求,每个 req_id 都不同
|
|
43
|
+
WHEN 同时充值 10 分
|
|
44
|
+
THEN 余额精确增加 200 分,不丢更新
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**判断需求是否可开发的标准**:任何一个验收句都能被自动化测试翻译出来。如果只能靠口头解释,就还没到开发阶段。
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 21.2 先定 API,再写实现
|
|
52
|
+
|
|
53
|
+
### 请求
|
|
54
|
+
|
|
55
|
+
```http
|
|
56
|
+
POST /v1/recharges
|
|
57
|
+
Content-Type: application/json
|
|
58
|
+
X-Request-Id: trace-demo-001
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
"req_id": "r-20261101-001",
|
|
62
|
+
"uid": "u-1001",
|
|
63
|
+
"amount": 50,
|
|
64
|
+
"channel": "manual"
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
字段约束:
|
|
69
|
+
|
|
70
|
+
| 字段 | 类型 | 规则 |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `req_id` | string | 1~64,客户端生成,同一业务请求复用 |
|
|
73
|
+
| `uid` | string | 1~64,必须存在 |
|
|
74
|
+
| `amount` | int64 | 单位分,必须 > 0,设置业务上限 |
|
|
75
|
+
| `channel` | string | 枚举白名单,例如 `manual`、`coupon` |
|
|
76
|
+
|
|
77
|
+
### 成功响应
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"code": "OK",
|
|
82
|
+
"message": "success",
|
|
83
|
+
"request_id": "trace-demo-001",
|
|
84
|
+
"data": {
|
|
85
|
+
"order_id": "90001",
|
|
86
|
+
"uid": "u-1001",
|
|
87
|
+
"balance": 150,
|
|
88
|
+
"duplicated": false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 失败响应
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"code": "ACCOUNT_FROZEN",
|
|
98
|
+
"message": "account is frozen",
|
|
99
|
+
"request_id": "trace-demo-001"
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**接口设计三条硬规则**:
|
|
104
|
+
|
|
105
|
+
1. HTTP 状态码表达协议层结果,业务 `code` 表达领域结果;
|
|
106
|
+
2. 错误码是稳定契约,不能被日志文案随意替换;
|
|
107
|
+
3. 成功和失败都必须带 `request_id`,否则线上无法串联日志。
|
|
108
|
+
|
|
109
|
+
### 查询余额
|
|
110
|
+
|
|
111
|
+
```http
|
|
112
|
+
GET /v1/accounts/u-1001/balance
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
查询接口不应该要求传用户余额,因为余额只能由服务端算出来。**客户端传来的金额、余额、状态都不可信。**
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 21.3 目录结构:让依赖方向固定
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
mini-coin/
|
|
123
|
+
├── cmd/server/main.go # 组装依赖、启动、优雅退出
|
|
124
|
+
├── internal/httpapi/ # HTTP 适配层
|
|
125
|
+
├── internal/service/ # 业务规则和事务边界
|
|
126
|
+
├── internal/repository/ # MySQL 实现
|
|
127
|
+
├── internal/cache/ # Redis/内存缓存实现
|
|
128
|
+
├── internal/mq/ # Kafka/RabbitMQ/内存实现
|
|
129
|
+
├── internal/model/ # 领域数据结构
|
|
130
|
+
├── migrations/ # 数据库迁移
|
|
131
|
+
├── api/openapi.yaml # HTTP 契约
|
|
132
|
+
└── deploy/ # Docker/K8s 配置
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
依赖只能这样流动:
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
main -> httpapi -> service -> repository/cache/mq interfaces
|
|
139
|
+
^
|
|
140
|
+
|
|
|
141
|
+
MySQL/Redis/MQ adapters
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
反向依赖是错误信号:`repository` 不应 import `httpapi`;`model` 不应知道 HTTP 状态码;业务层不应直接读取环境变量。
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 21.4 领域模型:金额、状态和请求 ID
|
|
149
|
+
|
|
150
|
+
```go
|
|
151
|
+
package model
|
|
152
|
+
|
|
153
|
+
type Account struct {
|
|
154
|
+
ID int64
|
|
155
|
+
UID string
|
|
156
|
+
Balance int64 // 分
|
|
157
|
+
Status int
|
|
158
|
+
Version int64
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const (
|
|
162
|
+
AccountNormal = 1
|
|
163
|
+
AccountFrozen = 2
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
type RechargeOrder struct {
|
|
167
|
+
ID int64
|
|
168
|
+
ReqID string
|
|
169
|
+
UID string
|
|
170
|
+
Amount int64
|
|
171
|
+
Channel string
|
|
172
|
+
Status string
|
|
173
|
+
CreatedAt int64
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 为什么金额用 int64
|
|
178
|
+
|
|
179
|
+
`0.1 + 0.2` 在浮点数里不一定等于 `0.3`。钱的金额、余额、手续费必须使用最小货币单位整数,例如“分”;如果业务需要更细精度,使用数据库 `DECIMAL` 和明确的定点数规则。
|
|
180
|
+
|
|
181
|
+
### 为什么不能只靠自增 ID 做幂等
|
|
182
|
+
|
|
183
|
+
自增 ID 只能区分“两次插入”,不能识别“同一个业务请求被客户端重试”。业务幂等键必须由请求方提供,例如 `req_id`,并在数据库中建立唯一索引。
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 21.5 建表:约束必须落到数据库
|
|
188
|
+
|
|
189
|
+
```sql
|
|
190
|
+
CREATE TABLE account (
|
|
191
|
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
192
|
+
uid VARCHAR(64) NOT NULL,
|
|
193
|
+
balance BIGINT NOT NULL DEFAULT 0 COMMENT '余额,单位:分',
|
|
194
|
+
status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2冻结',
|
|
195
|
+
version BIGINT NOT NULL DEFAULT 0,
|
|
196
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
197
|
+
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
|
198
|
+
ON UPDATE CURRENT_TIMESTAMP(3),
|
|
199
|
+
PRIMARY KEY (id),
|
|
200
|
+
UNIQUE KEY uk_uid (uid),
|
|
201
|
+
CONSTRAINT chk_balance_nonnegative CHECK (balance >= 0)
|
|
202
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```sql
|
|
206
|
+
CREATE TABLE recharge_order (
|
|
207
|
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
208
|
+
req_id VARCHAR(64) NOT NULL,
|
|
209
|
+
uid VARCHAR(64) NOT NULL,
|
|
210
|
+
amount BIGINT NOT NULL,
|
|
211
|
+
channel VARCHAR(32) NOT NULL,
|
|
212
|
+
status TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败',
|
|
213
|
+
balance_after BIGINT NULL,
|
|
214
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
215
|
+
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
|
216
|
+
ON UPDATE CURRENT_TIMESTAMP(3),
|
|
217
|
+
PRIMARY KEY (id),
|
|
218
|
+
UNIQUE KEY uk_req_id (req_id),
|
|
219
|
+
KEY idx_uid_created (uid, created_at),
|
|
220
|
+
KEY idx_status_created (status, created_at),
|
|
221
|
+
CONSTRAINT chk_amount_positive CHECK (amount > 0)
|
|
222
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```sql
|
|
226
|
+
CREATE TABLE recharge_flow (
|
|
227
|
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
228
|
+
uid VARCHAR(64) NOT NULL,
|
|
229
|
+
order_id BIGINT UNSIGNED NOT NULL,
|
|
230
|
+
delta BIGINT NOT NULL,
|
|
231
|
+
balance_after BIGINT NOT NULL,
|
|
232
|
+
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
233
|
+
PRIMARY KEY (id),
|
|
234
|
+
UNIQUE KEY uk_order (order_id),
|
|
235
|
+
KEY idx_uid_created (uid, created_at)
|
|
236
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
三张表职责不同:
|
|
240
|
+
|
|
241
|
+
- `account` 保存当前余额,是高频点查;
|
|
242
|
+
- `recharge_order` 保存请求和幂等状态;
|
|
243
|
+
- `recharge_flow` 保存每一次余额变化,便于审计和对账。
|
|
244
|
+
|
|
245
|
+
**不要只保存最终余额。** 没有流水,出现“为什么少了 50 分”时无法解释。
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## 21.6 正确的事务边界
|
|
250
|
+
|
|
251
|
+
充值必须在**同一个数据库事务**里完成:
|
|
252
|
+
|
|
253
|
+
```text
|
|
254
|
+
BEGIN
|
|
255
|
+
1. INSERT recharge_order(req_id...),唯一键冲突表示重复请求
|
|
256
|
+
2. SELECT account ... FOR UPDATE
|
|
257
|
+
3. 检查账户存在且状态正常
|
|
258
|
+
4. UPDATE account SET balance = balance + ?, version = version + 1
|
|
259
|
+
5. INSERT recharge_flow
|
|
260
|
+
6. UPDATE recharge_order SET status = 成功, balance_after = ?
|
|
261
|
+
COMMIT
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
任何一步失败都回滚。尤其不能先插入订单、提交事务,再去加余额;进程在两步之间崩溃会留下“订单成功但钱没加”的脏数据。
|
|
265
|
+
|
|
266
|
+
### Go 中的事务轮廓
|
|
267
|
+
|
|
268
|
+
```go
|
|
269
|
+
func (r *MySQLRepository) Recharge(ctx context.Context, req model.RechargeOrder) (Result, error) {
|
|
270
|
+
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
|
271
|
+
if err != nil {
|
|
272
|
+
return Result{}, err
|
|
273
|
+
}
|
|
274
|
+
defer tx.Rollback() // 已 Commit 后 Rollback 返回 ErrTxDone,可安全 defer
|
|
275
|
+
|
|
276
|
+
// 唯一键冲突用 MySQL error 1062 判断,演示代码不依赖具体驱动的错误类型。
|
|
277
|
+
if _, err := tx.ExecContext(ctx,
|
|
278
|
+
`INSERT INTO recharge_order(req_id, uid, amount, channel, status)
|
|
279
|
+
VALUES (?, ?, ?, ?, 0)`, req.ReqID, req.UID, req.Amount, req.Channel); err != nil {
|
|
280
|
+
if isDuplicateKey(err) {
|
|
281
|
+
return r.loadDuplicateOrLock(ctx, tx, req)
|
|
282
|
+
}
|
|
283
|
+
return Result{}, err
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
var acc model.Account
|
|
287
|
+
err = tx.QueryRowContext(ctx,
|
|
288
|
+
`SELECT id, uid, balance, status, version
|
|
289
|
+
FROM account WHERE uid = ? FOR UPDATE`, req.UID).
|
|
290
|
+
Scan(&acc.ID, &acc.UID, &acc.Balance, &acc.Status, &acc.Version)
|
|
291
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
292
|
+
return Result{}, ErrNotFound
|
|
293
|
+
}
|
|
294
|
+
if err != nil {
|
|
295
|
+
return Result{}, err
|
|
296
|
+
}
|
|
297
|
+
if acc.Status != model.AccountNormal {
|
|
298
|
+
return Result{}, ErrFrozen
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
newBalance := acc.Balance + req.Amount
|
|
302
|
+
if _, err := tx.ExecContext(ctx,
|
|
303
|
+
`UPDATE account SET balance = balance + ?, version = version + 1
|
|
304
|
+
WHERE id = ?`, req.Amount, acc.ID); err != nil {
|
|
305
|
+
return Result{}, err
|
|
306
|
+
}
|
|
307
|
+
if _, err := tx.ExecContext(ctx,
|
|
308
|
+
`INSERT INTO recharge_flow(uid, order_id, delta, balance_after)
|
|
309
|
+
VALUES (?, ?, ?, ?)`, req.UID, req.ID, req.Amount, newBalance); err != nil {
|
|
310
|
+
return Result{}, err
|
|
311
|
+
}
|
|
312
|
+
if _, err := tx.ExecContext(ctx,
|
|
313
|
+
`UPDATE recharge_order SET status = 1, balance_after = ? WHERE id = ?`,
|
|
314
|
+
newBalance, req.ID); err != nil {
|
|
315
|
+
return Result{}, err
|
|
316
|
+
}
|
|
317
|
+
if err := tx.Commit(); err != nil {
|
|
318
|
+
return Result{}, err
|
|
319
|
+
}
|
|
320
|
+
return Result{Balance: newBalance, Duplicated: false}, nil
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### 唯一键冲突后还能不能继续查
|
|
325
|
+
|
|
326
|
+
MySQL 中普通 `INSERT` 触发唯一键冲突时,语句失败但事务通常仍可继续,所以示例可以在同一个事务里查询已有订单。PostgreSQL 在语句报错后默认会把整个事务标记为 aborted,必须使用 `SAVEPOINT`/`ROLLBACK TO SAVEPOINT`,或者先独立查询再决定是否开启写入事务。**数据库不同,错误恢复语义也不同,不能照抄。**
|
|
327
|
+
|
|
328
|
+
另外,同一 `req_id` 再次出现时必须比较 `uid`、`amount`、`channel` 是否与首次请求一致。如果同一个幂等键被用于不同参数,应返回 `REQ_ID_CONFLICT`,不能把另一笔请求的结果当成成功结果。
|
|
329
|
+
|
|
330
|
+
### `SELECT ... FOR UPDATE` 解决什么
|
|
331
|
+
|
|
332
|
+
两个请求同时给同一账户加 10 分。没有行锁时,它们可能都读到 100,再分别写 110,最终只增加 10 分。`FOR UPDATE` 让同一账户的更新串行化,后一个事务必须等待前一个提交,再读到 110 并写 120。
|
|
333
|
+
|
|
334
|
+
### 事务不能做的事
|
|
335
|
+
|
|
336
|
+
- 不要在持有数据库事务时调用慢速 HTTP/gRPC 下游;
|
|
337
|
+
- 不要在事务里发 Kafka、发短信或写日志到远程服务;
|
|
338
|
+
- 不要以为 Redis 操作会跟随 MySQL 回滚;
|
|
339
|
+
- 事务要短,先做参数校验,再开启事务。
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## 21.7 幂等的正确理解
|
|
344
|
+
|
|
345
|
+
幂等不是“第二次请求直接返回成功”。要分清楚以下几种状态:
|
|
346
|
+
|
|
347
|
+
| 数据库里的请求状态 | 第二次请求应该怎么处理 |
|
|
348
|
+
|---|---|
|
|
349
|
+
| 不存在 | 首次执行 |
|
|
350
|
+
| 处理中 | 返回处理中,或短暂等待;不能重复加钱 |
|
|
351
|
+
| 已成功 | 返回首次结果或当前结果,`duplicated=true` |
|
|
352
|
+
| 已失败且可重试 | 按明确状态机重试,不能盲目插入 |
|
|
353
|
+
| 已失败且不可重试 | 返回原失败原因 |
|
|
354
|
+
|
|
355
|
+
最危险的做法是“先查再插”:
|
|
356
|
+
|
|
357
|
+
```text
|
|
358
|
+
请求 A 查询 req_id 不存在
|
|
359
|
+
请求 B 查询 req_id 不存在
|
|
360
|
+
请求 A 插入并执行
|
|
361
|
+
请求 B 也插入并执行
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
必须依赖数据库唯一键或原子条件写入来兜底。
|
|
365
|
+
|
|
366
|
+
### 唯一键冲突后的读取
|
|
367
|
+
|
|
368
|
+
冲突发生后,不要直接返回 `nil`。应读取已有订单状态和 `balance_after`:
|
|
369
|
+
|
|
370
|
+
```go
|
|
371
|
+
func (r *MySQLRepository) loadDuplicateOrLock(
|
|
372
|
+
ctx context.Context, tx *sql.Tx, req model.RechargeOrder,
|
|
373
|
+
) (Result, error) {
|
|
374
|
+
var status int
|
|
375
|
+
var balanceAfter sql.NullInt64
|
|
376
|
+
err := tx.QueryRowContext(ctx,
|
|
377
|
+
`SELECT status, balance_after FROM recharge_order WHERE req_id = ?`,
|
|
378
|
+
req.ReqID).Scan(&status, &balanceAfter)
|
|
379
|
+
if err != nil {
|
|
380
|
+
return Result{}, err
|
|
381
|
+
}
|
|
382
|
+
switch status {
|
|
383
|
+
case 1:
|
|
384
|
+
return Result{Balance: balanceAfter.Int64, Duplicated: true}, nil
|
|
385
|
+
case 0:
|
|
386
|
+
return Result{}, ErrProcessing
|
|
387
|
+
default:
|
|
388
|
+
return Result{}, ErrPreviouslyFailed
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### 幂等失败时的原则
|
|
394
|
+
|
|
395
|
+
**不要在同一事务里把订单标记失败后继续加钱。** 状态机必须清晰:先校验、再改变余额、最后把订单标记成功。失败时事务回滚,订单是否保留由业务决定;如果保留失败流水,要有单独的失败表或明确状态,不要伪装成成功。
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## 21.8 HTTP 层:薄,不做业务
|
|
400
|
+
|
|
401
|
+
```go
|
|
402
|
+
type rechargeRequest struct {
|
|
403
|
+
ReqID string `json:"req_id"`
|
|
404
|
+
UID string `json:"uid"`
|
|
405
|
+
Amount int64 `json:"amount"`
|
|
406
|
+
Channel string `json:"channel"`
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
func (h *Handler) recharge(w http.ResponseWriter, r *http.Request) {
|
|
410
|
+
if r.Method != http.MethodPost {
|
|
411
|
+
writeError(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", nil)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
var body rechargeRequest
|
|
416
|
+
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
|
417
|
+
dec.DisallowUnknownFields()
|
|
418
|
+
if err := dec.Decode(&body); err != nil {
|
|
419
|
+
writeError(w, r, http.StatusBadRequest, "INVALID_JSON", err)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
result, err := h.recharge.Recharge(r.Context(), service.RechargeRequest{
|
|
424
|
+
ReqID: body.ReqID, UID: body.UID,
|
|
425
|
+
Amount: body.Amount, Channel: body.Channel,
|
|
426
|
+
})
|
|
427
|
+
if err != nil {
|
|
428
|
+
writeServiceError(w, r, err)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
writeJSON(w, http.StatusOK, successResponse(r, result))
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### HTTP 层负责什么
|
|
436
|
+
|
|
437
|
+
- 解析 JSON、限制请求体;
|
|
438
|
+
- 校验字段格式和枚举;
|
|
439
|
+
- 把请求上下文传给 service;
|
|
440
|
+
- 把 service 错误映射为 HTTP 状态码和稳定业务码;
|
|
441
|
+
- 设置响应头、记录请求耗时。
|
|
442
|
+
|
|
443
|
+
### HTTP 层不负责什么
|
|
444
|
+
|
|
445
|
+
- 不直接拼 SQL;
|
|
446
|
+
- 不直接读写 Redis;
|
|
447
|
+
- 不决定“余额扣多少”;
|
|
448
|
+
- 不把底层数据库错误原样返回给前端。
|
|
449
|
+
|
|
450
|
+
### 错误映射示例
|
|
451
|
+
|
|
452
|
+
```go
|
|
453
|
+
func writeServiceError(w http.ResponseWriter, r *http.Request, err error) {
|
|
454
|
+
switch {
|
|
455
|
+
case errors.Is(err, service.ErrInvalidAmount):
|
|
456
|
+
writeError(w, r, http.StatusBadRequest, "INVALID_AMOUNT", nil)
|
|
457
|
+
case errors.Is(err, service.ErrMissingReqID):
|
|
458
|
+
writeError(w, r, http.StatusBadRequest, "MISSING_REQ_ID", nil)
|
|
459
|
+
case errors.Is(err, service.ErrFrozen):
|
|
460
|
+
writeError(w, r, http.StatusConflict, "ACCOUNT_FROZEN", nil)
|
|
461
|
+
case errors.Is(err, service.ErrProcessing):
|
|
462
|
+
writeError(w, r, http.StatusAccepted, "PROCESSING", nil)
|
|
463
|
+
default:
|
|
464
|
+
log.Printf("recharge failed request_id=%s err=%v", r.Header.Get("X-Request-Id"), err)
|
|
465
|
+
writeError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", nil)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## 21.9 缓存:只缓存能容忍短暂过期的数据
|
|
473
|
+
|
|
474
|
+
余额缓存是高危场景。缓存能提升查询 QPS,但必须回答:
|
|
475
|
+
|
|
476
|
+
1. 写入数据库后,缓存何时失效?
|
|
477
|
+
2. 删除缓存失败怎么办?
|
|
478
|
+
3. 两个请求同时读写,是否会把旧值写回?
|
|
479
|
+
4. 缓存是不是一致性来源?
|
|
480
|
+
|
|
481
|
+
推荐策略:
|
|
482
|
+
|
|
483
|
+
```text
|
|
484
|
+
读:先读缓存 -> 未命中读 DB -> 回填短 TTL
|
|
485
|
+
写:先提交 DB 事务 -> 删除缓存 -> 失败则重试/发失效消息
|
|
486
|
+
兜底:TTL 自动过期,监控缓存陈旧率
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### Cache-Aside 的伪代码
|
|
490
|
+
|
|
491
|
+
```go
|
|
492
|
+
func (s *Service) Balance(ctx context.Context, uid string) (int64, error) {
|
|
493
|
+
key := "account:balance:" + uid
|
|
494
|
+
if v, ok := s.cache.Get(ctx, key); ok {
|
|
495
|
+
return v.(int64), nil
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
acc, err := s.repo.GetAccount(ctx, uid)
|
|
499
|
+
if err != nil {
|
|
500
|
+
return 0, err
|
|
501
|
+
}
|
|
502
|
+
_ = s.cache.Set(ctx, key, acc.Balance, 30*time.Second)
|
|
503
|
+
return acc.Balance, nil
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
更新后:
|
|
508
|
+
|
|
509
|
+
```go
|
|
510
|
+
if err := s.repo.Recharge(ctx, order); err != nil {
|
|
511
|
+
return Result{}, err
|
|
512
|
+
}
|
|
513
|
+
if err := s.cache.Delete(ctx, "account:balance:"+order.UID); err != nil {
|
|
514
|
+
// 记录指标,交给 TTL 和后台修复任务,不要让缓存故障伪装成充值失败。
|
|
515
|
+
s.metrics.CacheDeleteFailed.Inc()
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### 三个常见问题
|
|
520
|
+
|
|
521
|
+
| 问题 | 现象 | 处理 |
|
|
522
|
+
|---|---|---|
|
|
523
|
+
| 缓存穿透 | 查不存在的数据,每次都打 DB | 空值短 TTL、参数校验、布隆过滤器 |
|
|
524
|
+
| 缓存击穿 | 热 key 过期瞬间打爆 DB | 单飞/互斥回源、逻辑过期 |
|
|
525
|
+
| 缓存雪崩 | 大量 key 同时过期 | TTL 加随机抖动、分批预热 |
|
|
526
|
+
|
|
527
|
+
**重要边界**:余额查询可以短 TTL 缓存,但充值扣款判断必须读数据库事务中的最新值。不能拿缓存余额做资金判断。
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## 21.10 消息队列:只传递已经提交的事实
|
|
532
|
+
|
|
533
|
+
充值成功后发送消息,消息应该表达事实,而不是命令另一个服务“必须加钱”:
|
|
534
|
+
|
|
535
|
+
```json
|
|
536
|
+
{
|
|
537
|
+
"event_id": "evt-90001",
|
|
538
|
+
"event_type": "recharge.succeeded",
|
|
539
|
+
"occurred_at": "2026-11-01T10:00:00Z",
|
|
540
|
+
"request_id": "r-20261101-001",
|
|
541
|
+
"uid": "u-1001",
|
|
542
|
+
"amount": 50,
|
|
543
|
+
"balance_after": 150
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
流程:
|
|
548
|
+
|
|
549
|
+
```text
|
|
550
|
+
DB 事务提交 -> 记录 outbox 事件 -> 异步发布 MQ -> 消费端按 event_id 幂等
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
如果直接在事务里调用 MQ,可能出现:数据库提交成功,但 MQ 发送失败;或者 MQ 发送成功,数据库回滚。使用 **Transactional Outbox** 可以把“业务成功”和“待发送事件”放进同一个 DB 事务,再由后台发布。
|
|
554
|
+
|
|
555
|
+
### 消费端幂等表
|
|
556
|
+
|
|
557
|
+
```sql
|
|
558
|
+
CREATE TABLE consumed_event (
|
|
559
|
+
event_id VARCHAR(64) NOT NULL,
|
|
560
|
+
consumer VARCHAR(64) NOT NULL,
|
|
561
|
+
consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
562
|
+
PRIMARY KEY (event_id, consumer)
|
|
563
|
+
) ENGINE=InnoDB;
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
消费逻辑:
|
|
567
|
+
|
|
568
|
+
```text
|
|
569
|
+
开始消费
|
|
570
|
+
INSERT consumed_event(event_id, consumer)
|
|
571
|
+
如果唯一键冲突 -> 已经处理过,直接 ACK
|
|
572
|
+
执行本地业务
|
|
573
|
+
提交本地事务
|
|
574
|
+
ACK
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
注意:消费成功但 ACK 丢失时,消息会再次投递;幂等表必须和本地业务处在同一个数据库事务里,或者使用等价的原子方案。
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## 21.11 配置、日志、指标和追踪
|
|
582
|
+
|
|
583
|
+
### 配置
|
|
584
|
+
|
|
585
|
+
```json
|
|
586
|
+
{
|
|
587
|
+
"address": "127.0.0.1:8081",
|
|
588
|
+
"mysql": {
|
|
589
|
+
"dsn_env": "MINICOIN_MYSQL_DSN",
|
|
590
|
+
"max_open_conns": 20,
|
|
591
|
+
"max_idle_conns": 10,
|
|
592
|
+
"conn_max_lifetime_seconds": 300
|
|
593
|
+
},
|
|
594
|
+
"redis": {
|
|
595
|
+
"addr": "127.0.0.1:6379",
|
|
596
|
+
"dial_timeout_seconds": 1
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
配置里不写密码。敏感值通过环境变量或秘密管理系统注入。
|
|
602
|
+
|
|
603
|
+
### 结构化日志
|
|
604
|
+
|
|
605
|
+
每次请求至少记录:
|
|
606
|
+
|
|
607
|
+
```text
|
|
608
|
+
level, timestamp, service, request_id, trace_id, route,
|
|
609
|
+
method, status, latency_ms, user_id_masked, error_code
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
不要打印密码、完整 token、完整手机号、身份证、银行卡和完整请求体。
|
|
613
|
+
|
|
614
|
+
### 指标
|
|
615
|
+
|
|
616
|
+
```text
|
|
617
|
+
http_requests_total{route="/v1/recharges", status="200"}
|
|
618
|
+
http_request_duration_seconds_bucket{route="/v1/recharges", le="0.1"}
|
|
619
|
+
recharge_total{result="success"}
|
|
620
|
+
recharge_failures_total{code="ACCOUNT_FROZEN"}
|
|
621
|
+
db_pool_in_use
|
|
622
|
+
redis_errors_total
|
|
623
|
+
mq_publish_failures_total
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### 追踪
|
|
627
|
+
|
|
628
|
+
```go
|
|
629
|
+
ctx, span := tracer.Start(ctx, "RechargeService.Recharge")
|
|
630
|
+
defer span.End()
|
|
631
|
+
|
|
632
|
+
span.SetAttributes(attribute.String("request_id", req.ReqID))
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
原则:日志回答“发生了什么”,指标回答“有多少、多快、是否恶化”,追踪回答“慢在哪一段”。三者缺一不可。
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
639
|
+
## 21.12 测试:从规则到集成
|
|
640
|
+
|
|
641
|
+
### 单元测试
|
|
642
|
+
|
|
643
|
+
测试业务规则时使用可控的内存 repository,不启动 MySQL:
|
|
644
|
+
|
|
645
|
+
```go
|
|
646
|
+
func TestRechargeRejectsInvalidAmount(t *testing.T) {
|
|
647
|
+
svc := NewRecharge(newMemoryRepo(), noopCache{}, noopPublisher{})
|
|
648
|
+
_, err := svc.Recharge(context.Background(), RechargeRequest{
|
|
649
|
+
ReqID: "r-1", UID: "u-1", Amount: 0,
|
|
650
|
+
})
|
|
651
|
+
if !errors.Is(err, ErrInvalidAmount) {
|
|
652
|
+
t.Fatalf("got %v, want ErrInvalidAmount", err)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
### 表驱动测试
|
|
658
|
+
|
|
659
|
+
```go
|
|
660
|
+
tests := []struct{
|
|
661
|
+
name string
|
|
662
|
+
amount int64
|
|
663
|
+
want error
|
|
664
|
+
}{
|
|
665
|
+
{"zero", 0, ErrInvalidAmount},
|
|
666
|
+
{"negative", -1, ErrInvalidAmount},
|
|
667
|
+
{"normal", 1, nil},
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
### 幂等测试
|
|
672
|
+
|
|
673
|
+
```go
|
|
674
|
+
first, err := svc.Recharge(ctx, req)
|
|
675
|
+
require.NoError(t, err)
|
|
676
|
+
|
|
677
|
+
second, err := svc.Recharge(ctx, req)
|
|
678
|
+
require.NoError(t, err)
|
|
679
|
+
require.True(t, second.Duplicated)
|
|
680
|
+
require.Equal(t, first.Balance, second.Balance)
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
### 并发测试
|
|
684
|
+
|
|
685
|
+
```go
|
|
686
|
+
const n = 100
|
|
687
|
+
var wg sync.WaitGroup
|
|
688
|
+
for i := 0; i < n; i++ {
|
|
689
|
+
wg.Add(1)
|
|
690
|
+
go func(i int) {
|
|
691
|
+
defer wg.Done()
|
|
692
|
+
_, _ = svc.Recharge(ctx, RechargeRequest{
|
|
693
|
+
ReqID: fmt.Sprintf("r-%d", i),
|
|
694
|
+
UID: "u-1",
|
|
695
|
+
Amount: 1,
|
|
696
|
+
})
|
|
697
|
+
}(i)
|
|
698
|
+
}
|
|
699
|
+
wg.Wait()
|
|
700
|
+
|
|
701
|
+
acc, _ := repo.GetAccount(ctx, "u-1")
|
|
702
|
+
if acc.Balance != initial+n {
|
|
703
|
+
t.Fatalf("got %d, want %d", acc.Balance, initial+n)
|
|
704
|
+
}
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
### 集成测试
|
|
708
|
+
|
|
709
|
+
集成测试必须连接真实 MySQL 或 testcontainers,至少覆盖:
|
|
710
|
+
|
|
711
|
+
- 唯一键冲突;
|
|
712
|
+
- 事务回滚;
|
|
713
|
+
- `SELECT FOR UPDATE` 并发;
|
|
714
|
+
- 字段长度、CHECK、字符集;
|
|
715
|
+
- 数据库连接断开后的恢复;
|
|
716
|
+
- 迁移脚本从空库执行成功。
|
|
717
|
+
|
|
718
|
+
### `-race`
|
|
719
|
+
|
|
720
|
+
```bash
|
|
721
|
+
go test ./... -count=1
|
|
722
|
+
go test -race ./... -count=1
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
`-race` 只能发现实际访问到的竞争,不证明没有竞争;并发路径要设计能同时触发。
|
|
726
|
+
|
|
727
|
+
---
|
|
728
|
+
|
|
729
|
+
## 21.13 自测和接口验证
|
|
730
|
+
|
|
731
|
+
### curl 自测
|
|
732
|
+
|
|
733
|
+
```bash
|
|
734
|
+
curl -i -X POST http://127.0.0.1:8081/v1/recharges \
|
|
735
|
+
-H 'Content-Type: application/json' \
|
|
736
|
+
-H 'X-Request-Id: trace-demo-001' \
|
|
737
|
+
-d '{"req_id":"r-1","uid":"u-1001","amount":50,"channel":"manual"}'
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
### 验证幂等
|
|
741
|
+
|
|
742
|
+
```bash
|
|
743
|
+
for i in 1 2; do
|
|
744
|
+
curl -sS -X POST http://127.0.0.1:8081/v1/recharges \
|
|
745
|
+
-H 'Content-Type: application/json' \
|
|
746
|
+
-H 'X-Request-Id: trace-idem-$i' \
|
|
747
|
+
-d '{"req_id":"r-idem","uid":"u-1001","amount":10,"channel":"manual"}'
|
|
748
|
+
done
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
第二次结果必须显示重复标记,余额不能增加两次。
|
|
752
|
+
|
|
753
|
+
### 验证错误路径
|
|
754
|
+
|
|
755
|
+
- `amount=0`;
|
|
756
|
+
- 缺 `req_id`;
|
|
757
|
+
- 不存在账户;
|
|
758
|
+
- 冻结账户;
|
|
759
|
+
- 超大请求体;
|
|
760
|
+
- 非 JSON;
|
|
761
|
+
- 方法错误;
|
|
762
|
+
- 下游数据库超时。
|
|
763
|
+
|
|
764
|
+
真正的自测不是只测 happy path,而是把错误路径变成可重复的命令。
|
|
765
|
+
|
|
766
|
+
---
|
|
767
|
+
|
|
768
|
+
## 21.14 CI、镜像和发布
|
|
769
|
+
|
|
770
|
+
### CI 门禁
|
|
771
|
+
|
|
772
|
+
```yaml
|
|
773
|
+
stages: [lint, test, build]
|
|
774
|
+
lint:
|
|
775
|
+
script:
|
|
776
|
+
- test -z "$(gofmt -l .)"
|
|
777
|
+
- go vet ./...
|
|
778
|
+
test:
|
|
779
|
+
script:
|
|
780
|
+
- go test ./... -count=1
|
|
781
|
+
- go test -race ./... -count=1
|
|
782
|
+
build:
|
|
783
|
+
script:
|
|
784
|
+
- CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o app ./cmd/server
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### 多阶段 Dockerfile
|
|
788
|
+
|
|
789
|
+
```dockerfile
|
|
790
|
+
FROM golang:1.22-alpine AS build
|
|
791
|
+
WORKDIR /src
|
|
792
|
+
COPY go.mod go.sum ./
|
|
793
|
+
RUN go mod download
|
|
794
|
+
COPY . .
|
|
795
|
+
RUN CGO_ENABLED=0 GOOS=linux go build \
|
|
796
|
+
-trimpath -ldflags="-s -w" -o /out/server ./cmd/server
|
|
797
|
+
|
|
798
|
+
FROM gcr.io/distroless/static-debian12:nonroot
|
|
799
|
+
COPY --from=build /out/server /server
|
|
800
|
+
USER nonroot:nonroot
|
|
801
|
+
EXPOSE 8081
|
|
802
|
+
ENTRYPOINT ["/server"]
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
发布前检查:
|
|
806
|
+
|
|
807
|
+
- 镜像带不可变 tag 或 digest;
|
|
808
|
+
- 不把 `.env`、证书、token 打进镜像;
|
|
809
|
+
- 进程以非 root 运行;
|
|
810
|
+
- 有 CPU/内存限制;
|
|
811
|
+
- 有启动、存活、就绪探针;
|
|
812
|
+
- 有回滚版本和数据库迁移回滚方案。
|
|
813
|
+
|
|
814
|
+
### 优雅退出
|
|
815
|
+
|
|
816
|
+
```go
|
|
817
|
+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
|
818
|
+
defer stop()
|
|
819
|
+
<-ctx.Done()
|
|
820
|
+
|
|
821
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
822
|
+
defer cancel()
|
|
823
|
+
_ = server.Shutdown(shutdownCtx)
|
|
824
|
+
_ = repo.Close()
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
收到 SIGTERM 后停止接新请求,等待在途请求完成,再退出。不能直接 `os.Exit(0)`,否则请求可能写一半。
|
|
828
|
+
|
|
829
|
+
### 数据库迁移顺序
|
|
830
|
+
|
|
831
|
+
1. 先执行向后兼容的 DDL;
|
|
832
|
+
2. 再发布能读写新结构的代码;
|
|
833
|
+
3. 观察指标;
|
|
834
|
+
4. 最后删除旧字段或旧代码。
|
|
835
|
+
|
|
836
|
+
禁止把不可回滚的 DROP 和代码发布绑在同一步。
|
|
837
|
+
|
|
838
|
+
---
|
|
839
|
+
|
|
840
|
+
## 21.15 上线后的观察顺序
|
|
841
|
+
|
|
842
|
+
上线后 10 分钟内按下面顺序看:
|
|
843
|
+
|
|
844
|
+
1. **流量**:QPS 是否达到预期,入口有没有被限流;
|
|
845
|
+
2. **错误率**:5xx、业务错误码、数据库错误、MQ 错误;
|
|
846
|
+
3. **延迟**:P50/P95/P99,是否出现长尾;
|
|
847
|
+
4. **饱和度**:CPU、内存、goroutine、连接池、Redis、磁盘;
|
|
848
|
+
5. **业务结果**:充值成功数、重复请求数、余额变化总量是否与订单一致;
|
|
849
|
+
6. **日志与追踪**:挑选几个失败 request_id 追踪完整链路。
|
|
850
|
+
|
|
851
|
+
报警要围绕用户影响,不要只报 CPU 80%。例如:
|
|
852
|
+
|
|
853
|
+
```text
|
|
854
|
+
5 分钟错误率 > 2%
|
|
855
|
+
P99 > 800ms 持续 5 分钟
|
|
856
|
+
连续 3 分钟没有成功充值
|
|
857
|
+
数据库连接池使用率 > 90% 持续 10 分钟
|
|
858
|
+
消息积压持续增长
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
---
|
|
862
|
+
|
|
863
|
+
## 21.16 本章综合练习
|
|
864
|
+
|
|
865
|
+
### 练习 A:需求转验收
|
|
866
|
+
|
|
867
|
+
把“用户充值”改成至少 8 条可自动化验收用例,必须覆盖幂等、并发、金额、账户状态和错误响应。
|
|
868
|
+
|
|
869
|
+
### 练习 B:事务设计
|
|
870
|
+
|
|
871
|
+
写出“创建订单、锁账户、加余额、写流水、更新订单”的 SQL 事务,并解释为什么不能把 MQ 发送放在事务中。
|
|
872
|
+
|
|
873
|
+
### 练习 C:幂等状态机
|
|
874
|
+
|
|
875
|
+
分别画出 `不存在/处理中/成功/可重试失败/不可重试失败` 五种状态下的第二次请求流程。
|
|
876
|
+
|
|
877
|
+
### 练习 D:故障注入
|
|
878
|
+
|
|
879
|
+
人为让缓存删除失败、数据库提交超时、消息消费重复,分别写观察指标和恢复方案。
|
|
880
|
+
|
|
881
|
+
### 练习 E:上线演练
|
|
882
|
+
|
|
883
|
+
为一个只读接口设计发布、灰度和回滚步骤,要求说明如何判断是否可以扩大流量。
|
|
884
|
+
|
|
885
|
+
---
|
|
886
|
+
|
|
887
|
+
## 参考答案
|
|
888
|
+
|
|
889
|
+
<details>
|
|
890
|
+
<summary>展开参考答案</summary>
|
|
891
|
+
|
|
892
|
+
**A**:至少包括:正常充值;重复 `req_id` 不重复加钱;并发不同 `req_id` 不丢更新;金额为 0;金额为负;账户不存在;账户冻结;非法 JSON;请求体过大;数据库超时。每条都应给出输入、预期 HTTP 状态、业务码和最终余额。
|
|
893
|
+
|
|
894
|
+
**B**:事务内先插入订单并依赖唯一键防重,再用 `SELECT ... FOR UPDATE` 锁账户,校验状态后更新余额,写流水,更新订单状态,最后一起提交。MQ 发送是外部副作用,不能跟随事务回滚,应使用 outbox 或提交后可靠发布。
|
|
895
|
+
|
|
896
|
+
**C**:不存在则首次执行;处理中返回 `PROCESSING`,客户端退避重试;成功返回首次 `balance_after` 和 `duplicated=true`;可重试失败只允许明确状态机重试,不可重试失败返回稳定错误码。任何状态都不能绕过唯一键和账户状态校验。
|
|
897
|
+
|
|
898
|
+
**D**:缓存删除失败记录 `cache_delete_failed_total`,依靠短 TTL 和后台修复;数据库提交超时先按事务结果未知处理,使用 `req_id` 查询订单确认,禁止盲目重试加钱;重复消费通过消费幂等表识别,ACK 丢失后用同一 `event_id` 重试。
|
|
899
|
+
|
|
900
|
+
**E**:先在同版本测试环境验证,再发布小流量实例;观察 5xx、P99、业务成功率和日志错误;逐级扩大流量;回滚时先摘流量,再切回旧镜像或旧配置,确认数据库结构仍向后兼容。没有指标证据就不能宣布发布成功。
|
|
901
|
+
|
|
902
|
+
</details>
|