阶段2(上):后端专题 Go/Gin/PostgreSQL/MySQL/Redis/MQ/gRPC 的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:24:57 +08:00
1 parent 0bae9916a9
commit 5eb67c5d08
65 files changed
+6457 -77

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
---
id: backend.gin.routing-binding
title: 路由、参数绑定和校验
type: lesson
domain: backend
stack: [gin]
tags: [Gin, 路由, 参数校验, REST]
level: basic
prerequisites: [backend.go.basics-types]
est_hours: 3
related: [backend.gin.middleware-auth, backend.gin.interview]
week: [1]
order: 1
---
# 路由、参数绑定和校验
## 解释说明
- **路由**:`r.GET("/tasks/:id", handler)`,`:id` 是路径参数,`*path` 是通配。用 `r.Group("/api")` 给一组路由加公共前缀和中间件。Gin 的路由是前缀树(radix tree),匹配速度和路由数量关系不大。
- **取参数**:路径参数 `c.Param("id")`;查询参数 `c.Query("page")` / `c.DefaultQuery("page", "1")`;请求体用**绑定**。
- **绑定和校验**:定义结构体,用标签声明来源和规则,`c.ShouldBindJSON(&req)` 一步完成解析和校验(底层是 go-playground/validator):
| 标签 | 含义 |
| --- | --- |
| `json:"title"` | JSON 字段名 |
| `form:"page"` | 查询参数 / 表单字段名(配合 `ShouldBindQuery`) |
| `binding:"required,max=100"` | 必填、最大长度 100 |
| `binding:"omitempty,oneof=todo doing done"` | 可选,有值时必须是其中之一 |
| `binding:"gte=1,lte=100"` | 数字范围 |
- **响应**:`c.JSON(状态码, 数据)`;写完响应后要 `return`,否则后面的代码还会执行。
- **RESTful 约定**:资源用名词复数(`/tasks`),动作用 HTTP 方法;创建返回 201,删除成功返回 204,参数错误 400,未登录 401,无权限 403,不存在 404,冲突 409。
## 案例
任务列表接口要支持分页和状态筛选:`GET /api/tasks?page=2&size=20&status=done`。查询参数绑定到结构体,校验 `size` 不超过 100(防止一次拉太多数据拖垮数据库),`status` 只允许固定几个值。
## 代码示例
```go
package main
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type ListQuery struct {
Page int `form:"page,default=1" binding:"gte=1"`
Size int `form:"size,default=20" binding:"gte=1,lte=100"`
Status string `form:"status" binding:"omitempty,oneof=todo doing done"`
}
type CreateTask struct {
Title string `json:"title" binding:"required,max=100"`
Note string `json:"note" binding:"max=1000"`
}
func main() {
r := gin.Default() // 自带 Logger 和 Recovery 中间件
api := r.Group("/api")
{
api.GET("/tasks", func(c *gin.Context) {
var q ListQuery
if err := c.ShouldBindQuery(&q); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return // 一定要 return
}
c.JSON(http.StatusOK, gin.H{"page": q.Page, "size": q.Size, "status": q.Status, "items": []string{}})
})
api.POST("/tasks", func(c *gin.Context) {
var req CreateTask
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": 1, "title": req.Title})
})
api.GET("/tasks/:id", func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "id 必须是正整数"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id})
})
}
_ = r.Run(":8080")
}
```
试一试:
```bash
curl 'localhost:8080/api/tasks?size=500' # 400:size 超过 100
curl -X POST localhost:8080/api/tasks -d '{"title":""}' -H 'Content-Type: application/json' # 400:title 必填
curl -X POST localhost:8080/api/tasks -d '{"title":"学 Gin"}' -H 'Content-Type: application/json' # 201
```
## 易错点
- **写完响应不 return**:后面的代码继续执行,可能再次写响应,日志出现 `headers were already written`。
- **`BindJSON` 和 `ShouldBindJSON`**:前者校验失败会自动写 400 且之后不能改状态码,统一用 `ShouldBind*` 自己处理。
- **请求体只能读一次**:中间件里读过 `c.Request.Body`,后面再绑定会是空的;需要多次读用 `c.ShouldBindBodyWith`。
- **校验错误直接返回给前端**:原始错误信息是英文、带结构体字段名,生产环境要转换成友好的提示(见下一课的统一错误处理)。
- **路由冲突**:`/tasks/:id` 和 `/tasks/new` 这类静态和参数路由的组合,不同版本的 Gin 处理不同,设计路由时尽量避免歧义。
## 面试常问
见 [Gin 面试题](interview.md):路由原理、绑定和校验、RESTful 状态码。
+114
View File
@@ -0,0 +1,114 @@
---
id: backend.gin.middleware-auth
title: 中间件、JWT 鉴权和统一错误处理
type: lesson
domain: backend
stack: [gin]
tags: [Gin, 中间件, JWT, 鉴权, 错误处理]
level: intermediate
prerequisites: [backend.gin.routing-binding, backend.go.errors-concurrency]
est_hours: 3
related: [backend.gin.scenario.unified-api-response, backend.gin.interview]
week: [2, 3]
order: 2
---
# 中间件、JWT 鉴权和统一错误处理
## 解释说明
- **中间件**就是一个 `gin.HandlerFunc`,在处理函数前后执行公共逻辑:日志、恢复 panic、跨域、鉴权、限流、请求 ID。和 Koa / Express 的洋葱模型一样:`c.Next()` 之前是“进入”,之后是“返回”;`c.Abort()` 终止后续处理函数(当前函数还会继续执行到结束,所以 Abort 后要 return)。
- **注册范围**:`r.Use(...)` 全局;`group.Use(...)` 只对一组路由;也可以写在单个路由上。
- **在中间件之间传值**:`c.Set("userID", id)` / `c.GetString("userID")`;跨到 service 层时,放进 `context.Context` 传递。
- **JWT 鉴权**:登录成功后签发令牌(包含用户 ID `sub`、过期时间 `exp`),之后每次请求带 `Authorization: Bearer <token>`。中间件校验签名和过期时间,**必须指定允许的签名算法**,防止“算法替换”攻击。
- **统一错误处理**:业务代码返回带错误码的错误,由一个地方转换成统一的响应格式,避免每个处理函数自己拼 JSON、格式不一致。
## 案例
企业后台的公共要求:所有接口返回 `{code, message, data, request_id}`;未登录 401、无权限 403、参数错误 400 都有固定的业务错误码;出问题时客服把 `request_id` 发给开发,开发在日志里一搜就能找到完整的请求记录。下一节的场景文档给出了完整设计。
## 代码示例
```go
package main
import (
"errors"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
var secret = []byte("dev-only-secret-change-me-at-least-32-bytes")
func Sign(userID string, ttl time.Duration) (string, error) {
claims := jwt.RegisteredClaims{Subject: userID, ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl))}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
// Auth 校验 Bearer 令牌,把用户 ID 放进上下文
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
raw, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40100, "message": "请先登录"})
return
}
var claims jwt.RegisteredClaims
_, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) { return secret, nil },
jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())
if err != nil {
msg := "令牌无效"
if errors.Is(err, jwt.ErrTokenExpired) {
msg = "登录已过期"
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": msg})
return
}
c.Set("userID", claims.Subject)
c.Next()
}
}
// Timing 演示“洋葱模型”:Next 之前和之后都能执行代码
func Timing() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next() // 执行后面的中间件和处理函数
log.Printf("%s %s %d %v", c.Request.Method, c.FullPath(), c.Writer.Status(), time.Since(start))
}
}
func main() {
r := gin.New()
r.Use(gin.Recovery(), Timing())
r.POST("/login", func(c *gin.Context) {
token, _ := Sign("u1", 2*time.Hour)
c.JSON(http.StatusOK, gin.H{"token": token})
})
authed := r.Group("/api", Auth())
authed.GET("/me", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user_id": c.GetString("userID")})
})
_ = r.Run(":8080")
}
```
完整的“统一响应 + 错误码 + 请求 ID”实现和测试见 [examples/unified-api](examples/unified-api/)。
## 易错点
- **`Abort` 后不 return**:当前中间件后面的代码还会执行。
- **不指定签名算法**:攻击者可能把算法改成 `none` 或用公钥当 HMAC 密钥;用 `jwt.WithValidMethods` 限定。
- **密钥写死在代码里**:从环境变量读取,长度至少 32 字节,不同环境不同密钥。
- **在 `c.Next()` 之后改响应头**:处理函数已经写出响应时,再设置头部不会生效;需要在写响应前设置。
- **在 goroutine 里直接用 `c`**:请求结束后 `gin.Context` 会被复用,后台 goroutine 要用 `c.Copy()`。
- **令牌无法主动失效**:JWT 是无状态的,退出登录、改密码后旧令牌仍有效;需要短有效期 + 刷新令牌,或服务端维护令牌版本号 / 黑名单。
## 面试常问
见 [Gin 面试题](interview.md):中间件执行顺序、JWT 和 Session 的区别、令牌失效方案。
@@ -0,0 +1,185 @@
// Package unifiedapi 演示企业后台接口规范:统一响应、业务错误码、请求 ID、参数校验中文提示、panic 兜底。
package unifiedapi
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
// AppError 业务错误:HTTP 状态码 + 业务错误码 + 给用户看的提示
type AppError struct {
Status int
Code int
Message string
}
func (e *AppError) Error() string { return e.Message }
var (
ErrNotFound = &AppError{http.StatusNotFound, 40400, "资源不存在"}
ErrInternal = &AppError{http.StatusInternalServerError, 50000, "服务器开小差了,请稍后再试"}
)
func Invalid(msg string) *AppError { return &AppError{http.StatusBadRequest, 40000, msg} }
// Body 统一响应格式
type Body struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
RequestID string `json:"request_id"`
}
func OK(c *gin.Context, status int, data any) {
c.JSON(status, Body{Code: 0, Message: "ok", Data: data, RequestID: c.GetString("requestID")})
}
func writeErr(c *gin.Context, e *AppError) {
c.AbortWithStatusJSON(e.Status, Body{Code: e.Code, Message: e.Message, RequestID: c.GetString("requestID")})
}
// RequestID 沿用客户端传来的 X-Request-ID,没有就生成一个,并写回响应头
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader("X-Request-ID")
if id == "" || len(id) > 64 {
b := make([]byte, 8)
_, _ = rand.Read(b)
id = hex.EncodeToString(b)
}
c.Set("requestID", id)
c.Header("X-Request-ID", id)
c.Next()
}
}
// Errors 统一把处理函数里 c.Error(err) 记录的错误和 panic 转成统一响应;内部错误不把细节暴露给用户
func Errors(log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Error("panic", "request_id", c.GetString("requestID"), "panic", r)
writeErr(c, ErrInternal)
}
}()
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
err := c.Errors.Last().Err
var ae *AppError
if errors.As(err, &ae) {
writeErr(c, ae)
return
}
log.Error("internal error", "request_id", c.GetString("requestID"), "err", err)
writeErr(c, ErrInternal)
}
}
var setupOnce sync.Once
// useJSONFieldNames 让校验错误里显示 JSON 字段名(title)而不是结构体字段名(Title)
func useJSONFieldNames() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterTagNameFunc(func(f reflect.StructField) string {
name := strings.Split(f.Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
return f.Name
}
return name
})
}
}
// Bind 解析并校验 JSON,失败时记录一个用户能看懂的错误
func Bind(c *gin.Context, req any) bool {
err := c.ShouldBindJSON(req)
if err == nil {
return true
}
var ve validator.ValidationErrors
if errors.As(err, &ve) {
msgs := make([]string, 0, len(ve))
for _, fe := range ve {
switch fe.Tag() {
case "required":
msgs = append(msgs, fmt.Sprintf("%s 不能为空", fe.Field()))
case "max":
msgs = append(msgs, fmt.Sprintf("%s 长度不能超过 %s", fe.Field(), fe.Param()))
case "oneof":
msgs = append(msgs, fmt.Sprintf("%s 只能是 %s 之一", fe.Field(), fe.Param()))
default:
msgs = append(msgs, fmt.Sprintf("%s 不合法", fe.Field()))
}
}
_ = c.Error(Invalid(strings.Join(msgs, ";")))
} else {
_ = c.Error(Invalid("请求体格式错误"))
}
return false
}
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Priority string `json:"priority"`
}
type CreateTask struct {
Title string `json:"title" binding:"required,max=50"`
Priority string `json:"priority" binding:"omitempty,oneof=low normal high"`
}
// New 组装路由
func New(log *slog.Logger) *gin.Engine {
setupOnce.Do(useJSONFieldNames)
var mu sync.Mutex
tasks := map[int]Task{}
next := 1
r := gin.New()
r.Use(RequestID(), Errors(log))
api := r.Group("/api")
api.POST("/tasks", func(c *gin.Context) {
var req CreateTask
if !Bind(c, &req) {
return
}
mu.Lock()
t := Task{ID: next, Title: req.Title, Priority: req.Priority}
tasks[t.ID] = t
next++
mu.Unlock()
OK(c, http.StatusCreated, t)
})
api.GET("/tasks/:id", func(c *gin.Context) {
var id int
if _, err := fmt.Sscan(c.Param("id"), &id); err != nil {
_ = c.Error(Invalid("id 必须是整数"))
return
}
mu.Lock()
t, ok := tasks[id]
mu.Unlock()
if !ok {
_ = c.Error(ErrNotFound)
return
}
OK(c, http.StatusOK, t)
})
api.GET("/boom", func(c *gin.Context) { panic("数据库连接串 postgres://secret 泄漏测试") })
api.GET("/db-error", func(c *gin.Context) { _ = c.Error(errors.New("pq: relation \"tasks\" does not exist")) })
return r
}
@@ -0,0 +1,68 @@
package unifiedapi
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func do(t *testing.T, r http.Handler, method, path, body string, hdr map[string]string) (int, Body, http.Header) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range hdr {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
var b Body
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil {
t.Fatalf("响应不是统一格式:%s", w.Body.String())
}
return w.Code, b, w.Header()
}
func TestUnifiedResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
r := New(slog.New(slog.NewTextHandler(io.Discard, nil)))
cases := []struct {
name, method, path, body string
status, code int
msgContains string
}{
{"创建成功", "POST", "/api/tasks", `{"title":"写周报","priority":"high"}`, 201, 0, "ok"},
{"必填校验", "POST", "/api/tasks", `{"title":""}`, 400, 40000, "title 不能为空"},
{"枚举校验", "POST", "/api/tasks", `{"title":"a","priority":"urgent"}`, 400, 40000, "priority 只能是 low normal high 之一"},
{"JSON 格式错误", "POST", "/api/tasks", `{"title":`, 400, 40000, "请求体格式错误"},
{"查询存在", "GET", "/api/tasks/1", "", 200, 0, "ok"},
{"查询不存在", "GET", "/api/tasks/999", "", 404, 40400, "资源不存在"},
{"panic 兜底", "GET", "/api/boom", "", 500, 50000, "服务器开小差"},
{"内部错误不泄漏细节", "GET", "/api/db-error", "", 500, 50000, "服务器开小差"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
status, b, h := do(t, r, c.method, c.path, c.body, nil)
if status != c.status || b.Code != c.code || !strings.Contains(b.Message, c.msgContains) {
t.Fatalf("got %d %+v", status, b)
}
if b.RequestID == "" || h.Get("X-Request-ID") != b.RequestID {
t.Fatalf("缺少请求 ID:%+v", b)
}
if strings.Contains(b.Message, "secret") || strings.Contains(b.Message, "relation") {
t.Fatal("内部错误细节泄漏给了用户")
}
})
}
_, b, _ := do(t, r, "GET", "/api/tasks/1", "", map[string]string{"X-Request-ID": "trace-123"})
if b.RequestID != "trace-123" {
t.Fatalf("应沿用客户端的请求 ID,得到 %q", b.RequestID)
}
}
@@ -0,0 +1,39 @@
module example/unifiedapi
go 1.26.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-playground/validator/v10 v10.30.5
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
@@ -0,0 +1,89 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+28 -3
View File
@@ -4,12 +4,37 @@ title: Gin Web 框架
type: index
domain: backend
stack: [gin]
tags: []
tags: [Go, Gin, REST, 中间件]
week: [1, 2, 3]
---
# Gin Web 框架
路由、参数绑定、中间件、鉴权、统一错误处理。
Gin 是 Go 里使用最广的 Web 框架之一:在标准库 `net/http` 之上提供了高性能路由、参数绑定和校验、中间件链。学会 Gin,看懂其他 Go 框架(Echo、Fiber、Hertz)也很快。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [路由、参数绑定和校验](01-routing-binding.md) | 基础 | 3 小时 |
| 2 | [中间件、JWT 鉴权和统一错误处理](02-middleware-auth.md) | 进阶 | 3 小时 |
## 企业场景
- [企业后台接口规范:统一响应、错误码、请求 ID 和参数校验](scenarios/unified-api-response.md)
## 面试题
- [Gin 面试题](interview.md)
## 示例代码
- [examples/unified-api](examples/unified-api/):统一响应 + 错误码 + 请求 ID + 参数校验中文提示 + panic 兜底 + 测试(`go test ./...`)
## 在 16 周计划中
- [第 1 周](../../../plan/week01-go-gin.md)(Gin 接口)、[第 2 周](../../../plan/week02-db-auth.md)(JWT 鉴权)、[第 3 周](../../../plan/week03-project1.md)(权限、限流中间件)
## 官方资料
- Gin 文档(中文):https://gin-gonic.com/zh-cn/docs/ ;参数校验 validator:https://github.com/go-playground/validator
+69
View File
@@ -0,0 +1,69 @@
---
id: backend.gin.interview
title: Gin 面试题
type: interview
domain: backend
stack: [gin]
tags: [Gin, Web, 面试]
level: intermediate
related: [backend.gin.routing-binding, backend.gin.middleware-auth, backend.gin.scenario.unified-api-response]
week: [1, 2, 3, 16]
---
# Gin 面试题
> 在本计划里的体现:`todo-api`(第 1~2 周)、`task-board`(第 3 周)。
## 基础
### 【基础】Gin 和标准库 net/http 是什么关系?
**参考答案**:Gin 的 `Engine` 实现了 `http.Handler` 接口,底层仍然由 `net/http` 负责监听和连接管理。Gin 增加了基于前缀树的高性能路由、`gin.Context`(封装请求和响应、参数获取、绑定、中间件间传值)、中间件链和常用工具(JSON 渲染、校验)。所以 Gin 可以和任何标准库中间件、`httptest` 一起使用。
### 【基础】Gin 怎么获取路径参数、查询参数和请求体?
**参考答案**:路径参数 `c.Param("id")`;查询参数 `c.Query("k")` / `c.DefaultQuery("k", "默认")`;请求体用绑定 `c.ShouldBindJSON(&req)`,查询参数也可以 `c.ShouldBindQuery(&q)` 绑定到结构体。绑定时根据结构体的 `binding` 标签做校验。
**追问**:`ShouldBind` 和 `Bind` 的区别?→ `Bind*` 失败时自动返回 400 并中止,`ShouldBind*` 只返回错误、由你决定如何响应,一般统一用后者。
### 【基础】中间件的执行顺序是怎样的?`c.Next()` 和 `c.Abort()` 做什么?
**参考答案**:按注册顺序依次执行,形成洋葱模型:`c.Next()` 之前的代码按顺序执行,之后的代码按相反顺序执行。`c.Next()` 立即执行后续的处理函数;`c.Abort()` 阻止后续处理函数执行,但当前函数会继续执行完,所以通常 `Abort` 后紧跟 `return`。
### 【基础】RESTful 接口的状态码怎么选?
**参考答案**:查询成功 200、创建成功 201、删除成功 204、参数错误 400、未认证 401、已认证但无权限 403、不存在 404、状态冲突(如重复创建)409、请求过多 429、服务器错误 500、下游不可用 502/503。
## 进阶
### 【进阶】JWT 和 Session 有什么区别?JWT 怎么实现退出登录?
**参考答案**:Session 把登录状态存在服务端(内存或 Redis),客户端只拿一个 Session ID,服务端可以随时让它失效,但需要共享存储。JWT 把用户信息签名后交给客户端,服务端只验签,无状态、易扩展,但签发后在过期前一直有效。退出登录 / 改密码的方案:短有效期访问令牌(如 15 分钟)+ 刷新令牌;服务端维护用户的令牌版本号或黑名单(存 Redis),校验时比对。
**追问**:令牌存 localStorage 还是 Cookie?→ localStorage 怕 XSS 窃取;httpOnly Cookie 防 XSS 但要防 CSRF(SameSite、CSRF Token)。
### 【进阶】为什么在 goroutine 里使用 gin.Context 要先 `c.Copy()`?
**参考答案**:Gin 用对象池复用 `Context`,请求处理结束后它会被重置给下一个请求使用。如果后台 goroutine 还持有原来的 `c`,读到的可能是别的请求的数据。`c.Copy()` 生成只读副本。更好的做法是只把需要的值(用户 ID、请求 ID、`c.Request.Context()` 派生的 context)传给后台任务。
### 【进阶】如何设计统一的错误处理?
**参考答案**:定义业务错误类型(HTTP 状态 + 业务码 + 用户提示),处理函数通过 `c.Error(err)` 记录错误并返回,由一个错误中间件统一转换成标准响应;未知错误和 panic 统一返回通用提示,详细信息带请求 ID 写日志。详见本技术栈的场景文档。
### 【进阶】Gin 的路由为什么快?
**参考答案**:Gin 使用 httprouter 风格的前缀树(radix tree),每个 HTTP 方法一棵树,按路径公共前缀压缩存储,匹配时间与路径长度相关,基本不受路由数量影响;同时 `Context` 用对象池减少内存分配。
## 场景题
### 【场景题】接口被恶意刷,怎么在 Gin 里做限流?
**参考答案**:单实例用令牌桶(`golang.org/x/time/rate`),按用户 ID 或 IP 维护一个限流器 map(注意定期清理);多实例部署需要全局限流,用 Redis(固定窗口 `INCR+EXPIRE` 或滑动窗口 ZSet、Lua 脚本保证原子)。超限返回 429 并带 `Retry-After`。更外层可以在 Nginx 或网关做 IP 级限流,应用层做用户级、接口级限流。本计划第 3 周项目一实现过。
### 【场景题】一个上传接口偶尔导致服务内存暴涨,怎么处理?
**参考答案**:限制请求体大小(`http.MaxBytesReader` 或 Gin 的 `MaxMultipartMemory`,Nginx 的 `client_max_body_size`);大文件流式处理而不是一次读入内存;校验文件类型和大小;上传改成前端直传对象存储(预签名 URL),后端只接收回调。
### 【场景题】怎么给 Gin 服务写接口测试?
**参考答案**:把路由组装放在一个函数里(如 `New(deps) *gin.Engine`),测试时注入内存版依赖;用 `httptest.NewRecorder()` 和 `r.ServeHTTP(w, req)` 直接调用,不启动端口;用表格驱动覆盖成功、参数错误、未登录、无权限、不存在、内部错误等情况;数据库相关的用集成测试单独跑。示例见 [examples/unified-api](examples/unified-api/)。
@@ -0,0 +1,107 @@
---
id: backend.gin.scenario.unified-api-response
title: 企业后台接口规范:统一响应、错误码、请求 ID 和参数校验
type: scenario
domain: backend
stack: [gin, go]
tags: [接口规范, 错误码, 请求ID, 可观测性, 参数校验]
level: intermediate
prerequisites: [backend.gin.middleware-auth]
est_hours: 2
related: [backend.gin.interview]
week: [2, 3]
---
# 企业后台接口规范:统一响应、错误码、请求 ID 和参数校验
## 业务背景与问题
一个内部管理系统由 4 个后端同事开发,半年后接口风格五花八门:有的返回 `{"error": "..."}`,有的返回 `{"msg": "...", "status": -1}`;参数错误有时 400 有时 200;数据库报错原文直接返回给前端(暴露了表名);用户反馈“提交失败”时,开发只能根据时间点大海捞针地翻日志。前端为每个接口单独写错误处理,维护成本高。
## 约束
- 不能一次性重写所有接口,要能逐步迁移。
- 前端需要稳定的结构:成功、业务失败、系统错误能用同一套代码处理。
- 安全要求:内部错误信息(SQL、堆栈、连接串)不能返回给用户。
- 运维要求:任何一次失败都能通过一个 ID 在日志里找到完整链路。
## 方案设计
**响应格式**(HTTP 状态码表达大类,`code` 表达具体业务原因):
```json
{"code": 0, "message": "ok", "data": {"id": 1}, "request_id": "a1b2c3d4e5f60718"}
{"code": 40000, "message": "title 不能为空", "data": null, "request_id": "..."}
```
**错误码规则**:5 位数字,前 3 位对应 HTTP 状态码,后 2 位是业务细分。例如 40000 参数错误、40100 未登录、40101 登录过期、40300 无权限、40400 资源不存在、40900 状态冲突、50000 系统错误。错误码在一个文件里集中定义,并生成文档给前端。
```mermaid
sequenceDiagram
participant FE as 前端
participant RID as RequestID 中间件
participant ERR as Errors 中间件
participant H as 处理函数
FE->>RID: 请求(可带 X-Request-ID)
RID->>ERR: 设置 request_id,写回响应头
ERR->>H: c.Next()
H-->>ERR: c.Error(AppError) 或 panic 或 成功响应
ERR-->>FE: 统一格式;内部错误只返回“服务器开小差”,详细信息写日志(带 request_id)
```
**职责划分**:
1. `RequestID` 中间件:沿用网关或前端传入的 ID(便于跨服务追踪),没有就生成。
2. 处理函数:只做“绑定参数 → 调用 service → 成功响应 / `c.Error(err)`”,不自己拼错误 JSON。
3. `Errors` 中间件:把 `AppError` 转成对应响应;未知错误和 panic 统一转成 50000,详细信息只写日志。
4. 校验错误翻译成中文、用 JSON 字段名,前端可以直接展示。
5. **逐步迁移**:新接口直接用新规范;旧接口在网关层或前端适配层兼容,按模块逐步改造。
## 关键代码
完整实现和 8 个测试用例见 [examples/unified-api](../examples/unified-api/)。核心是错误中间件:
```go
func Errors(log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Error("panic", "request_id", c.GetString("requestID"), "panic", r)
writeErr(c, ErrInternal)
}
}()
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
err := c.Errors.Last().Err
var ae *AppError
if errors.As(err, &ae) {
writeErr(c, ae) // 业务错误:提示语是写给用户看的
return
}
log.Error("internal error", "request_id", c.GetString("requestID"), "err", err)
writeErr(c, ErrInternal) // 未知错误:不暴露细节
}
}
```
## 取舍
- **HTTP 状态码 + 业务码 vs 全部返回 200**:全部 200 的做法前端处理简单,但网关监控、浏览器缓存、重试策略都依赖 HTTP 状态码。本方案两者都给:状态码表达大类,业务码表达细节。
- **错误码集中定义**:增加一点流程成本(新增错误码要登记),换来前后端一致和可生成文档。
- **请求 ID 自己生成 vs 接入链路追踪(OpenTelemetry)**:先用请求 ID 解决 80% 的排查问题,后续接入追踪时把 trace ID 作为请求 ID 即可,接口格式不变。
## 易错点
- 处理函数里既 `c.JSON` 又 `c.Error`,中间件里要判断 `c.Writer.Written()`,否则会写两次响应。
- `panic` 恢复后要记录堆栈(`debug.Stack()`),否则很难定位。
- 请求 ID 来自客户端时要限制长度和字符,防止日志注入。
- 错误提示里拼接用户输入,要注意不要把敏感数据回显。
- 不同服务的错误码冲突:多服务时按服务分段(例如前两位是服务编号)。
## 面试怎么讲
> “我们后台接口曾经格式不统一、错误信息直接暴露 SQL、排查问题要翻很久日志。我定了统一响应格式和 5 位业务错误码,用两个中间件实现:一个生成或透传请求 ID,一个统一把业务错误、未知错误和 panic 转成标准响应,内部错误只给用户友好提示、细节带着请求 ID 写日志。校验错误翻译成中文直接给前端展示。新接口直接用,旧接口逐步迁移。之后用户报错只要提供请求 ID,就能在日志里一次定位。”
可能的追问:错误码怎么管理和生成文档?多个微服务之间请求 ID 怎么传递(HTTP 头透传、gRPC metadata)?为什么不全部返回 200?
+117
View File
@@ -0,0 +1,117 @@
---
id: backend.go.basics-types
title: 语法、类型和接口(对照 TypeScript)
type: lesson
domain: backend
stack: [go]
tags: [Go, 切片, map, 结构体, 接口]
level: basic
est_hours: 4
related: [backend.go.interview]
week: [1]
order: 1
---
# 语法、类型和接口(对照 TypeScript)
## 解释说明
Go 是静态类型、编译型语言,语法关键字只有 25 个。对前端开发者来说,最需要转变的几点:
| 概念 | TypeScript | Go |
| --- | --- | --- |
| 变量 | `let x = 1` | `x := 1`(函数内)或 `var x int = 1` |
| 数组 / 列表 | `number[]` | 切片 `[]int`:底层是“指向数组的指针 + 长度 + 容量” |
| 对象 / 字典 | `Record<string, number>` | `map[string]int`,**使用前必须初始化** |
| 类 / 接口 | `class`、`interface` 需要 `implements` | 结构体 + 方法;接口是**隐式实现**:方法对得上就算实现 |
| 空值 | `null` / `undefined` | 每种类型都有“零值”:`0`、`""`、`false`、`nil` |
| 异常 | `throw` / `try` | 函数多返回值,最后一个是 `error`,调用方显式检查 |
| 可见性 | `export` | 名字**首字母大写**就是导出的 |
- **切片**:`append` 超过容量时会分配新的底层数组,所以 `append` 的结果一定要接住:`s = append(s, x)`。两个切片可能共享同一个底层数组,修改一个会影响另一个。
- **指针**:Go 的函数参数都是值拷贝。要在方法里修改结构体,用**指针接收者** `func (t *Task) Done()`。
- **接口**:小接口是 Go 的习惯,例如标准库的 `io.Reader` 只有一个方法。依赖接口而不是具体类型,测试时就能换成假实现。
## 案例
企业后端最常见的分层:`handler`(HTTP)→ `service`(业务)→ `store`(数据库)。`store` 定义成接口,生产用 PostgreSQL 实现,单元测试用内存实现——第 1 周的 `todo-api` 就是这样做的。
## 代码示例
```go
package main
import (
"errors"
"fmt"
"sort"
)
type Task struct {
ID int
Title string
Done bool
}
// 指针接收者:修改的是原对象
func (t *Task) Finish() { t.Done = true }
// TaskStore 是一个小接口:任何有这两个方法的类型都自动实现它
type TaskStore interface {
Add(title string) (Task, error)
List() []Task
}
type MemoryStore struct {
nextID int
tasks map[int]Task
}
func NewMemoryStore() *MemoryStore { return &MemoryStore{nextID: 1, tasks: map[int]Task{}} }
var ErrEmptyTitle = errors.New("标题不能为空")
func (m *MemoryStore) Add(title string) (Task, error) {
if title == "" {
return Task{}, ErrEmptyTitle
}
t := Task{ID: m.nextID, Title: title}
m.tasks[t.ID] = t
m.nextID++
return t, nil
}
func (m *MemoryStore) List() []Task {
out := make([]Task, 0, len(m.tasks)) // 预分配容量
for _, t := range m.tasks {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) // map 遍历顺序是随机的
return out
}
func main() {
var store TaskStore = NewMemoryStore() // 编译期检查 *MemoryStore 实现了接口
_, _ = store.Add("学 Go")
if _, err := store.Add(""); errors.Is(err, ErrEmptyTitle) {
fmt.Println("校验生效:", err)
}
tasks := store.List()
tasks[0].Finish()
fmt.Printf("%+v\n", tasks)
}
```
运行:保存为 `main.go`,执行 `go run main.go`。
## 易错点
- **map 没初始化就写入**:`var m map[string]int; m["a"] = 1` 会 panic。用 `make` 或字面量 `map[string]int{}` 初始化。
- **map 遍历顺序随机**:需要稳定顺序时先取出 key 排序。
- **切片共享底层数组**:`b := a[:2]; b[0] = 9` 会改到 `a`;需要独立副本时用 `copy` 或 `slices.Clone`。
- **值接收者改不了原对象**:方法要修改字段就用指针接收者;同一个类型的方法接收者风格保持一致。
- **接口里的 nil**:把一个值为 `nil` 的指针赋给接口,接口本身不等于 `nil`(类型信息还在),返回错误时要直接 `return nil`。
## 面试常问
见 [Go 面试题](interview.md) 中的切片扩容、接口隐式实现、值接收者和指针接收者。
+123
View File
@@ -0,0 +1,123 @@
---
id: backend.go.errors-concurrency
title: 错误处理和并发:goroutine、channel、context
type: lesson
domain: backend
stack: [go]
tags: [Go, 错误处理, goroutine, channel, context, 并发]
level: intermediate
prerequisites: [backend.go.basics-types]
est_hours: 4
related: [backend.go.scenario.batch-third-party-calls, backend.go.interview]
week: [1, 3]
order: 2
---
# 错误处理和并发:goroutine、channel、context
## 解释说明
**错误处理**
- 错误是普通的值。用 `fmt.Errorf("读取配置: %w", err)` **包装**错误,保留原始错误并加上下文;调用方用 `errors.Is(err, ErrNotFound)` 判断是不是某个错误,用 `errors.As(err, &target)` 取出某种类型的错误。
- 只在真正不可恢复的情况下用 `panic`(例如程序启动时配置缺失);HTTP 服务里用中间件 `recover`,防止一个请求的 panic 让整个进程退出。
**并发**
- **goroutine**:`go f()` 启动一个轻量级“线程”(初始栈几 KB,由 Go 运行时调度到系统线程上),可以轻松开成千上万个。
- **channel**:goroutine 之间传递数据的管道。无缓冲 channel 收发双方要同时就绪;有缓冲 channel 满了才阻塞发送。“不要通过共享内存来通信,而要通过通信来共享内存。”
- **select**:同时等待多个 channel,哪个先就绪处理哪个;常用来实现超时和取消。
- **sync**:`WaitGroup` 等待一组 goroutine 结束;`Mutex` / `RWMutex` 保护共享数据;`Once` 只执行一次。
- **context**:在调用链上传递**取消信号、超时和请求范围的值**。HTTP 请求进来时创建,往下传给数据库查询、外部调用;客户端断开或超时,下游全部及时停止。约定:`ctx` 作为函数的第一个参数。
## 案例
- 一个接口要同时查用户信息、订单列表、优惠券三个下游服务:三个 goroutine 并发查询,整体用 `context.WithTimeout` 限制 2 秒,任一失败就取消其他的(`errgroup` 的典型用法)。
- 消息消费者:从队列取消息、交给固定数量的 worker 处理(worker pool),服务关闭时通过 `context` 通知 worker 处理完手上的消息再退出。
## 代码示例
一个带超时和取消的 worker pool:
```go
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
var ErrInvalid = errors.New("无效的任务")
func process(ctx context.Context, n int) (int, error) {
if n < 0 {
return 0, fmt.Errorf("处理 %d: %w", n, ErrInvalid) // 包装错误
}
select {
case <-time.After(time.Duration(n) * 10 * time.Millisecond): // 模拟耗时操作
return n * n, nil
case <-ctx.Done(): // 超时或被取消
return 0, ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
jobs := make(chan int)
type result struct {
n, v int
err error
}
results := make(chan result)
var wg sync.WaitGroup
for w := 0; w < 3; w++ { // 3 个 worker
wg.Add(1)
go func() {
defer wg.Done()
for n := range jobs { // jobs 关闭后循环结束
v, err := process(ctx, n)
results <- result{n, v, err}
}
}()
}
go func() {
for _, n := range []int{1, 2, -1, 3, 50} {
jobs <- n
}
close(jobs) // 发送方负责关闭
}()
go func() { wg.Wait(); close(results) }() // 全部 worker 结束后关闭结果通道
for r := range results {
switch {
case errors.Is(r.err, ErrInvalid):
fmt.Println(r.n, "参数错误:", r.err)
case errors.Is(r.err, context.DeadlineExceeded):
fmt.Println(r.n, "超时")
default:
fmt.Println(r.n, "=>", r.v)
}
}
}
```
运行:`go run main.go`,再用 `go run -race main.go` 检查数据竞争。完整的生产写法(并发上限、单次超时、部分失败)见 [examples/batch-fetch](examples/batch-fetch/)。
## 易错点
- **goroutine 泄漏**:goroutine 阻塞在没人接收的 channel 上永远不退出。每个 goroutine 都要想清楚“它什么时候结束”,配合 `context` 或关闭 channel。
- **向已关闭的 channel 发送会 panic**;关闭的责任在发送方,多个发送方时用 `WaitGroup` 等全部结束再关闭。
- **循环变量**:Go 1.22 起每次循环都是新变量,旧版本里在 goroutine 中直接用循环变量会全部拿到最后一个值。
- **map 不是并发安全的**:多个 goroutine 同时读写会直接崩溃(fatal error),用 `Mutex` 或 `sync.Map`。
- **把 context 存进结构体**:context 应该随调用传递,不要长期保存。
- **吞掉错误**:`_ = f()` 要有理由;记录日志和返回错误二选一,不要两个都做(会重复打印)。
## 面试常问
见 [Go 面试题](interview.md):GMP 调度、channel 的阻塞规则、context 的用途、如何发现数据竞争。
+100
View File
@@ -0,0 +1,100 @@
---
id: backend.go.testing-engineering
title: 测试和工程化:表格驱动测试、httptest、模块、项目结构
type: lesson
domain: backend
stack: [go]
tags: [Go, 测试, httptest, go mod, 项目结构]
level: intermediate
prerequisites: [backend.go.basics-types]
est_hours: 3
related: [backend.go.interview, backend.gin.routing-binding]
week: [1, 2, 3]
order: 3
---
# 测试和工程化:表格驱动测试、httptest、模块、项目结构
## 解释说明
- **go test**:`xxx_test.go` 里的 `TestXxx(t *testing.T)` 会被自动执行。`t.Run` 定义子测试;`-race` 检测数据竞争;`-cover` 看覆盖率;`BenchmarkXxx` 做性能测试。
- **表格驱动测试**:把多组“输入 + 期望”写成切片循环执行,是 Go 社区的惯例,新增用例只加一行。
- **httptest**:`httptest.NewRecorder()` 不启动端口直接测 handler;`httptest.NewServer` 启动一个真实的本地服务,用来模拟第三方接口。
- **模块**:`go mod init 模块名` 生成 `go.mod`;`go get` 加依赖;`go mod tidy` 清理。`go.sum` 记录依赖的校验和,要提交。
- **项目结构**(社区常见做法,不是强制标准):`cmd/<程序名>/main.go` 放入口;`internal/` 放不希望被其他模块导入的代码(编译器强制);按职责分包(`handler`、`service`、`store`),避免一个 `utils` 包装所有东西。
## 案例
接口层的测试一般这样分:handler 测试用 `httptest.NewRecorder` + 内存版 store,覆盖参数校验、状态码、鉴权;store 的集成测试连真实数据库(没有数据库时跳过);调用第三方的代码用 `httptest.NewServer` 模拟各种返回(超时、500、格式错误)。
## 代码示例
```go
// 文件:slug.go
package slug
import (
"strings"
"unicode"
)
// Make 把标题转成 URL 片段:小写、空白和标点变成 -,去掉首尾 -
func Make(title string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(title) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
dash = false
} else if !dash && b.Len() > 0 {
b.WriteByte('-')
dash = true
}
}
return strings.TrimSuffix(b.String(), "-")
}
```
```go
// 文件:slug_test.go
package slug
import "testing"
func TestMake(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"普通", "Hello World", "hello-world"},
{"多个符号", "Go -- 并发!!", "go-并发"},
{"首尾空白", " Redis ", "redis"},
{"空", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Make(c.in); got != c.want {
t.Fatalf("Make(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
func BenchmarkMake(b *testing.B) {
for b.Loop() { // Go 1.24+ 的写法;旧版本用 for i := 0; i < b.N; i++
Make("Hello World From Go")
}
}
```
运行:`go mod init slug && go test -v -race -cover ./... && go test -bench=. ./...`
## 易错点
- 测试之间共享全局状态(全局 map、同一张表)会让测试结果依赖执行顺序;每个测试自己准备数据,`t.Cleanup` 清理。
- 只测“正常路径”:错误路径(参数错、下游失败、超时)才是线上出问题的地方。
- `internal` 包只能被它的父目录及子目录导入;别的模块导入会编译失败——这是故意的。
- 不提交 `go.sum` 会让别人构建时无法校验依赖。
## 面试常问
见 [Go 面试题](interview.md):怎么测试依赖数据库或第三方接口的代码、`internal` 的作用。
@@ -0,0 +1,64 @@
// Package batchfetch 演示企业里常见的“批量调用第三方接口”:
// 并发上限、单次超时、整体取消、部分失败不影响其他结果、结果顺序与输入一致。
package batchfetch
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
)
type Result struct {
ID string
Body string
Err error
}
// FetchAll 最多同时发出 limit 个请求,每个请求最多等待 perReq。
func FetchAll(ctx context.Context, client *http.Client, baseURL string, ids []string, limit int, perReq time.Duration) []Result {
results := make([]Result, len(ids)) // 每个 goroutine 只写自己的下标,不需要加锁
sem := make(chan struct{}, limit) // 信号量:缓冲区大小就是并发上限
var wg sync.WaitGroup
for i, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}: // 拿到名额
case <-ctx.Done(): // 整体已取消,不再发请求
results[i] = Result{ID: id, Err: ctx.Err()}
return
}
defer func() { <-sem }()
results[i] = fetchOne(ctx, client, baseURL, id, perReq)
}()
}
wg.Wait()
return results
}
func fetchOne(ctx context.Context, client *http.Client, baseURL, id string, timeout time.Duration) Result {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/items/"+url.PathEscape(id), nil)
if err != nil {
return Result{ID: id, Err: err}
}
resp, err := client.Do(req)
if err != nil {
return Result{ID: id, Err: fmt.Errorf("请求 %s: %w", id, err)}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Result{ID: id, Err: fmt.Errorf("请求 %s: 状态码 %d", id, resp.StatusCode)}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 限制响应大小,防止对方返回超大内容
if err != nil {
return Result{ID: id, Err: fmt.Errorf("读取 %s: %w", id, err)}
}
return Result{ID: id, Body: string(body)}
}
@@ -0,0 +1,78 @@
package batchfetch
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestFetchAll(t *testing.T) {
var cur, peak int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt64(&cur, 1)
defer atomic.AddInt64(&cur, -1)
for {
p := atomic.LoadInt64(&peak)
if n <= p || atomic.CompareAndSwapInt64(&peak, p, n) {
break
}
}
id := strings.TrimPrefix(r.URL.Path, "/items/")
switch id {
case "slow":
select {
case <-time.After(time.Second):
case <-r.Context().Done():
}
case "bad":
http.Error(w, "boom", http.StatusInternalServerError)
return
default:
time.Sleep(20 * time.Millisecond)
}
_, _ = w.Write([]byte("ok:" + id))
}))
defer srv.Close()
ids := []string{"a", "b", "slow", "c", "bad", "d", "e", "f"}
res := FetchAll(context.Background(), srv.Client(), srv.URL, ids, 3, 200*time.Millisecond)
if peak > 3 {
t.Fatalf("并发数 %d 超过上限 3", peak)
}
for i, r := range res {
if r.ID != ids[i] {
t.Fatalf("结果顺序不对:%v", r.ID)
}
switch r.ID {
case "slow":
if !errors.Is(r.Err, context.DeadlineExceeded) {
t.Fatalf("slow 应该超时,得到 %v", r.Err)
}
case "bad":
if r.Err == nil || !strings.Contains(r.Err.Error(), "500") {
t.Fatalf("bad 应该返回 500 错误,得到 %v", r.Err)
}
default:
if r.Err != nil || r.Body != "ok:"+r.ID {
t.Fatalf("%s: %v %q", r.ID, r.Err, r.Body)
}
}
}
}
func TestCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
res := FetchAll(ctx, http.DefaultClient, "http://127.0.0.1:1", []string{"x", "y"}, 1, time.Second)
for _, r := range res {
if r.Err == nil {
t.Fatal("已取消的 context 应该让所有请求失败")
}
}
}
@@ -0,0 +1,3 @@
module example/batchfetch
go 1.22
+29 -3
View File
@@ -4,12 +4,38 @@ title: Go 语言
type: index
domain: backend
stack: [go]
tags: []
tags: [Go, 后端, 并发]
week: [1, 2, 3, 14]
---
# Go 语言
语法、接口、错误处理、并发、测试和工程化。
Go 在本计划里是**加分项**:目标是能独立写出带数据库、鉴权、缓存、部署的后端服务,并能回答常见的 Go 面试题。语法简单、并发好用、部署是一个二进制文件——这些是企业选 Go 的主要原因。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [语法、类型和接口(对照 TypeScript)](01-basics-types.md) | 基础 | 4 小时 |
| 2 | [错误处理和并发:goroutine、channel、context](02-errors-concurrency.md) | 进阶 | 4 小时 |
| 3 | [测试和工程化:表格驱动测试、httptest、模块、项目结构](03-testing-engineering.md) | 进阶 | 3 小时 |
## 企业场景
- [批量调用第三方接口:并发控制、超时和部分失败](scenarios/batch-third-party-calls.md)
## 面试题
- [Go 面试题(基础 / 进阶 / 场景题)](interview.md)
## 示例代码
- [examples/batch-fetch](examples/batch-fetch/):有并发上限、单次超时、部分失败的批量请求(`go test ./...`)
## 在 16 周计划中
- [第 1 周:Go 基础 + Gin](../../../plan/week01-go-gin.md)、[第 2 周:数据库 + 鉴权](../../../plan/week02-db-auth.md)、[第 3 周:项目一](../../../plan/week03-project1.md)、[第 14 周:可选 Go 网关](../../../plan/week14-backend-extend-deploy.md)
## 官方资料
- Go 官方教程(中文):https://tour.go-zh.org/ ;Effective Go:https://go.dev/doc/effective_go ;Go by Example(中文):https://gobyexample-cn.github.io/
+85
View File
@@ -0,0 +1,85 @@
---
id: backend.go.interview
title: Go 面试题
type: interview
domain: backend
stack: [go]
tags: [Go, 面试]
level: intermediate
related: [backend.go.basics-types, backend.go.errors-concurrency, backend.go.testing-engineering]
week: [1, 3, 16]
---
# Go 面试题
> 在本计划里的体现:`todo-api`、`task-board`(第 1~3 周),可选的 Go 网关(第 14 周)。
## 基础
### 【基础】切片(slice)和数组有什么区别?append 什么时候扩容?
**参考答案**:数组长度固定,是值类型,赋值会整体拷贝;切片是对底层数组的引用,包含指针、长度(len)和容量(cap)。`append` 时如果容量够就直接写入底层数组,不够就分配一个更大的新数组并拷贝(小切片大约翻倍,大切片按更小的比例增长,具体策略随版本调整)。所以 `append` 的返回值必须接住。
**追问**:两个切片共享底层数组会有什么问题?→ 修改一个会影响另一个;需要独立副本时用 `copy` 或 `slices.Clone`。
### 【基础】Go 的接口有什么特点?
**参考答案**:隐式实现——类型只要有接口要求的全部方法,就自动实现了这个接口,不需要声明 `implements`。好处是解耦:使用方定义小接口,实现方不需要知道这个接口的存在。常见做法是“接受接口,返回具体类型”。
**追问**:空接口 `any` 是什么?→ 没有方法的接口,所有类型都实现它;取出具体值要用类型断言 `v.(T)` 或类型 switch。
### 【基础】值接收者和指针接收者怎么选?
**参考答案**:需要修改接收者、或者结构体很大(避免拷贝)时用指针接收者;小的、不可变的值类型可以用值接收者。同一个类型的方法风格保持一致。注意:指针接收者的方法只属于 `*T` 的方法集,所以只有 `*T` 实现了相关接口。
### 【基础】Go 为什么没有异常?错误怎么处理?
**参考答案**:Go 把错误当成普通返回值,强制调用方显式处理,控制流清楚。用 `fmt.Errorf("...: %w", err)` 包装错误加上下文,用 `errors.Is` / `errors.As` 判断。`panic` 只用于不可恢复的错误,HTTP 服务用中间件 `recover` 兜底。
**追问**:`errors.Is` 和 `==` 的区别?→ `errors.Is` 会沿着包装链逐层比较,`==` 只比较最外层。
### 【基础】`defer` 的执行顺序和常见用途?
**参考答案**:函数返回前执行,多个 `defer` 后进先出。常用于关闭文件、释放锁、`recover`、记录耗时。参数在 `defer` 语句执行时就求值了。
**追问**:在循环里 `defer` 关闭文件有什么问题?→ 要等整个函数结束才关闭,循环次数多会耗尽文件句柄;把循环体提取成函数。
## 进阶
### 【进阶】goroutine 和线程有什么区别?讲讲 GMP 调度。
**参考答案**:goroutine 是 Go 运行时管理的轻量级协程,初始栈只有几 KB 且可增长,创建和切换成本远低于系统线程。GMP:G 是 goroutine,M 是系统线程,P 是调度用的处理器(数量默认等于 CPU 核数,`GOMAXPROCS`)。每个 P 有本地队列,M 绑定 P 执行其中的 G;队列空了会从全局队列或其他 P“偷”任务;G 阻塞在系统调用时,P 会交给其他 M 继续运行。
**追问**:goroutine 会不会被抢占?→ Go 1.14 起支持基于信号的异步抢占,长时间运行的循环也能被调度出去。
### 【进阶】channel 的阻塞规则?关闭后会怎样?
**参考答案**:无缓冲 channel 发送和接收必须同时就绪;有缓冲的满了发送阻塞、空了接收阻塞。关闭后:接收会先读完剩余数据,然后立即返回零值(`v, ok := <-ch` 中 `ok` 为 false);向已关闭的 channel 发送会 panic;重复关闭也会 panic;对 nil channel 收发永远阻塞。
**追问**:谁负责关闭 channel?→ 发送方;多个发送方时用 `WaitGroup` 等全部结束后由一个协调者关闭。
### 【进阶】context 有什么用?使用规范是什么?
**参考答案**:在调用链上传递取消信号、截止时间和请求范围的值。HTTP 请求结束或超时,所有带这个 context 的数据库查询、下游调用都会及时停止,避免资源浪费。规范:作为第一个参数传递、不要存进结构体、不要传 nil(用 `context.TODO()`)、`WithValue` 只放请求范围的元数据(如请求 ID),不放业务参数。
### 【进阶】什么是数据竞争?怎么发现和避免?
**参考答案**:多个 goroutine 同时访问同一变量且至少一个是写,又没有同步。用 `go test -race` / `go run -race` 发现(只能发现实际执行到的路径)。避免:用 channel 传递所有权、`sync.Mutex` / `RWMutex` 加锁、`sync/atomic` 原子操作;并发读写 map 会直接 fatal。
## 场景题
### 【场景题】一个接口要并发调用 3 个下游服务,整体 2 秒超时,任一失败就返回错误,怎么写?
**参考答案**:用 `errgroup.WithContext(ctx)` 配合 `context.WithTimeout(ctx, 2*time.Second)`:每个下游一个 `g.Go(...)`,任一返回错误时 errgroup 会取消共享的 context,其他调用随之停止;`g.Wait()` 返回第一个错误。下游客户端必须使用这个 context 才能真正被取消。如果需要“部分失败也返回”,就不用 errgroup 的取消语义,改成各自记录结果(见本技术栈的场景文档)。
**追问**:下游有一个特别慢但不重要的服务怎么办?→ 给它单独更短的超时,失败时降级(返回默认值)。
### 【场景题】线上 Go 服务内存持续上涨,怎么排查?
**参考答案**:先确认是不是泄漏:看监控的内存曲线和 goroutine 数量。用 `net/http/pprof` 暴露分析接口,抓 `heap` 看哪些对象占内存、抓 `goroutine` 看有没有大量阻塞在同一处的 goroutine(最常见的是 channel 没人接收、没设超时的网络请求)。对比两个时间点的 heap 差异定位增长来源。修复后加上超时和 context 取消。
**追问**:pprof 能直接暴露在公网吗?→ 不能,只在内网或通过鉴权访问。
### 【场景题】服务发布时如何做到不丢请求?
**参考答案**:优雅关机:监听 SIGTERM,调用 `http.Server.Shutdown(ctx)` 停止接收新连接并等待处理中的请求完成(带超时);后台 worker 通过 context 收到取消信号,处理完当前任务再退出;最后关闭数据库连接池。部署侧配合负载均衡的健康检查,先摘流量再停进程。在本计划第 1 周第 7 天实现过。
@@ -0,0 +1,98 @@
---
id: backend.go.scenario.batch-third-party-calls
title: 批量调用第三方接口:并发控制、超时和部分失败
type: scenario
domain: backend
stack: [go]
tags: [并发, 超时, 第三方接口, 限流, 重试]
level: intermediate
prerequisites: [backend.go.errors-concurrency]
est_hours: 2
related: [backend.go.interview, backend.redis.lock-ratelimit]
week: [3]
---
# 批量调用第三方接口:并发控制、超时和部分失败
## 业务背景与问题
电商后台的“批量查询物流状态”功能:运营一次选中几百个订单,系统要调用物流公司的查询接口,汇总后展示。第一版代码在一个循环里逐个调用,500 个订单要等一分多钟,接口经常超时;改成“每个订单开一个 goroutine”后又被物流公司限流(返回 429),偶尔还把自己服务的连接数打满。
## 约束
- 第三方接口限流:每个账号最多 10 个并发、每秒 50 次(以对方文档为准)。
- 单次调用一般 100~300 毫秒,偶尔卡住几十秒。
- 页面请求整体最多等 5 秒;部分订单查询失败不能让整批失败。
- 结果要和输入订单一一对应,方便前端展示。
## 方案设计
```mermaid
flowchart LR
A[接口收到订单列表] --> B{整体 context<br/>超时 5 秒}
B --> C[信号量:最多 10 个并发]
C --> D[单次请求 context<br/>超时 1 秒]
D -->|成功| E[写入结果下标 i]
D -->|失败 / 超时| F[记录该订单的错误]
E --> G[等待全部完成]
F --> G
G --> H[返回:成功列表 + 失败列表]
```
1. **并发上限**:用带缓冲的 channel 做信号量,缓冲大小 = 对方允许的并发数。
2. **两层超时**:整体 context 控制页面等待时间;单次请求再套一层短超时,避免一个慢请求占着名额。
3. **部分失败**:每个订单的结果独立记录错误,最后返回“成功 + 失败(带原因)”,前端可以对失败的单独重试。
4. **结果顺序**:预先分配结果切片,每个 goroutine 写自己的下标,不需要加锁。
5. **进一步**:每秒次数限制用令牌桶(`golang.org/x/time/rate`);多实例部署时用 Redis 做全局限流;常用订单的物流状态缓存 1~5 分钟;数量很大时改成异步任务(提交后后台处理、完成后通知)。
## 关键代码
完整代码和测试见 [examples/batch-fetch](../examples/batch-fetch/),核心部分:
```go
sem := make(chan struct{}, limit) // 并发上限
for i, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done(): // 整体超时后,没开始的请求不再发
results[i] = Result{ID: id, Err: ctx.Err()}
return
}
defer func() { <-sem }()
results[i] = fetchOne(ctx, client, baseURL, id, perReq) // 内部再套单次超时
}()
}
wg.Wait()
```
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 串行调用 | 简单,不会触发限流 | 太慢 |
| 每个订单一个 goroutine,不限并发 | 写法简单 | 被限流、连接数暴涨,下游故障时拖垮自己 |
| **信号量 + 两层超时(本方案)** | 速度和稳定性平衡,代码量小 | 单机限流,多实例需要 Redis 全局限流 |
| 异步任务 + 消息队列 | 能处理上万条、可重试 | 交互变成“提交—等待通知”,复杂度高 |
选择:几百条以内用本方案;超过几千条或需要可靠重试时改成异步任务。
## 易错点
- 只设整体超时、不设单次超时:一个卡住的请求会一直占着并发名额。
- 用 `http.DefaultClient`:它没有超时,生产代码要自己创建 `http.Client` 并设置 `Timeout`,或者每个请求都带 context。
- 忘了 `resp.Body.Close()`:连接无法复用,最终耗尽连接。
- 对 429 / 5xx 立即重试:会让限流更严重;重试要有退避和次数上限,最好只重试幂等的查询。
- 多个 goroutine 同时 `append` 同一个切片:数据竞争,用下标写入或加锁。
## 面试怎么讲
> “我们有个批量查物流的功能,最早串行调用太慢,改成全并发又被第三方限流。我用带缓冲的 channel 做信号量把并发控制在对方允许的范围内,整体请求和单次调用分别设超时,每个订单独立记录成功或失败,最后返回部分成功的结果,前端可以单独重试失败的。测试里用 httptest 模拟慢请求和 500,验证并发上限、超时和结果顺序。如果数据量再大,我会改成异步任务加消息队列。”
可能的追问:
- 多实例部署时怎么保证总并发不超过 10?→ Redis 分布式限流或者集中到一个调用服务。
- 为什么不用 `errgroup`?→ `errgroup` 默认“一个失败全部取消”,这里需要部分失败;新版 `errgroup` 的 `SetLimit` 也能限并发,可以按需选择。
- 怎么知道限流参数合不合适?→ 监控第三方的 429 比例和耗时分布,逐步调整。
@@ -0,0 +1,114 @@
---
id: backend.grpc-microservices.grpc-proto
title: gRPC 和 Protobuf:IDL、四种调用方式、错误码、超时、拦截器
type: lesson
domain: backend
stack: [grpc-microservices, go]
tags: [gRPC, Protobuf, HTTP/2, 拦截器, deadline]
level: intermediate
prerequisites: [backend.go.errors-concurrency]
est_hours: 3
related: [backend.grpc-microservices.governance, backend.grpc-microservices.interview]
week: [14]
order: 1
---
# gRPC 和 Protobuf:IDL、四种调用方式、错误码、超时、拦截器
## 解释说明
- **gRPC**:基于 HTTP/2 的 RPC 框架。先用 **Protobuf** 写接口定义(`.proto`),再由工具生成各语言的客户端和服务端代码,调用远程方法像调用本地函数。
- **Protobuf**:二进制序列化格式。比 JSON 体积小、解析快,有强类型约束;缺点是不可读,浏览器不能直接调用(需要 gRPC-Web 或网关转 HTTP/JSON)。
- **字段编号**:序列化时用编号而不是字段名。**已发布的编号不能改、不能复用**;删除字段时用 `reserved` 保留编号和名字。新增字段是向后兼容的。
- **四种调用方式**:一元(一问一答)、服务端流(一次请求、多次返回,例如推送进度)、客户端流(多次上传、一次返回,例如上传分片)、双向流(聊天、实时协作)。
- **错误码**:gRPC 有自己的状态码,常用 `InvalidArgument`、`NotFound`、`AlreadyExists`、`PermissionDenied`、`Unauthenticated`、`DeadlineExceeded`、`Unavailable`、`Internal`。可重试的一般只有 `Unavailable`(以及部分 `DeadlineExceeded`、`ResourceExhausted`),由业务判断。
- **Deadline(截止时间)**:客户端每次调用都要设置;截止时间会随请求传到服务端,服务端的 `ctx` 到期后应该停止工作,并继续传给下游调用。
- **拦截器(interceptor)**:gRPC 的中间件,用来做日志、鉴权、指标、恢复 panic。元数据(metadata)相当于 HTTP 头,用来传 token、请求 ID。
**和 REST 怎么选**:对外开放的接口、浏览器直接调用用 REST/JSON;内部服务之间、对性能和强类型契约有要求时用 gRPC。
## 案例
订单服务需要查用户信息:定义 `UserService.GetUser`,用户服务实现,订单服务通过生成的客户端调用,每次调用带 300ms 截止时间;用户不存在返回 `NotFound`,订单服务据此返回业务错误,而不是 500。
## 代码示例
接口定义([examples/grpc-user/user.proto](examples/grpc-user/user.proto)):
```proto
syntax = "proto3";
package user.v1;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User); // 服务端流
}
message GetUserRequest { int64 id = 1; }
message ListUsersRequest { int32 page_size = 1; }
message User {
int64 id = 1;
string name = 2;
string email = 3;
reserved 4; // 已删除字段的编号,不能复用
reserved "phone";
}
```
Go 服务端和客户端的关键写法(需要先用 `protoc --go_out --go-grpc_out` 生成 `userv1` 包;安装和生成命令以官方 Go 快速开始为准:https://grpc.io/docs/languages/go/quickstart/ ):
```go
// 服务端:实现生成的接口
type userServer struct {
userv1.UnimplementedUserServiceServer // 嵌入它,接口新增方法时不会编译失败
repo UserRepo
}
func (s *userServer) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.User, error) {
if req.GetId() <= 0 {
return nil, status.Error(codes.InvalidArgument, "id 必须大于 0")
}
u, err := s.repo.Find(ctx, req.GetId()) // 把 ctx 继续往下传,截止时间才能生效
if errors.Is(err, ErrNotFound) {
return nil, status.Errorf(codes.NotFound, "用户 %d 不存在", req.GetId())
}
if err != nil {
return nil, status.Error(codes.Internal, "查询失败") // 不把内部错误细节暴露给调用方
}
return &userv1.User{Id: u.ID, Name: u.Name, Email: u.Email}, nil
}
// 一元拦截器:记录方法、耗时和状态码
func logging(ctx context.Context, req any, info *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := h(ctx, req)
log.Printf("%s %s %v", info.FullMethod, status.Code(err), time.Since(start))
return resp, err
}
// 客户端:每次调用都带截止时间
func getUser(cli userv1.UserServiceClient, id int64) (*userv1.User, error) {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
u, err := cli.GetUser(ctx, &userv1.GetUserRequest{Id: id})
if status.Code(err) == codes.NotFound {
return nil, ErrUserNotFound
}
return u, err
}
```
可直接运行的完整演示用 Python(不需要单独安装 protoc):[examples/grpc-user/demo.py](examples/grpc-user/demo.py),覆盖一元调用、服务端流、`NOT_FOUND` 和 `DEADLINE_EXCEEDED`。
## 易错点
- **修改或复用字段编号**:老客户端解析出错乱的数据,而且不会报错。
- **客户端不设截止时间**:下游卡住时请求无限等待,连接和 goroutine 堆积。
- **服务端不把 `ctx` 传给下游**:客户端已经放弃了,服务端还在查数据库。
- **所有错误都返回 `Unknown` / `Internal`**:调用方无法区分“参数错”和“服务挂了”,也就无法决定是否重试。
- **每次调用都新建连接**:gRPC 连接(`grpc.ClientConn`)应该复用,它内部会管理 HTTP/2 连接。
- **把 proto 文件复制到各个仓库**:容易不一致,放在统一的仓库或用 Buf 等工具管理版本。
## 面试常问
见 [gRPC 与微服务面试题](interview.md):gRPC 和 REST 的区别、为什么快、四种调用方式、字段编号规则、deadline 传递。
@@ -0,0 +1,101 @@
---
id: backend.grpc-microservices.governance
title: 服务治理:超时、重试、熔断、限流、注册发现、链路追踪、分布式事务
type: lesson
domain: backend
stack: [grpc-microservices]
tags: [超时, 重试, 熔断, 限流, 服务发现, 链路追踪, Saga, 分布式事务]
level: advanced
prerequisites: [backend.grpc-microservices.grpc-proto]
est_hours: 3
related: [backend.grpc-microservices.scenario.monolith-split, backend.mq.reliability, backend.grpc-microservices.interview]
week: [14]
order: 2
---
# 服务治理:超时、重试、熔断、限流、注册发现、链路追踪、分布式事务
## 解释说明
服务拆开之后,一次用户请求会经过多个服务,任何一个慢了或挂了都可能拖垮整条链路(**雪崩**)。服务治理就是防止这种情况、并且出问题时能定位。
| 手段 | 解决什么 | 要点 |
| --- | --- | --- |
| 超时 | 下游卡住时不无限等待 | 每次调用都设;越往下游超时越短,总和不超过入口超时 |
| 重试 | 偶发失败 | 只重试幂等操作和可重试错误;指数退避 + 抖动;限制次数;**不要每一层都重试**(会放大流量) |
| 熔断 | 下游已经故障时快速失败,给它恢复时间 | 关闭 → 打开(直接失败)→ 半开(试探)→ 关闭 |
| 限流 | 保护自己不被打垮 | 令牌桶 / 滑动窗口;超出返回 429 / `ResourceExhausted` |
| 降级 | 非核心功能出问题时保证核心可用 | 推荐服务挂了就返回默认列表 |
| 注册发现 | 服务实例动态变化时找到地址 | Consul、etcd、Nacos;Kubernetes 里直接用 Service 的 DNS |
| 负载均衡 | 请求分给多个实例 | gRPC 是长连接,需要客户端负载均衡或服务网格,否则请求会集中在一个实例 |
| 链路追踪 | 定位慢在哪个服务 | 用 OpenTelemetry 在请求中传递 trace ID,每个服务记录自己的 span |
**分布式事务**:一个业务操作跨多个服务的数据库。
- **2PC(两阶段提交)**:强一致,但协调者单点、锁持有时间长,互联网业务很少用。
- **TCC**:每个服务实现 Try(预留资源)/ Confirm / Cancel,业务侵入大。
- **Saga**:拆成一串本地事务,每一步有对应的补偿操作;失败时逆序执行补偿。
- **可靠消息 + 最终一致**:发件箱 + MQ + 幂等消费(见[消息可靠性](../mq/03-reliability.md)),大部分业务的首选,再加定时对账。
## 案例
订单服务调用库存服务和优惠券服务:
- 入口超时 2 秒;调用库存 500ms、优惠券 300ms;
- 优惠券服务故障时熔断,下单时提示“优惠券暂不可用”,仍然允许原价下单(降级);
- 下单跨“订单、库存、优惠券”三个数据库:用 Saga——创建订单(待确认)→ 扣库存 → 核销优惠券,任一步失败就逆序补偿(释放库存、取消订单)。
## 代码示例
完整代码和测试见 [examples/resilience](examples/resilience/)(Go 标准库实现)。重试的核心:
```go
func Retry(ctx context.Context, attempts int, base time.Duration, fn func(context.Context) error) error {
var err error
for i := range attempts {
if err = fn(ctx); err == nil {
return nil
}
var p Permanent
if errors.As(err, &p) || errors.Is(err, ErrOpen) || i == attempts-1 {
return err // 不可重试的错误、熔断中、最后一次:直接返回
}
backoff := base << i // 指数退避
backoff += time.Duration(rand.Int64N(int64(backoff)/2 + 1)) // 抖动
select {
case <-ctx.Done():
return errors.Join(err, ctx.Err()) // 截止时间到了就不再重试
case <-time.After(backoff):
}
}
return err
}
```
组合使用:先熔断后重试(熔断打开时 `ErrOpen` 不再重试),外层由 `context.WithTimeout` 控制总时长:
```go
ctx, cancel := context.WithTimeout(parent, 500*time.Millisecond)
defer cancel()
err := resilience.Retry(ctx, 3, 50*time.Millisecond, func(ctx context.Context) error {
return breaker.Do(ctx, func(ctx context.Context) error {
_, err := inventoryClient.Reserve(ctx, req)
return err
})
})
```
生产环境通常不自己写,而是用 gRPC 自带的重试配置(service config)、服务网格(Istio 等)或成熟的库;自己实现一遍是为了理解原理和面试。
## 易错点
- **每一层都重试 3 次**:三层调用会放大成 27 倍请求,雪上加霜。在一个地方重试即可。
- **重试非幂等操作**:扣款、下单重复执行。要么不重试,要么带幂等键。
- **下游超时比上游长**:上游已经超时返回,下游还在干活,白白浪费资源。
- **熔断把业务错误算作失败**:用户输错参数也能把熔断器打开。
- **gRPC 长连接 + Kubernetes Service 默认负载均衡**:连接建立后请求都落到同一个 Pod。用客户端负载均衡(headless Service + `round_robin`)或服务网格。
- **没有 trace ID**:出了问题只能在多个服务的日志里按时间猜。
## 面试常问
见 [gRPC 与微服务面试题](interview.md):服务雪崩怎么防、熔断器状态机、重试的注意事项、分布式事务方案对比。
@@ -0,0 +1,81 @@
# requires: grpc, grpc_tools
"""gRPC 演示:从 user.proto 生成代码 → 启动服务端 → 客户端调用(一元、服务端流、超时、错误码)。
运行:pip install grpcio grpcio-tools && python demo.py
生成的代码放在临时目录,不污染仓库。
"""
import importlib
import sys
import tempfile
import time
from concurrent import futures
from pathlib import Path
import grpc
from grpc_tools import protoc
HERE = Path(__file__).resolve().parent
def generate(out: Path):
rc = protoc.main(["protoc", f"-I{HERE}", f"--python_out={out}", f"--grpc_python_out={out}", str(HERE / "user.proto")])
assert rc == 0, "protoc 生成失败"
sys.path.insert(0, str(out))
return importlib.import_module("user_pb2"), importlib.import_module("user_pb2_grpc")
def main() -> None:
with tempfile.TemporaryDirectory() as tmp:
pb, pb_grpc = generate(Path(tmp))
users = {i: pb.User(id=i, name=f"用户{i}", email=f"u{i}@example.com") for i in range(1, 6)}
class UserService(pb_grpc.UserServiceServicer):
def GetUser(self, request, context):
if request.id == 99:
time.sleep(0.5) # 模拟慢请求
user = users.get(request.id)
if user is None:
context.abort(grpc.StatusCode.NOT_FOUND, f"用户 {request.id} 不存在")
return user
def ListUsers(self, request, context):
for i, u in enumerate(users.values()):
if i >= request.page_size:
break
yield u
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
pb_grpc.add_UserServiceServicer_to_server(UserService(), server)
port = server.add_insecure_port("127.0.0.1:0") # 0 = 随机可用端口
server.start()
try:
with grpc.insecure_channel(f"127.0.0.1:{port}") as ch:
stub = pb_grpc.UserServiceStub(ch)
u = stub.GetUser(pb.GetUserRequest(id=1), timeout=1.0) # 每次调用都带超时
print("一元调用:", u.id, u.name)
assert u.name == "用户1"
names = [x.name for x in stub.ListUsers(pb.ListUsersRequest(page_size=3), timeout=1.0)]
print("服务端流:", names)
assert len(names) == 3
try:
stub.GetUser(pb.GetUserRequest(id=42), timeout=1.0)
raise AssertionError("应该返回 NOT_FOUND")
except grpc.RpcError as e:
print("错误码:", e.code().name, e.details())
assert e.code() == grpc.StatusCode.NOT_FOUND
try:
stub.GetUser(pb.GetUserRequest(id=99), timeout=0.1)
raise AssertionError("应该超时")
except grpc.RpcError as e:
print("超时:", e.code().name)
assert e.code() == grpc.StatusCode.DEADLINE_EXCEEDED
finally:
server.stop(grace=None)
print("gRPC 演示通过")
if __name__ == "__main__":
main()
@@ -0,0 +1,26 @@
syntax = "proto3";
package user.v1;
// 用户服务:一个一元调用 + 一个服务端流
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (stream User);
}
message GetUserRequest {
int64 id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
// 字段编号一旦发布就不能修改或复用;删除字段时用 reserved
reserved 4;
reserved "phone";
}
@@ -0,0 +1,3 @@
module example/resilience
go 1.22
@@ -0,0 +1,119 @@
// Package resilience 服务调用的三件套:超时(由调用方的 context 控制)、带退避的重试、熔断器。只用标准库。
package resilience
import (
"context"
"errors"
"math/rand/v2"
"sync"
"time"
)
// ErrOpen 熔断器打开时直接返回,不再调用下游
var ErrOpen = errors.New("熔断中:下游暂时不可用")
// Permanent 包装不应该重试的错误(参数错误、没有权限、资源不存在等)
type Permanent struct{ Err error }
func (p Permanent) Error() string { return p.Err.Error() }
func (p Permanent) Unwrap() error { return p.Err }
// Retry 最多尝试 attempts 次,间隔指数增长并加随机抖动;context 取消或超时立即停止
func Retry(ctx context.Context, attempts int, base time.Duration, fn func(context.Context) error) error {
var err error
for i := range attempts {
if err = fn(ctx); err == nil {
return nil
}
var p Permanent
if errors.As(err, &p) || errors.Is(err, ErrOpen) || i == attempts-1 {
return err
}
backoff := base << i // base, 2*base, 4*base ...
backoff += time.Duration(rand.Int64N(int64(backoff)/2 + 1)) // 抖动,避免所有客户端同时重试
select {
case <-ctx.Done():
return errors.Join(err, ctx.Err())
case <-time.After(backoff):
}
}
return err
}
type state int
const (
closed state = iota // 正常放行
open // 熔断:直接拒绝
halfOpen // 试探:放一个请求过去
)
// Breaker 连续失败 threshold 次后打开,cooldown 之后进入半开,试探成功则关闭、失败则重新打开
type Breaker struct {
mu sync.Mutex
state state
failures int
threshold int
cooldown time.Duration
openedAt time.Time
probing bool
now func() time.Time // 测试时可以替换
}
func NewBreaker(threshold int, cooldown time.Duration) *Breaker {
return &Breaker{threshold: threshold, cooldown: cooldown, now: time.Now}
}
func (b *Breaker) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case open:
if b.now().Sub(b.openedAt) < b.cooldown {
return false
}
b.state = halfOpen
fallthrough
case halfOpen:
if b.probing {
return false // 半开状态只放一个试探请求
}
b.probing = true
return true
}
return true
}
func (b *Breaker) record(err error) {
b.mu.Lock()
defer b.mu.Unlock()
var p Permanent
failed := err != nil && !errors.As(err, &p) // 业务错误不算下游故障
if b.state == halfOpen {
b.probing = false
if failed {
b.state, b.openedAt = open, b.now()
} else {
b.state, b.failures = closed, 0
}
return
}
if !failed {
b.failures = 0
return
}
b.failures++
if b.failures >= b.threshold {
b.state, b.openedAt = open, b.now()
}
}
// Do 通过熔断器调用 fn
func (b *Breaker) Do(ctx context.Context, fn func(context.Context) error) error {
if !b.allow() {
return ErrOpen
}
err := fn(ctx)
b.record(err)
return err
}
@@ -0,0 +1,82 @@
package resilience
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
var errDown = errors.New("下游 503")
func TestRetrySucceedsAfterTransientErrors(t *testing.T) {
var calls atomic.Int32
err := Retry(context.Background(), 5, time.Millisecond, func(context.Context) error {
if calls.Add(1) < 3 {
return errDown
}
return nil
})
if err != nil || calls.Load() != 3 {
t.Fatalf("err=%v calls=%d", err, calls.Load())
}
}
func TestRetryStopsOnPermanent(t *testing.T) {
var calls int
err := Retry(context.Background(), 5, time.Millisecond, func(context.Context) error {
calls++
return Permanent{errors.New("参数错误")}
})
if calls != 1 || err == nil {
t.Fatalf("不可重试的错误只应调用一次,calls=%d", calls)
}
}
func TestRetryRespectsDeadline(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
start := time.Now()
err := Retry(ctx, 10, 20*time.Millisecond, func(context.Context) error { return errDown })
if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 200*time.Millisecond {
t.Fatalf("应该在截止时间后停止:%v,耗时 %v", err, time.Since(start))
}
}
func TestBreaker(t *testing.T) {
now := time.Unix(0, 0)
b := NewBreaker(3, 10*time.Second)
b.now = func() time.Time { return now }
ctx := context.Background()
fail := func(context.Context) error { return errDown }
ok := func(context.Context) error { return nil }
for range 3 {
_ = b.Do(ctx, fail)
}
if err := b.Do(ctx, ok); !errors.Is(err, ErrOpen) {
t.Fatalf("连续失败 3 次后应该熔断,得到 %v", err)
}
now = now.Add(11 * time.Second) // 冷却结束 → 半开,试探失败 → 重新打开
if err := b.Do(ctx, fail); !errors.Is(err, errDown) {
t.Fatalf("半开时应放行试探请求:%v", err)
}
if err := b.Do(ctx, ok); !errors.Is(err, ErrOpen) {
t.Fatal("试探失败后应该重新熔断")
}
now = now.Add(11 * time.Second) // 再次半开,试探成功 → 关闭
if err := b.Do(ctx, ok); err != nil {
t.Fatal(err)
}
if err := b.Do(ctx, ok); err != nil {
t.Fatal("恢复后应该正常放行")
}
// 业务错误不计入熔断
for range 5 {
_ = b.Do(ctx, func(context.Context) error { return Permanent{errors.New("用户不存在")} })
}
if err := b.Do(ctx, ok); err != nil {
t.Fatal("业务错误不应该触发熔断")
}
}
+31 -3
View File
@@ -4,12 +4,40 @@ title: gRPC 与微服务
type: index
domain: backend
stack: [grpc-microservices]
tags: []
tags: [gRPC, Protobuf, 微服务, 服务治理]
week: [14]
---
# gRPC 与微服务
proto、四种调用方式、超时重试、服务治理、分布式事务。
Go 岗位的 JD 里经常出现“熟悉 gRPC / 微服务架构”。这个专题讲清楚三件事:gRPC 和 Protobuf 怎么用;服务之间调用要做哪些治理(超时、重试、熔断、限流、注册发现、链路追踪);什么时候拆服务、怎么拆。转岗求职阶段的项目不需要真的拆成很多服务,但要能讲清楚取舍。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [gRPC 和 Protobuf:IDL、四种调用方式、错误码、超时、拦截器](01-grpc-proto.md) | 进阶 | 3 小时 |
| 2 | [服务治理:超时、重试、熔断、限流、注册发现、链路追踪、分布式事务](02-governance.md) | 进阶 | 3 小时 |
## 企业场景
- [单体拆分:从单体到“订单 + 用户”两个服务](scenarios/monolith-split.md)
## 面试题
- [gRPC 与微服务面试题](interview.md)
## 示例代码
- [examples/grpc-user](examples/grpc-user/):Python 从 `user.proto` 生成代码,演示一元调用、服务端流、错误码和超时(需要 `grpcio`、`grpcio-tools`)
- [examples/resilience](examples/resilience/):Go 标准库实现带退避的重试和熔断器,附测试
## 在 16 周计划中
- [第 14 周:后端扩展与部署](../../../plan/week14-backend-extend-deploy.md)
## 官方资料
- gRPC 文档(Go / Python 快速开始):https://grpc.io/docs/
- Protobuf 语言指南:https://protobuf.dev/programming-guides/proto3/
- OpenTelemetry:https://opentelemetry.io/docs/
@@ -0,0 +1,63 @@
---
id: backend.grpc-microservices.interview
title: gRPC 与微服务面试题
type: interview
domain: backend
stack: [grpc-microservices]
tags: [gRPC, 微服务, 服务治理, 分布式事务, 面试]
level: advanced
related: [backend.grpc-microservices.grpc-proto, backend.grpc-microservices.governance]
week: [14, 16]
---
# gRPC 与微服务面试题
## 基础
### 【基础】gRPC 和 REST 有什么区别?
**参考答案**:gRPC 基于 HTTP/2,用 Protobuf 二进制序列化,有 IDL 和代码生成,强类型契约,支持流式调用;REST 通常基于 HTTP/1.1 + JSON,可读性好、浏览器原生支持、生态通用。内部服务之间、追求性能和契约时用 gRPC;对外开放、前端直接调用用 REST。
### 【基础】gRPC 有哪几种调用方式?
**参考答案**:一元调用(一请求一响应)、服务端流(一请求多响应,如推送进度)、客户端流(多请求一响应,如分片上传)、双向流(如聊天)。
### 【基础】Protobuf 字段编号有什么规则?
**参考答案**:编号决定序列化后的字段标识,发布后不能修改、不能复用;删除字段用 `reserved` 保留编号和名字;新增字段使用新编号,对老客户端兼容(老客户端会忽略未知字段)。1~15 编码只占一个字节,留给高频字段。
### 【基础】微服务相比单体有什么优缺点?
**参考答案**:优点是独立部署、独立扩容、技术栈可以不同、故障隔离、团队可以独立开发。缺点是网络调用带来的延迟和失败、分布式事务和数据一致性、运维和监控复杂度、调试和排查困难。团队小、业务早期通常模块化单体更合适。
## 进阶
### 【进阶】什么是服务雪崩?怎么防止?
**参考答案**:一个下游服务变慢,调用方的线程 / 连接被占住,进而调用方也变慢,最终一层层传导导致整个系统不可用。防止:所有调用设超时;熔断故障的下游;限流保护自己;降级非核心功能;隔离资源(不同下游用不同的连接池 / 并发限制);重试要有限次数并退避。
### 【进阶】讲一下熔断器的状态机。
**参考答案**:关闭状态正常放行并统计失败;失败达到阈值(次数或比例)后打开,直接返回失败;冷却时间后进入半开,放少量请求试探;试探成功则关闭,失败则重新打开。只把下游故障(超时、不可用)计为失败,业务错误不算。
### 【进阶】重试要注意什么?
**参考答案**:只重试可重试的错误(如 `Unavailable`)和幂等操作;指数退避加随机抖动;限制次数,并受整体截止时间约束;只在一层做重试,避免逐层放大;熔断打开时不重试;非幂等操作需要幂等键。
### 【进阶】分布式事务有哪些方案?怎么选?
**参考答案**:2PC 强一致但性能差、协调者单点,很少用于互联网业务;TCC 通过 Try/Confirm/Cancel 预留资源,一致性较好但业务侵入大;Saga 把长事务拆成本地事务 + 补偿,适合流程长的业务;可靠消息(发件箱 + MQ + 幂等)实现最终一致,最常用。一般优先“可靠消息 + 对账”,对资金等强一致场景再考虑 TCC。
## 场景题
### 【场景题】订单服务调用库存服务,库存服务偶尔超时,应该怎么处理?
**参考答案**:设置合理的超时(小于订单接口自身的超时);扣库存接口要求幂等(带订单号作为幂等键),这样超时后可以安全重试一两次;超时结果不确定时,不能直接认为失败——通过查询接口或后续对账确认;持续超时时熔断,下单接口返回“系统繁忙”;排查库存服务本身的慢查询或资源问题。
### 【场景题】团队想把单体系统改成微服务,你怎么推进?
**参考答案**:先明确要解决的问题,能用模块化、读写分离、缓存解决的不急于拆;选边界清晰、需求明确的模块先拆;用绞杀者模式:先在单体内抽接口,再实现远程服务,用开关灰度切流,随时可回退;数据分库通过双写或 CDC 迁移并校验;同时建设超时熔断、链路追踪、集中日志等基础设施。详见[单体拆分场景](scenarios/monolith-split.md)。
### 【场景题】一个请求经过 5 个服务,用户反馈偶尔很慢,怎么定位?
**参考答案**:靠链路追踪:入口生成 trace ID,各服务通过 OpenTelemetry 记录 span 并传递上下文,在追踪系统里按耗时筛选慢请求,看是哪一个 span 慢;再结合该服务的日志(带 trace ID)和指标(CPU、GC、连接池、数据库慢查询)找原因。没有追踪系统时,至少要在日志里统一打印请求 ID。
@@ -0,0 +1,113 @@
---
id: backend.grpc-microservices.scenario.monolith-split
title: 单体拆分:从单体到“订单 + 用户”两个服务
type: scenario
domain: backend
stack: [grpc-microservices, go, mq]
tags: [单体拆分, 绞杀者模式, 数据拆分, gRPC, 灰度]
level: advanced
prerequisites: [backend.grpc-microservices.governance]
est_hours: 2
related: [backend.grpc-microservices.interview, backend.mq.reliability]
week: [14]
---
# 单体拆分:从单体到“订单 + 用户”两个服务
## 业务背景与问题
一个 Go 单体后台(用户、商品、订单、报表都在一个进程、一个数据库),团队从 3 人增加到 10 人后:多人改同一个仓库冲突多;报表的重查询拖慢下单;一个模块的 bug 导致整个服务重启。团队讨论“要不要全面改微服务”。
## 约束
- 业务不能停,拆分过程中要能随时回退。
- 运维人力有限,没有专职的平台团队。
- 用户数据被订单、报表等多处直接 JOIN 查询。
## 方案设计
1. **先判断是否真的需要拆**:很多问题不需要微服务就能解决——报表查询拖慢下单 → 报表走只读从库;代码冲突 → 按模块划分目录和负责人(模块化单体)。只有“独立部署、独立扩容、故障隔离”的需求明确的部分才拆出来。
2. **选第一个拆分对象**:边界清晰、被依赖多、变化少的模块——用户服务(登录、资料)。
3. **绞杀者模式(Strangler Fig)逐步迁移**:
- 第一步:在单体内部把用户模块整理成接口(`UserService`),其他模块只通过接口访问,不再直接 JOIN 用户表;
- 第二步:新建用户服务(gRPC),实现同样的接口;单体里的接口实现改为调用 gRPC 客户端,用开关控制走本地还是远程,灰度切换;
- 第三步:数据拆分——用户表迁到用户服务的独立数据库;迁移期间双写或用 CDC 同步,校验一致后切读、再切写;
- 第四步:订单需要的用户字段(如昵称)在订单里冗余一份或通过事件同步,避免每次查订单都远程调用。
4. **配套治理**:调用超时和熔断、统一 trace ID、错误码映射、接口契约(proto)放在独立仓库管理。
```mermaid
flowchart LR
subgraph 单体
O[订单模块] --> I{UserService 接口}
I -->|开关=本地| L[本地实现]
I -->|开关=远程| G[gRPC 客户端]
end
G -->|超时 + 熔断| U[用户服务] --> UDB[(用户库)]
L --> MDB[(原单体库)]
```
## 关键代码
接口先行,单体里的调用方不关心实现在本地还是远程:
```go
type UserService interface {
GetUser(ctx context.Context, id int64) (User, error)
}
// 远程实现:gRPC 客户端 + 超时 + 错误码映射
type remoteUsers struct{ cli userv1.UserServiceClient }
func (r remoteUsers) GetUser(ctx context.Context, id int64) (User, error) {
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
u, err := r.cli.GetUser(ctx, &userv1.GetUserRequest{Id: id})
switch status.Code(err) {
case codes.OK:
return User{ID: u.GetId(), Name: u.GetName()}, nil
case codes.NotFound:
return User{}, ErrUserNotFound
default:
return User{}, fmt.Errorf("用户服务调用失败:%w", err)
}
}
// 灰度开关:按比例把流量切到远程实现,出问题可以立即切回
type switchUsers struct {
local, remote UserService
percent func() int // 从配置中心读取 0~100
}
func (s switchUsers) GetUser(ctx context.Context, id int64) (User, error) {
if int(id%100) < s.percent() {
return s.remote.GetUser(ctx, id)
}
return s.local.GetUser(ctx, id)
}
```
gRPC 调用、错误码和超时的可运行演示见 [examples/grpc-user](../examples/grpc-user/);超时 / 重试 / 熔断见 [examples/resilience](../examples/resilience/)。
## 取舍
| 方案 | 适合 | 代价 |
| --- | --- | --- |
| 保持单体 + 模块化 | 团队小、业务变化快 | 部署和扩容不能独立 |
| **按需拆出少数服务(本方案)** | 有明确的独立部署 / 扩容 / 隔离需求 | 引入网络调用、分布式数据一致性、更多运维 |
| 一次性全面微服务 | 很少适合 | 风险高、周期长、运维成本陡增 |
## 易错点
- 拆了服务但还共用一个数据库:耦合没有解决,反而多了网络调用。
- 一次性切换、没有开关:出问题无法快速回退。
- 订单列表每行都远程查用户:N+1 远程调用。用批量接口或冗余字段。
- 拆分后跨服务事务直接用分布式锁硬凑:应该用可靠消息 / Saga 做最终一致。
- 拆得太细(每张表一个服务):调用链变长,问题更多。
## 面试怎么讲
> “我们没有一上来就全面微服务。先在单体里把用户模块抽成接口,让其他模块只通过接口访问;再把用户服务独立出来用 gRPC 提供,单体里加了按比例切流的开关,出问题可以立即切回;数据用双写加校验迁到独立库,订单里需要的用户昵称通过事件同步冗余。调用都加了超时、熔断和 trace ID。”
转岗求职时如果没有真实的拆分经历,可以讲“我在项目里做过模块化设计,并且了解拆分的步骤和风险”,不要编造经历。
追问准备:怎么判断一个模块该不该拆?数据迁移期间怎么保证一致?服务间调用失败怎么处理?
+102
View File
@@ -0,0 +1,102 @@
---
id: backend.mq.rabbitmq
title: RabbitMQ:交换机、队列、确认机制、死信和延迟
type: lesson
domain: backend
stack: [mq]
tags: [RabbitMQ, AMQP, 交换机, ACK, 死信, 延迟队列]
level: basic
est_hours: 3
related: [backend.mq.reliability, backend.mq.scenario.delayed-order-cancel, backend.mq.interview]
week: [3, 14]
order: 1
---
# RabbitMQ:交换机、队列、确认机制、死信和延迟
## 解释说明
**模型**:生产者 → **交换机(Exchange)** →(按绑定规则路由)→ **队列(Queue)** → 消费者。生产者不直接发给队列,而是发给交换机并带一个 routing key。
| 交换机类型 | 路由规则 | 场景 |
| --- | --- | --- |
| direct | routing key 完全匹配 | 按类型分发任务 |
| topic | 通配符匹配,`*` 一个词、`#` 零个或多个词,如 `order.*.created` | 按业务域订阅事件 |
| fanout | 忽略 routing key,发给所有绑定的队列 | 广播 |
| headers | 按消息头匹配 | 少用 |
还有一个“默认交换机”:routing key 等于队列名时直接投递到该队列。
**可靠性三件套**
- **持久化**:交换机、队列声明为 durable,消息设置 `delivery_mode=2`(持久化),重启后不丢。
- **生产者确认(publisher confirm)**:broker 收到并处理好消息后回 ack,生产者才认为发送成功。
- **消费者手动确认(manual ack)**:处理完成后再 ack;处理失败 nack,可以选择重新入队或进入死信。消费者断开时未确认的消息会重新投递,所以消费要幂等。
**prefetch(QoS)**:每个消费者最多同时持有多少条未确认消息,防止一个消费者拿太多、其他消费者闲着。
**死信(Dead Letter)**:消息被拒绝且不重新入队、过期(TTL)、队列满时,会被转到队列配置的死信交换机。用途:失败消息集中处理、延迟消息。
**延迟消息**的两种做法:TTL + 死信(消息在没有消费者的等待队列中过期,转到处理队列);或者安装官方社区的延迟消息插件。注意:消息级 TTL 只在消息到达队头时检查,不同 TTL 的消息混在一个队列里会互相阻塞,所以**一个等待队列只放同一种延迟时间**。
**队列类型**:RabbitMQ 4.x 推荐需要高可用和数据安全的队列使用 **quorum 队列**(基于 Raft 复制),经典队列的镜像功能已移除。具体特性以官方文档为准:https://www.rabbitmq.com/docs/quorum-queues
## 案例
- 用户注册后发欢迎邮件、送新人券:注册服务发 `user.registered` 到 topic 交换机,邮件服务和营销服务各自建队列绑定,互不影响。
- 订单 30 分钟未支付自动取消:见[延迟取消场景](scenarios/delayed-order-cancel.md)。
## 代码示例
Python + aio-pika,生产者带确认、消费者手动确认,失败的消息进死信队列(示意代码,完整可运行示例见 [examples/delay-cancel](examples/delay-cancel/)):
```python
import asyncio
import json
import aio_pika
async def main() -> None:
conn = await aio_pika.connect_robust("amqp://guest:guest@localhost/")
async with conn:
ch = await conn.channel(publisher_confirms=True) # 默认就是开启的;publish 会等待 broker 确认
await ch.set_qos(prefetch_count=20)
events = await ch.declare_exchange("events", aio_pika.ExchangeType.TOPIC, durable=True)
dlx = await ch.declare_exchange("events.dlx", aio_pika.ExchangeType.FANOUT, durable=True)
dead = await ch.declare_queue("email.dead", durable=True)
await dead.bind(dlx)
q = await ch.declare_queue("email.welcome", durable=True,
arguments={"x-dead-letter-exchange": "events.dlx", "x-queue-type": "quorum"})
await q.bind(events, routing_key="user.registered")
await events.publish(
aio_pika.Message(json.dumps({"user_id": 1}).encode(), delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
message_id="user-1-registered"),
routing_key="user.registered")
async with q.iterator() as it:
async for msg in it:
# requeue=False:处理失败不重新入队,直接进死信队列,避免无限重试
async with msg.process(requeue=False):
data = json.loads(msg.body)
print("发送欢迎邮件给用户", data["user_id"])
break
asyncio.run(main())
```
## 易错点
- **自动 ack**:消息一到就确认,消费者处理到一半崩溃,消息就丢了。
- **失败消息无限重新入队**:一条有问题的消息反复失败,堵住队列、刷爆日志。设置重试次数,超过进死信。
- **以为开启持久化就不会丢**:还需要生产者确认;单节点磁盘故障仍会丢,需要 quorum 队列。
- **一个等待队列放不同 TTL 的消息**:后面短 TTL 的消息被前面长 TTL 的挡住。
- **队列声明参数不一致**:同名队列用不同参数重复声明会报错(PRECONDITION_FAILED),要删除重建或换名。
- **prefetch 设成无限**:一个消费者囤积大量消息,内存暴涨、负载不均。
## 面试常问
见[消息队列面试题](interview.md):交换机类型、怎么保证消息不丢、死信队列、延迟消息实现。
+99
View File
@@ -0,0 +1,99 @@
---
id: backend.mq.kafka
title: Kafka:主题、分区、消费组、位移和顺序
type: lesson
domain: backend
stack: [mq]
tags: [Kafka, 分区, 消费组, offset, 顺序消息, ISR]
level: intermediate
prerequisites: [backend.mq.rabbitmq]
est_hours: 3
related: [backend.mq.reliability, backend.mq.interview]
week: [14]
order: 2
---
# Kafka:主题、分区、消费组、位移和顺序
## 解释说明
**模型**:Kafka 是一个**分布式的追加日志**。消息写入**主题(Topic)**,主题分成多个**分区(Partition)**,每个分区是一个只追加的有序日志,消息有递增的**位移(offset)**。消息被消费后**不会删除**,按保留时间或大小清理,所以可以回溯重新消费。
- **生产者**:消息带 key 时,同一个 key 总是进入同一个分区(按 key 哈希);没有 key 时分散写入。
- **消费组(Consumer Group)**:同一个组里,一个分区同一时间只分配给一个消费者;不同组各自独立消费全部消息。所以**消费者数量超过分区数时,多出来的消费者会空闲**——分区数决定了组内并行度上限。
- **位移提交**:消费者记录“处理到哪里了”。先处理再提交 = 至少一次(可能重复);先提交再处理 = 至多一次(可能丢)。业务一般选至少一次 + 幂等消费。
- **副本**:每个分区有多个副本,一个 leader 负责读写,follower 同步。和 leader 保持同步的副本集合叫 **ISR**。
- **生产者 acks**:`acks=0` 不等确认;`acks=1` leader 写入即确认;`acks=all` ISR 都写入才确认(配合 `min.insync.replicas` 保证不丢)。
- **顺序**:只保证**单个分区内**有序。需要“同一订单的消息有序”时,用订单 ID 作为 key。
**和 RabbitMQ 怎么选**
| | RabbitMQ | Kafka |
| --- | --- | --- |
| 模型 | 消息被消费确认后删除 | 日志保留,可回溯 |
| 优势 | 路由灵活、延迟 / 死信 / 优先级等功能多、单条消息时延低 | 吞吐量高、可回放、适合流处理 |
| 典型场景 | 业务异步任务、订单流程 | 日志采集、埋点、数据同步、事件流 |
新版本 Kafka 已用 KRaft 取代 ZooKeeper 管理元数据(Kafka 4.0 起移除 ZooKeeper),部署细节以官方文档为准:https://kafka.apache.org/documentation/
## 案例
- 埋点数据:前端事件写入 `events` 主题(几十个分区),实时计算和离线入库两个消费组各自消费。
- 订单状态变化事件:用订单 ID 作为 key,保证同一订单的“创建 → 支付 → 发货”按顺序被消费。
- 消费者 bug 导致数据处理错误:修复后把消费组位移重置到出问题前的时间点,重新消费。
## 代码示例
Python 客户端(`confluent-kafka`,示意代码,需要运行中的 Kafka;客户端参数以官方文档为准:https://docs.confluent.io/kafka-clients/python/current/overview.html ):
```python
import json
from confluent_kafka import Consumer, Producer
producer = Producer({"bootstrap.servers": "localhost:9092", "acks": "all", "enable.idempotence": True})
def on_delivery(err, msg):
if err is not None:
print("发送失败:", err) # 这里要记录并重试或告警
for status in ("created", "paid", "shipped"):
# 同一订单用同一个 key → 进入同一分区 → 有序
producer.produce("order-events", key="order-1001", value=json.dumps({"status": status}), callback=on_delivery)
producer.flush()
consumer = Consumer({
"bootstrap.servers": "localhost:9092",
"group.id": "points-service",
"auto.offset.reset": "earliest",
"enable.auto.commit": False, # 处理成功后手动提交,实现“至少一次”
})
consumer.subscribe(["order-events"])
try:
while True:
msg = consumer.poll(1.0)
if msg is None:
continue
if msg.error():
print("消费错误:", msg.error())
continue
event = json.loads(msg.value())
print(msg.key(), msg.partition(), msg.offset(), event) # 业务处理要幂等
consumer.commit(message=msg, asynchronous=False)
finally:
consumer.close()
```
## 易错点
- **消费者比分区多**:多出来的消费者空闲,加机器也提不了速度;要先加分区(分区只能增加不能减少,增加后 key 的分布会变,影响顺序)。
- **自动提交位移**:消息还没处理完就提交,崩溃后丢消息;或者处理完还没提交就崩溃,重复消费。业务里建议手动提交 + 幂等。
- **以为整个主题有序**:只有分区内有序。
- **单条消息处理太慢**:超过 `max.poll.interval.ms` 会被踢出消费组并触发再均衡,导致重复消费。
- **`acks=1` 又要求不丢**:leader 宕机且 follower 未同步时会丢;要 `acks=all` + `min.insync.replicas>=2` + 副本数 >=3。
## 面试常问
见[消息队列面试题](interview.md):Kafka 为什么吞吐高、分区和消费组的关系、怎么保证顺序、怎么不丢消息、和 RabbitMQ 的区别。
+105
View File
@@ -0,0 +1,105 @@
---
id: backend.mq.reliability
title: 可靠性:不丢消息、幂等消费、发件箱模式、积压处理
type: lesson
domain: backend
stack: [mq]
tags: [可靠投递, 幂等, 发件箱, Outbox, 消息积压, 最终一致性]
level: intermediate
prerequisites: [backend.mq.rabbitmq]
est_hours: 3
related: [backend.mq.kafka, backend.mq.scenario.delayed-order-cancel, backend.mq.interview, backend.postgresql.scenario.order-state-audit]
week: [3, 14]
order: 3
---
# 可靠性:不丢消息、幂等消费、发件箱模式、积压处理
## 解释说明
**消息可能在三个环节丢失**
| 环节 | 风险 | 措施 |
| --- | --- | --- |
| 生产者 → broker | 网络失败、broker 没收到 | 生产者确认(RabbitMQ confirm / Kafka `acks=all`)+ 失败重试 |
| broker 存储 | 宕机、磁盘故障 | 持久化 + 多副本(quorum 队列 / Kafka 副本) |
| broker → 消费者 | 消费者处理到一半崩溃 | 处理完成后再 ack / 提交位移 |
做到这些后,语义是**至少一次(at least once)**:不丢,但可能重复。所以**消费者必须幂等**。
**幂等消费的做法**
- 业务天然幂等:`UPDATE orders SET status='cancelled' WHERE id=? AND status='pending'`,重复执行没有副作用。
- 去重表:消息带唯一 `message_id`,消费时在**同一个事务里**插入 `processed_messages(message_id)`(唯一约束)并执行业务,插入冲突说明处理过了。
- 唯一业务键:如“同一个订单只发一次积分”,在积分流水表上建 `(order_id, type)` 唯一约束。
**发件箱模式(Transactional Outbox)**:解决“数据库写成功了但消息没发出去”(或反过来)。
1. 业务数据和一条“待发送消息”写在**同一个数据库事务**里(消息写 `outbox` 表)。
2. 后台程序轮询 `outbox` 中未发送的消息,发送到 MQ,成功后标记已发送。
3. 发送成功但标记失败会导致重发——还是至少一次,消费者幂等即可。
也可以用 CDC(订阅数据库 binlog / WAL,例如 Debezium)把 outbox 表的变化推送到 MQ,省掉轮询。
**消息积压**:消费速度跟不上生产速度。
- 先止血:临时增加消费者(Kafka 受分区数限制)、提高并发、批量处理;
- 查原因:消费者是不是卡在慢的下游(数据库、外部接口)、是否在反复重试失败消息;
- 应急:把积压的消息转存到新的、分区更多的临时主题,用更多消费者处理;非关键消息可以降级丢弃或延后。
## 案例
下单后要给用户加积分、发通知:
- 订单和 `order.created` 消息在同一事务写入(发件箱),保证“有订单就一定有消息”;
- 积分服务按 `message_id` 去重,重复消息不会重复加积分;
- 处理失败超过 3 次进入死信队列,告警后人工处理。
## 代码示例
完整可运行示例(只用标准库):[examples/outbox/outbox_demo.py](examples/outbox/outbox_demo.py)。核心片段:
```python
def create_order(db, user_id: int, amount: int) -> int:
with db: # 一个事务:订单和消息要么都写入,要么都不写入
cur = db.execute("INSERT INTO orders (user_id, amount) VALUES (?, ?)", (user_id, amount))
db.execute("INSERT INTO outbox (topic, payload) VALUES (?, ?)",
("order.created", json.dumps({"order_id": cur.lastrowid, "user_id": user_id, "amount": amount})))
return cur.lastrowid
def relay(db, broker, batch: int = 100) -> int:
"""后台投递:发送成功后再标记;可能重复发送,消费者要幂等。"""
rows = db.execute("SELECT id, topic, payload FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT ?",
(batch,)).fetchall()
for mid, topic, payload in rows:
broker.publish(topic, {"message_id": mid, **json.loads(payload)})
with db:
db.execute("UPDATE outbox SET sent_at = datetime('now') WHERE id = ?", (mid,))
return len(rows)
```
PostgreSQL 版的去重表写法:
```sql
CREATE TABLE processed_messages (
message_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 消费时,在同一个事务里:
-- INSERT INTO processed_messages (message_id) VALUES ($1) ON CONFLICT DO NOTHING;
-- 影响行数为 0 → 处理过了,直接 ack;否则执行业务,然后提交事务、ack
```
## 易错点
- **先发消息再提交事务**:事务回滚了,消息已经发出去,下游处理了一个不存在的订单。
- **先提交事务再发消息、发送失败不处理**:消息丢失。用发件箱。
- **去重记录和业务操作不在同一事务**:业务成功、去重记录没写,重复消息又执行一次。
- **只靠 Redis `SETNX` 去重**:Redis 和数据库不在一个事务里,过期或丢数据后仍会重复,关键业务以数据库唯一约束为准。
- **失败无限重试**:毒消息堵住队列。设置最大重试次数和死信。
- **发件箱表不清理**:定期删除或归档已发送的记录。
## 面试常问
见[消息队列面试题](interview.md):怎么保证消息不丢、怎么保证幂等、怎么保证顺序、积压了怎么办、分布式事务怎么做。
@@ -0,0 +1,84 @@
# requires: aio_pika
"""RabbitMQ 延迟取消订单:消息先进带 TTL 的等待队列,过期后经死信交换机转到处理队列。
运行:AMQP_URL=amqp://guest:guest@localhost/ python delay_cancel.py
没有设置 AMQP_URL 时打印提示并正常退出。会创建并删除名字带随机后缀的交换机和队列。
"""
import asyncio
import json
import os
import sys
import time
import uuid
import aio_pika
DELAY_MS = 1000 # 演示用 1 秒;真实业务常见是 15~30 分钟
class OrderStore:
"""模拟数据库里的订单状态(真实项目里用条件 UPDATE 保证幂等)。"""
def __init__(self) -> None:
self.status: dict[str, str] = {}
def cancel_if_unpaid(self, order_id: str) -> bool:
# 等价于 UPDATE orders SET status='cancelled' WHERE id=? AND status='pending'
if self.status.get(order_id) == "pending":
self.status[order_id] = "cancelled"
return True
return False
async def main() -> int:
url = os.environ.get("AMQP_URL")
if not url:
print("未设置 AMQP_URL,跳过(示例:amqp://guest:guest@localhost/)")
return 0
suffix = uuid.uuid4().hex[:8]
conn = await aio_pika.connect_robust(url)
async with conn:
ch = await conn.channel()
await ch.set_qos(prefetch_count=10)
dlx = await ch.declare_exchange(f"order.dlx.{suffix}", aio_pika.ExchangeType.DIRECT, auto_delete=True)
# 处理队列:真正执行“超时取消”的消费者监听这里
work_q = await ch.declare_queue(f"order.cancel.{suffix}", durable=True, auto_delete=True)
await work_q.bind(dlx, routing_key="cancel")
# 等待队列:没有消费者,消息在这里过期后转到死信交换机
wait_q = await ch.declare_queue(f"order.wait.{suffix}", durable=True, auto_delete=True, arguments={
"x-message-ttl": DELAY_MS,
"x-dead-letter-exchange": dlx.name,
"x-dead-letter-routing-key": "cancel",
})
store = OrderStore()
store.status = {"o1": "pending", "o2": "pending"}
start = time.monotonic()
for oid in ("o1", "o2", "o1"): # o1 故意发两次,模拟重复消息
await ch.default_exchange.publish(
aio_pika.Message(json.dumps({"order_id": oid}).encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT, message_id=f"cancel-{oid}"),
routing_key=wait_q.name)
store.status["o2"] = "paid" # o2 在超时前支付了
results: list[tuple[str, bool]] = []
async with work_q.iterator() as it:
async for msg in it:
async with msg.process(): # 正常结束自动 ack,抛异常则 nack
oid = json.loads(msg.body)["order_id"]
results.append((oid, store.cancel_if_unpaid(oid)))
if len(results) == 3:
break
elapsed = time.monotonic() - start
print("处理结果:", results, f"耗时 {elapsed:.1f}s")
assert elapsed >= DELAY_MS / 1000 * 0.9, "消息应该在延迟之后才被处理"
assert store.status == {"o1": "cancelled", "o2": "paid"}, store.status
assert sorted(results) == [("o1", False), ("o1", True), ("o2", False)] # 重复消息不会重复取消
await wait_q.delete(if_unused=False, if_empty=False)
await work_q.delete(if_unused=False, if_empty=False)
print("延迟取消正确:未支付的被取消,已支付的不受影响,重复消息幂等")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
@@ -0,0 +1,105 @@
"""发件箱(Outbox)模式:业务数据和“待发送消息”写在同一个数据库事务里,由后台程序投递。
只用标准库(sqlite3)演示,直接运行:python outbox_demo.py
真实项目里把 sqlite 换成 MySQL / PostgreSQL,把 FakeBroker 换成 RabbitMQ / Kafka 客户端。
"""
import json
import sqlite3
class FakeBroker:
"""模拟消息队列:fail_times 次之内发送失败,用来演示重试。"""
def __init__(self, fail_times: int = 0) -> None:
self.sent: list[dict] = []
self.fail_times = fail_times
def publish(self, topic: str, msg: dict) -> None:
if self.fail_times > 0:
self.fail_times -= 1
raise ConnectionError("broker 暂时不可用")
self.sent.append({"topic": topic, **msg})
def init(db: sqlite3.Connection) -> None:
db.executescript("""
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, amount INTEGER NOT NULL);
CREATE TABLE outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
payload TEXT NOT NULL,
sent_at TEXT, -- NULL 表示还没发送
attempts INTEGER NOT NULL DEFAULT 0
);
""")
def create_order(db: sqlite3.Connection, user_id: int, amount: int, fail_after_insert: bool = False) -> int:
with db: # 一个事务:订单和消息要么都写入,要么都不写入
cur = db.execute("INSERT INTO orders (user_id, amount) VALUES (?, ?)", (user_id, amount))
order_id = cur.lastrowid
db.execute("INSERT INTO outbox (topic, payload) VALUES (?, ?)",
("order.created", json.dumps({"order_id": order_id, "user_id": user_id, "amount": amount})))
if fail_after_insert:
raise RuntimeError("模拟业务异常:事务回滚")
return order_id
def relay(db: sqlite3.Connection, broker: FakeBroker, batch: int = 100) -> int:
"""后台投递:取未发送的消息,发送成功后标记。可能重复发送(至少一次),消费者要幂等。"""
rows = db.execute("SELECT id, topic, payload FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT ?",
(batch,)).fetchall()
sent = 0
for mid, topic, payload in rows:
try:
broker.publish(topic, {"message_id": mid, **json.loads(payload)})
except ConnectionError:
with db:
db.execute("UPDATE outbox SET attempts = attempts + 1 WHERE id = ?", (mid,))
break # 保持顺序:前面的没发出去,后面的先不发
with db:
db.execute("UPDATE outbox SET sent_at = datetime('now') WHERE id = ?", (mid,))
sent += 1
return sent
class Consumer:
"""幂等消费者:按 message_id 去重(真实项目里用数据库唯一约束记录已处理的消息)。"""
def __init__(self) -> None:
self.processed: set[int] = set()
self.points: dict[int, int] = {}
def handle(self, msg: dict) -> None:
if msg["message_id"] in self.processed:
return
self.points[msg["user_id"]] = self.points.get(msg["user_id"], 0) + msg["amount"] // 100
self.processed.add(msg["message_id"])
def main() -> None:
db = sqlite3.connect(":memory:")
init(db)
create_order(db, user_id=1, amount=19900)
try:
create_order(db, user_id=2, amount=500, fail_after_insert=True)
except RuntimeError:
pass
create_order(db, user_id=1, amount=10000)
assert db.execute("SELECT count(*) FROM orders").fetchone()[0] == 2
assert db.execute("SELECT count(*) FROM outbox").fetchone()[0] == 2 # 回滚的订单没有留下消息
broker = FakeBroker(fail_times=1)
assert relay(db, broker) == 0 # 第一次 broker 不可用
assert relay(db, broker) == 2 # 重试成功
assert relay(db, broker) == 0 # 没有待发送的了
consumer = Consumer()
for m in broker.sent + broker.sent[:1]: # 模拟第一条消息被重复投递
consumer.handle(m)
assert consumer.points == {1: 199 + 100}, consumer.points
print("发件箱:订单和消息同事务写入;投递失败可重试;消费者幂等,重复消息不会重复加积分")
if __name__ == "__main__":
main()
+32 -3
View File
@@ -4,12 +4,41 @@ title: 消息队列(RabbitMQ / Kafka)
type: index
domain: backend
stack: [mq]
tags: []
tags: [消息队列, RabbitMQ, Kafka, 异步, 削峰]
week: [3, 14]
---
# 消息队列(RabbitMQ / Kafka)
异步解耦、可靠投递、幂等消费、顺序、延迟消息。
消息队列用来**异步解耦**(下单后发短信、加积分不阻塞主流程)、**削峰填谷**(抢购请求先进队列)、**广播**(一个事件多个系统消费)。招聘要求里 RabbitMQ 和 Kafka 都常见:业务系统多用 RabbitMQ,日志、埋点、数据管道多用 Kafka。面试最常问的是**可靠性、幂等、顺序、积压**。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [RabbitMQ:交换机、队列、确认机制、死信和延迟](01-rabbitmq.md) | 基础 | 3 小时 |
| 2 | [Kafka:主题、分区、消费组、位移和顺序](02-kafka.md) | 进阶 | 3 小时 |
| 3 | [可靠性:不丢消息、幂等消费、发件箱模式、积压处理](03-reliability.md) | 进阶 | 3 小时 |
## 企业场景
- [订单超时自动取消:延迟消息、幂等和兜底扫描](scenarios/delayed-order-cancel.md)
## 面试题
- [消息队列面试题](interview.md)
## 示例代码
- [examples/delay-cancel](examples/delay-cancel/):Python + aio-pika,用 TTL + 死信交换机实现延迟取消;设置 `AMQP_URL` 后运行
- [examples/outbox](examples/outbox/):只用标准库演示发件箱模式和幂等消费,直接运行
## 在 16 周计划中
- [第 3 周:项目一(异步任务)](../../../plan/week03-project1.md)、[第 14 周:后端扩展](../../../plan/week14-backend-extend-deploy.md)
## 官方资料
- RabbitMQ 文档:https://www.rabbitmq.com/docs
- Kafka 文档:https://kafka.apache.org/documentation/
- aio-pika:https://docs.aio-pika.com/
+63
View File
@@ -0,0 +1,63 @@
---
id: backend.mq.interview
title: 消息队列面试题
type: interview
domain: backend
stack: [mq]
tags: [RabbitMQ, Kafka, 可靠性, 幂等, 面试]
level: intermediate
related: [backend.mq.rabbitmq, backend.mq.kafka, backend.mq.reliability]
week: [14, 16]
---
# 消息队列面试题
## 基础
### 【基础】为什么要用消息队列?会带来什么问题?
**参考答案**:异步(非核心步骤不阻塞主流程,接口更快)、解耦(新增下游不用改上游)、削峰(突发流量先进队列,按能力消费)。问题:系统更复杂,多了一个要维护的组件;消息可能重复、丢失、乱序,要处理;数据变成最终一致;排查链路变长。
### 【基础】RabbitMQ 有哪几种交换机?
**参考答案**:direct(routing key 完全匹配)、topic(通配符匹配,`*` 匹配一个词、`#` 匹配零个或多个词)、fanout(广播给所有绑定队列)、headers(按消息头匹配)。
### 【基础】什么是死信队列?
**参考答案**:消息被拒绝且不重新入队、TTL 过期、队列达到长度上限时,会被转发到队列配置的死信交换机,再路由到死信队列。用于集中处理失败消息,也可以和 TTL 组合实现延迟消息。
### 【基础】Kafka 的分区和消费组是什么关系?
**参考答案**:主题分成多个分区;同一个消费组里,一个分区同一时间只能被一个消费者消费,一个消费者可以消费多个分区;不同消费组互不影响、各自消费全部消息。所以组内并行度上限是分区数。
## 进阶
### 【进阶】怎么保证消息不丢失?
**参考答案**:分三段看。生产端:开启确认(RabbitMQ publisher confirm、Kafka `acks=all`),失败重试,必要时用发件箱模式保证和数据库事务一致。broker:持久化 + 多副本(RabbitMQ quorum 队列;Kafka 副本数 >=3、`min.insync.replicas>=2`)。消费端:处理完成后再手动 ack / 提交位移。这样是“至少一次”,消费端还要幂等。
### 【进阶】怎么保证消费幂等?
**参考答案**:利用业务状态的条件更新(如 `WHERE status='pending'`);或者用消息 ID 去重表,在同一个事务里插入去重记录(唯一约束)并执行业务;或者在业务表上建唯一约束(如同一订单只发一次积分)。Redis 去重可以作为前置过滤,但关键业务要以数据库约束为准。
### 【进阶】怎么保证消息顺序?
**参考答案**:Kafka 只保证分区内有序,把需要有序的消息用同一个 key(如订单 ID)发到同一分区,消费端单线程处理该分区或按 key 分发到固定线程。RabbitMQ 同一个队列单个消费者时有序,多个消费者并发时无序,可按 key 拆到多个队列。另外也可以在消息里带版本号 / 状态,消费端丢弃过期的消息,降低对顺序的依赖。
### 【进阶】Kafka 为什么吞吐量高?
**参考答案**:顺序写磁盘(追加日志);利用操作系统页缓存;零拷贝(sendfile)发送数据给消费者;生产和消费都支持批量和压缩;分区让读写可以分布到多台 broker 并行。
## 场景题
### 【场景题】下单后要扣库存、发积分、发通知,怎么保证订单和消息的一致性?
**参考答案**:用发件箱模式:订单和待发消息在同一事务里写入数据库,后台程序(或 CDC)把消息投递到 MQ,成功后标记;下游消费者幂等。这样订单提交了消息一定会发出(至少一次)。如果是强一致的跨服务操作(比如扣库存必须和下单同时成功),可以用 TCC 或 Saga 补偿,但复杂度高,大部分业务用“可靠消息 + 最终一致 + 对账”。
### 【场景题】线上消息积压了几百万条,怎么处理?
**参考答案**:先看消费者是否正常(是否卡在某条毒消息、下游是否变慢、是否频繁再均衡)。修复问题后扩容消费者(Kafka 要看分区数是否够),提高消费并发、批量处理。积压特别大时,写一个临时程序把消息快速转存到分区更多的新主题,用大量消费者并行处理。同时评估:过期没有意义的消息(如过时的通知)可以跳过。事后加上积压监控告警。
### 【场景题】订单 30 分钟未支付自动取消,怎么设计?
**参考答案**:RabbitMQ 用 TTL + 死信队列(或延迟插件)发送延迟消息;消费者用条件更新 `WHERE status='pending'` 取消并在同一事务回补库存,和支付回调天然互斥、重复消息幂等;保留低频的兜底扫描任务防止消息丢失。详见[延迟取消场景](scenarios/delayed-order-cancel.md)。
@@ -0,0 +1,99 @@
---
id: backend.mq.scenario.delayed-order-cancel
title: 订单超时自动取消:延迟消息、幂等和兜底扫描
type: scenario
domain: backend
stack: [mq, redis, mysql]
tags: [延迟消息, 死信, 订单超时, 幂等, 定时任务]
level: intermediate
prerequisites: [backend.mq.rabbitmq]
est_hours: 2
related: [backend.mq.reliability, backend.redis.scenario.stock-reservation, backend.mq.interview]
week: [14]
---
# 订单超时自动取消:延迟消息、幂等和兜底扫描
## 业务背景与问题
下单后 30 分钟未支付要自动取消并释放库存。最初的做法是每分钟一个定时任务扫描 `WHERE status='pending' AND created_at < now() - 30 分钟`。订单量上来后:扫描越来越慢;取消时间最多晚 1 分钟;多实例部署时定时任务同时执行,重复取消、重复回补库存。
## 约束
- 取消时间误差在秒级即可。
- 绝不能取消已支付的订单(支付回调和超时取消可能同时发生)。
- 库存回补只能做一次。
- 已有 RabbitMQ。
## 方案设计
```mermaid
sequenceDiagram
participant O as 订单服务
participant W as 等待队列(TTL=30 分钟,无消费者)
participant D as 死信交换机
participant C as 取消消费者
participant DB as 数据库
O->>DB: 创建订单(pending)+ outbox 消息(同一事务)
O->>W: 发布 {order_id}
Note over W: 30 分钟后消息过期
W->>D: 转为死信
D->>C: 投递到处理队列
C->>DB: UPDATE ... SET status='cancelled' WHERE id=? AND status='pending'
alt 影响 1 行
C->>DB: 同一事务回补库存
else 影响 0 行(已支付 / 已取消)
C-->>C: 什么都不做,直接 ack
end
```
1. **延迟消息**:TTL + 死信交换机。一个等待队列只放同一种延迟时间(30 分钟)。
2. **幂等取消**:用条件更新 `WHERE status='pending'`,和支付回调的 `WHERE status='pending'` 天然互斥,只有一个会成功;库存回补放在同一事务里,只有取消成功才回补。
3. **兜底扫描**:消息仍可能因为故障丢失,保留一个低频(如每 10 分钟)的扫描任务,处理超过 35 分钟还是 pending 的订单;扫描任务用分布式锁保证只有一个实例执行,并走同一个幂等取消函数。
4. **订单量非常大或延迟时间很多种**:考虑延迟消息插件、Redis ZSET(分数=到期时间)或专门的延迟任务服务。
## 关键代码
完整可运行示例:[examples/delay-cancel/delay_cancel.py](../examples/delay-cancel/delay_cancel.py)(Python + aio-pika,延迟 1 秒演示):
```python
async def declare_wait_queue(ch, dlx, suffix: str):
return await ch.declare_queue(f"order.wait.{suffix}", durable=True, arguments={
"x-message-ttl": DELAY_MS, # 过期时间
"x-dead-letter-exchange": dlx.name, # 过期后转到死信交换机
"x-dead-letter-routing-key": "cancel",
})
async def consume(work_q, store):
async with work_q.iterator() as it:
async for msg in it:
async with msg.process(): # 正常结束 ack,异常 nack
oid = json.loads(msg.body)["order_id"]
store.cancel_if_unpaid(oid) # 等价于条件 UPDATE,重复消息无副作用
```
示例验证了:消息在延迟之后才被处理;超时前支付的订单不受影响;同一订单的重复消息只取消一次。
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 定时扫描数据库 | 简单、可靠 | 有延迟;数据量大时扫描压力大;多实例要加锁 |
| **TTL + 死信(本方案)** | 不依赖插件、精度到秒 | 每种延迟时间一个队列;消息丢失需要兜底 |
| 延迟消息插件 | 任意延迟时间 | 需要安装插件,大量延迟消息时有性能和可靠性限制(以官方说明为准) |
| Redis ZSET 轮询 | 灵活、轻量 | 要自己处理可靠性(取出后崩溃) |
## 易错点
- 取消时不加 `status='pending'` 条件:把刚支付的订单取消了。
- 先回补库存再更新订单状态,且不在同一事务:重复消息导致库存多次回补。
- 一个队列混放 15 分钟和 30 分钟的消息:短的被长的挡住,到期也不出队。
- 完全依赖延迟消息不做兜底:消息丢了订单就一直 pending、库存一直被占。
- 消费者自动 ack:处理失败消息就没了。
## 面试怎么讲
> “订单超时取消原来是定时扫表,数据量大后又慢又不准,多实例还会重复执行。我改成 RabbitMQ 的 TTL 加死信队列做延迟消息,消费者用 `WHERE status='pending'` 的条件更新做取消,和支付回调天然互斥,库存回补放在同一事务里,所以重复消息也只会取消一次。另外保留了一个低频的兜底扫描,防止消息丢失。”
追问准备:为什么不用延迟插件?支付回调在取消之后才到怎么办(订单已取消 → 走自动退款流程)?
+89
View File
@@ -0,0 +1,89 @@
---
id: backend.mysql.index-explain
title: InnoDB 索引:B+ 树、聚簇索引、回表、最左前缀和 EXPLAIN
type: lesson
domain: backend
stack: [mysql]
tags: [索引, B+树, 聚簇索引, 回表, EXPLAIN]
level: basic
prerequisites: [backend.postgresql.schema-sql]
est_hours: 3
related: [backend.mysql.tx-locks-mvcc, backend.postgresql.index-tx-mvcc, backend.mysql.interview]
week: [2]
order: 1
---
# InnoDB 索引:B+ 树、聚簇索引、回表、最左前缀和 EXPLAIN
## 解释说明
- **B+ 树**:InnoDB 索引的数据结构。非叶子节点只存键,叶子节点存数据并用链表相连,所以树很矮(几千万行通常 3~4 层)、范围查询顺着叶子链表扫就行。
- **聚簇索引(主键索引)**:叶子节点存的是**整行数据**,表数据就是按主键组织的。所以主键最好是递增的整数(顺序插入,页分裂少),不要用随机 UUID 作主键。
- **二级索引(普通索引)**:叶子节点存“索引列 + 主键值”。通过二级索引查到主键后,再到聚簇索引里取整行,这一步叫**回表**。
- **覆盖索引**:查询需要的列都在二级索引里,就不需要回表,EXPLAIN 的 `Extra` 显示 `Using index`。
- **最左前缀**:联合索引 `(a, b, c)` 按 a、b、c 顺序排序。`WHERE a = ?`、`a = ? AND b = ?` 能用上;只有 `WHERE b = ?` 用不上。遇到范围条件(`a > ?`)后,后面的列通常无法继续用于定位。
- **索引下推(ICP)**:MySQL 5.6 起,联合索引中的后续列条件可以在存储引擎层先过滤,减少回表,`Extra` 显示 `Using index condition`。
**EXPLAIN 重点字段**
| 字段 | 看什么 |
| --- | --- |
| `type` | 访问方式,从好到差大致:`const` > `eq_ref` > `ref` > `range` > `index`(扫整个索引)> `ALL`(全表扫描) |
| `key` | 实际使用的索引;为 NULL 表示没用索引 |
| `rows` | 预估要扫描的行数 |
| `Extra` | `Using index`(覆盖索引)、`Using filesort`(额外排序)、`Using temporary`(临时表)要特别关注 |
MySQL 8.0.18+ 可以用 `EXPLAIN ANALYZE` 看实际执行耗时;MariaDB 对应的是 `ANALYZE SELECT ...`。
## 案例
订单表按“用户 + 状态”查最近订单:
- 只有主键时:`type = ALL`,全表扫描。
- 建 `(user_id, status, created_at)` 联合索引后:`type = ref`,并且按 `created_at` 排序不需要 filesort。
- 只查 `id, created_at` 时:变成覆盖索引,`Extra` 出现 `Using index`。
## 代码示例
```sql
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
INSERT INTO orders (user_id, status, amount, created_at)
SELECT seq % 1000, ELT(1 + seq % 3, 'pending', 'paid', 'done'), seq % 500, NOW() - INTERVAL seq MINUTE
FROM seq_1_to_50000; -- MariaDB 的序列表;MySQL 8 可以用递归 CTE 生成数据
EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
-- type=ALL,Extra=Using where; Using filesort
CREATE INDEX idx_user_status_time ON orders (user_id, status, created_at);
ANALYZE TABLE orders;
EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created_at DESC LIMIT 20;
-- type=ref,key=idx_user_status_time,没有 filesort
EXPLAIN SELECT id, created_at FROM orders WHERE user_id = 42 AND status = 'paid';
-- Extra 含 Using index:覆盖索引(二级索引叶子里本来就有主键 id)
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
-- 不满足最左前缀:type=ALL
```
## 易错点
- **隐式类型转换**:`phone` 是 VARCHAR,写 `WHERE phone = 13800000000`(数字)会对列做转换,索引失效。参数类型要和列类型一致。
- **索引列上用函数**:`WHERE DATE(created_at) = '2026-10-01'` 用不上索引,改成范围条件。
- **`LIKE '%xx'`** 前面有通配符用不上索引;`LIKE 'xx%'` 可以。
- **`OR` 连接不同列**:可能导致全表扫描,可改为 `UNION ALL` 或分别建索引(index merge)。
- **区分度低的列单独建索引**(如性别、状态只有几个值):优化器往往不用它;放在联合索引里配合其他列使用。
- **UUID 作主键**:随机插入导致页分裂、索引膨胀;需要全局唯一 ID 时用雪花算法等趋势递增的 ID。
- **`SELECT *`**:失去覆盖索引的机会,也多传数据。
## 面试常问
见 [MySQL 面试题](interview.md):为什么用 B+ 树、聚簇索引和回表、最左前缀、索引失效场景、EXPLAIN 怎么看。
+119
View File
@@ -0,0 +1,119 @@
---
id: backend.mysql.tx-locks-mvcc
title: 事务、隔离级别、MVCC 和锁(行锁、间隙锁、死锁)
type: lesson
domain: backend
stack: [mysql]
tags: [事务, 隔离级别, MVCC, 间隙锁, 死锁, undo log, redo log]
level: intermediate
prerequisites: [backend.mysql.index-explain]
est_hours: 4
related: [backend.postgresql.index-tx-mvcc, backend.mysql.interview]
week: [2, 14]
order: 2
---
# 事务、隔离级别、MVCC 和锁(行锁、间隙锁、死锁)
## 解释说明
**ACID 由谁保证**
| 特性 | InnoDB 的实现 |
| --- | --- |
| 原子性 | undo log:失败时按 undo log 回滚 |
| 持久性 | redo log:先写日志再改数据页(WAL),崩溃后按 redo log 恢复 |
| 隔离性 | MVCC + 锁 |
| 一致性 | 以上三者 + 约束共同保证 |
另外还有 **binlog**(Server 层的逻辑日志),用于主从复制和按时间点恢复。redo log 和 binlog 通过“两阶段提交”保持一致。
**隔离级别**(InnoDB 默认 **可重复读 RR**,PostgreSQL 默认读已提交)
| 级别 | 脏读 | 不可重复读 | 幻读 |
| --- | --- | --- | --- |
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 RC | 不会 | 可能 | 可能 |
| 可重复读 RR | 不会 | 不会 | 快照读不会;当前读靠间隙锁防止 |
| 串行化 | 不会 | 不会 | 不会 |
**MVCC**:每行有隐藏的事务 ID 和回滚指针,旧版本串在 undo log 里。普通 `SELECT` 是**快照读**,按 Read View 判断哪个版本可见;RC 每条语句生成新的 Read View,RR 在事务第一次读时生成、之后一直复用。
**当前读和锁**:`SELECT ... FOR UPDATE`、`UPDATE`、`DELETE` 读的是最新版本并加锁。
- **记录锁**:锁住索引上的一条记录。
- **间隙锁**:锁住两条记录之间的“空隙”,防止别人往里插入,RR 下才有。
- **临键锁(Next-Key Lock)**:记录锁 + 前面的间隙,是 RR 下的默认加锁单位。
- **锁加在索引上**:条件列没有索引时,会扫描并锁住大量记录,效果接近锁表。
**死锁**:两个事务互相等待对方持有的锁。InnoDB 会自动检测并回滚其中一个(错误码 1213),应用要捕获并重试。
## 案例
- 两个请求同时给同一个用户扣余额:用 `UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?`,一条语句完成判断和扣减,靠行锁串行化。
- 两个事务以相反顺序更新两行(A 先改 1 再改 2,B 先改 2 再改 1)就会死锁。解决:统一按 ID 顺序加锁。
## 代码示例
两个客户端窗口复现死锁:
```sql
CREATE TABLE accounts (id INT PRIMARY KEY, balance DECIMAL(12, 2) NOT NULL) ENGINE = InnoDB;
INSERT INTO accounts VALUES (1, 100), (2, 100);
-- 窗口 A
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- 窗口 B
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 2;
-- 窗口 A(等待 B 释放 id=2)
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
-- 窗口 B:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
-- 查看最近一次死锁:SHOW ENGINE INNODB STATUS\G 的 LATEST DETECTED DEADLOCK 部分
```
应用层处理死锁(Python + PyMySQL,按 ID 排序加锁 + 遇到 1213 重试):
```python
import time
import pymysql
def transfer(conn: pymysql.connections.Connection, src: int, dst: int, amount: int, retries: int = 3) -> None:
first, second = sorted((src, dst)) # 统一加锁顺序,从根源上减少死锁
for attempt in range(retries):
try:
with conn.cursor() as cur:
conn.begin()
cur.execute("SELECT id FROM accounts WHERE id IN (%s, %s) ORDER BY id FOR UPDATE", (first, second))
n = cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s AND balance >= %s",
(amount, src, amount))
if n == 0:
raise ValueError("余额不足")
cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, dst))
conn.commit()
return
except pymysql.err.OperationalError as e:
conn.rollback()
if e.args[0] != 1213 or attempt == retries - 1: # 1213 = 死锁
raise
time.sleep(0.05 * (attempt + 1))
except Exception:
conn.rollback()
raise
```
## 易错点
- **以为 RR 能防止所有并发问题**:快照读看到的是旧数据,“先查余额再扣”在 RR 下仍然会超扣,要用当前读(`FOR UPDATE`)或条件更新。
- **更新条件没有索引**:会锁住大量行,甚至阻塞全表写入。
- **大事务**:一次更新几十万行,锁持有时间长、undo 膨胀、主从延迟。分批提交。
- **死锁不重试**:死锁在高并发下无法完全避免,应用要能重试。
- **把 MySQL 的结论直接套到 PostgreSQL**:两者默认隔离级别、MVCC 实现都不同(见 [PostgreSQL 的事务课程](../postgresql/02-index-tx-mvcc.md))。
## 面试常问
见 [MySQL 面试题](interview.md):redo log / undo log / binlog 的区别、RR 怎么解决幻读、间隙锁、死锁排查。
+95
View File
@@ -0,0 +1,95 @@
---
id: backend.mysql.ops-scale
title: 运维与扩展:慢查询、主从复制、读写分离、分库分表
type: lesson
domain: backend
stack: [mysql]
tags: [慢查询, 主从复制, 读写分离, 分库分表, 在线 DDL]
level: intermediate
prerequisites: [backend.mysql.tx-locks-mvcc]
est_hours: 3
related: [backend.mysql.scenario.deep-pagination, backend.mysql.interview]
week: [14]
order: 3
---
# 运维与扩展:慢查询、主从复制、读写分离、分库分表
## 解释说明
**慢查询**
- 打开慢查询日志:`slow_query_log = ON`、`long_query_time = 1`(秒);用 `mysqldumpslow` 或 `pt-query-digest` 汇总,按“总耗时”排序优先优化。
- 排查顺序:EXPLAIN → 看是否全表扫描 / filesort / 临时表 → 加或调整索引 → 改写 SQL → 考虑缓存或改表结构。
**主从复制**
- 主库写 binlog,从库的 IO 线程拉取到 relay log,SQL 线程重放。默认是**异步复制**,主库宕机可能丢最后一部分数据;半同步复制要求至少一个从库收到后才返回。
- binlog 格式推荐 `ROW`(记录行的变化,最准确)。
- **主从延迟**:大事务、从库单线程重放、从库负载高。MySQL 5.7+ 支持并行复制。
**读写分离**
- 写走主库,读走从库。问题是**刚写完立刻读**可能读到旧数据。常见做法:写后短时间内的读走主库;关键业务(支付结果、订单详情)强制读主库。
- 实现方式:应用里配两个连接池,或者用中间件(ProxySQL、ShardingSphere 等)。
**分库分表**
- 什么时候考虑:单表数据量很大且索引已优化仍然慢、单库写入达到瓶颈。先考虑归档历史数据、加缓存、读写分离,分库分表是最后手段。
- 分片键:选查询最常用的维度(如 `user_id`),让大部分查询只落在一个分片上。
- 带来的问题:跨分片查询和排序分页、跨分片事务、全局唯一 ID(雪花算法)、扩容时的数据迁移。
**在线 DDL**:MySQL 8 很多 DDL 支持 `ALGORITHM=INPLACE / INSTANT`;大表变更常用 gh-ost、pt-online-schema-change。具体哪些操作支持 INSTANT 以官方文档为准:https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html
## 案例
- 某报表接口慢:慢日志显示一条 `GROUP BY` 语句每天累计耗时最高,EXPLAIN 发现 `Using temporary; Using filesort`,按分组列加联合索引后耗时明显下降;更大的统计改成每天定时汇总到统计表。
- 用户下单后立刻跳转订单详情,偶尔显示“订单不存在”:读写分离的主从延迟导致。订单详情页改为读主库。
## 代码示例
应用层读写分离的最小实现(Python,演示思路,示意代码):
```python
import contextvars
import time
_force_primary_until = contextvars.ContextVar("force_primary_until", default=0.0)
class Router:
"""写走主库;写后 2 秒内同一请求上下文的读也走主库,避免主从延迟读到旧数据。"""
def __init__(self, primary, replicas, sticky_seconds: float = 2.0):
self.primary, self.replicas, self.sticky = primary, replicas, sticky_seconds
self._i = 0
def for_write(self):
_force_primary_until.set(time.monotonic() + self.sticky)
return self.primary
def for_read(self, strong: bool = False):
if strong or not self.replicas or time.monotonic() < _force_primary_until.get():
return self.primary
self._i = (self._i + 1) % len(self.replicas) # 简单轮询
return self.replicas[self._i]
r = Router(primary="primary", replicas=["replica-1", "replica-2"])
assert r.for_read() in ("replica-1", "replica-2")
r.for_write()
assert r.for_read() == "primary" # 刚写完,读主库
assert r.for_read(strong=True) == "primary" # 关键业务强制读主库
```
## 易错点
- **一上来就分库分表**:复杂度大幅上升。先归档、索引、缓存、读写分离。
- **分片键选错**:大部分查询都要扫所有分片,性能反而更差。
- **从库当备份**:误删数据会立刻同步到从库,备份要单独做(全量 + binlog)并定期演练恢复。
- **大事务导致主从延迟**:批量操作分批提交。
- **直接在大表上执行 `ALTER TABLE`**:可能长时间锁表或引起复制延迟,先确认算法,必要时用在线变更工具,在低峰期执行。
## 面试常问
见 [MySQL 面试题](interview.md):主从延迟怎么处理、什么时候分库分表、分片键怎么选。
@@ -0,0 +1,114 @@
# requires: pymysql
"""对比 LIMIT offset 深分页和游标(keyset)分页。
运行:MYSQL_URL=mysql://user:pass@127.0.0.1:3306/db python paginate.py
没有设置 MYSQL_URL 时只打印提示并正常退出。会创建并删除临时表 demo_page_orders。
"""
import os
import sys
import time
from urllib.parse import urlparse
import pymysql
TABLE = "demo_page_orders"
ROWS = 100_000
PAGE = 20
def connect(url: str) -> pymysql.connections.Connection:
u = urlparse(url)
return pymysql.connect(host=u.hostname, port=u.port or 3306, user=u.username, password=u.password or "",
database=u.path.lstrip("/"), autocommit=True)
def seed(cur) -> None:
cur.execute(f"DROP TABLE IF EXISTS {TABLE}")
cur.execute(f"""CREATE TABLE {TABLE} (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
created_at DATETIME NOT NULL,
note VARCHAR(100) NOT NULL,
KEY idx_user_time_id (user_id, created_at, id)
) ENGINE = InnoDB""")
batch = []
for i in range(ROWS):
batch.append((1, f"note-{i}")) # 全部属于 user 1,便于构造深分页
if len(batch) == 5000:
cur.executemany(f"INSERT INTO {TABLE} (user_id, created_at, note) VALUES (%s, '2026-01-01', %s)", batch)
batch.clear()
# 时间按 id 递增;让每 3 行共用一个时间,演示“时间相同按 id 排序”
cur.execute(f"UPDATE {TABLE} SET created_at = '2026-01-01' + INTERVAL (id DIV 3) SECOND")
cur.execute(f"ANALYZE TABLE {TABLE}")
cur.fetchall()
def offset_page(cur, offset: int) -> list[tuple]:
cur.execute(f"SELECT id, created_at, note FROM {TABLE} WHERE user_id = 1 "
f"ORDER BY created_at DESC, id DESC LIMIT %s OFFSET %s", (PAGE, offset))
return list(cur.fetchall())
def deferred_join_page(cur, offset: int) -> list[tuple]:
# 延迟关联:先在覆盖索引上找出 id,再回表取这一页的整行
cur.execute(f"""SELECT o.id, o.created_at, o.note FROM {TABLE} o
JOIN (SELECT id FROM {TABLE} WHERE user_id = 1 ORDER BY created_at DESC, id DESC LIMIT %s OFFSET %s) t
ON o.id = t.id ORDER BY o.created_at DESC, o.id DESC""", (PAGE, offset))
return list(cur.fetchall())
def keyset_page(cur, after: tuple | None) -> list[tuple]:
if after is None:
cur.execute(f"SELECT id, created_at, note FROM {TABLE} WHERE user_id = 1 "
f"ORDER BY created_at DESC, id DESC LIMIT %s", (PAGE,))
else:
created_at, last_id = after
cur.execute(f"SELECT id, created_at, note FROM {TABLE} WHERE user_id = 1 "
f"AND (created_at < %s OR (created_at = %s AND id < %s)) "
f"ORDER BY created_at DESC, id DESC LIMIT %s", (created_at, created_at, last_id, PAGE))
return list(cur.fetchall())
def timed(fn, *args) -> tuple[float, list[tuple]]:
t = time.perf_counter()
rows = fn(*args)
return (time.perf_counter() - t) * 1000, rows
def main() -> int:
url = os.environ.get("MYSQL_URL")
if not url:
print("未设置 MYSQL_URL,跳过(示例:mysql://app:app@127.0.0.1:3306/learn)")
return 0
conn = connect(url)
cur = conn.cursor()
try:
seed(cur)
offset = ROWS - 1000 # 很深的一页
ms1, a = timed(offset_page, cur, offset)
ms2, b = timed(deferred_join_page, cur, offset)
# 游标分页:拿到上一页最后一行 (created_at, id),直接从那里继续
prev = offset_page(cur, offset - PAGE)[-1]
ms3, c = timed(keyset_page, cur, (prev[1], prev[0]))
assert a == b == c, "三种写法结果应该一致"
print(f"OFFSET {offset}: {ms1:.1f} ms;延迟关联: {ms2:.1f} ms;游标分页: {ms3:.1f} ms(耗时与机器有关,看相对差异)")
# 游标分页逐页翻完不重不漏
seen, after = 0, None
while True:
rows = keyset_page(cur, after)
if not rows:
break
seen += len(rows)
after = (rows[-1][1], rows[-1][0])
if seen >= 2000:
break
assert seen == 2000
print("游标分页连续翻页正确")
return 0
finally:
cur.execute(f"DROP TABLE IF EXISTS {TABLE}")
conn.close()
if __name__ == "__main__":
sys.exit(main())
+32 -3
View File
@@ -4,12 +4,41 @@ title: MySQL
type: index
domain: backend
stack: [mysql]
tags: []
tags: [数据库, SQL, MySQL, InnoDB]
week: [2, 14]
---
# MySQL
InnoDB 索引、EXPLAIN、事务隔离与锁、慢查询、分库分表与主从。
国内招聘里出现最多的关系数据库。本计划先用 [PostgreSQL](../postgresql/index.md) 学 SQL 和事务,再用这个专题补齐 MySQL(InnoDB)特有的知识:聚簇索引、EXPLAIN 读法、间隙锁、主从复制和分库分表——这些是面试高频题。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
> 示例在 MariaDB 11.8 上验证过;MariaDB 与 MySQL 8 在 `EXPLAIN ANALYZE`、部分系统变量上有差异,文中已标注,以 MySQL 官方文档为准。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [InnoDB 索引:B+ 树、聚簇索引、回表、最左前缀和 EXPLAIN](01-index-explain.md) | 基础 | 3 小时 |
| 2 | [事务、隔离级别、MVCC 和锁(行锁、间隙锁、死锁)](02-tx-locks-mvcc.md) | 进阶 | 4 小时 |
| 3 | [运维与扩展:慢查询、主从复制、读写分离、分库分表](03-ops-scale.md) | 进阶 | 3 小时 |
## 企业场景
- [大表深分页和历史数据:游标分页、延迟关联、归档](scenarios/deep-pagination.md)
## 面试题
- [MySQL 面试题](interview.md)
## 示例代码
- [examples/deep-pagination](examples/deep-pagination/):Python + PyMySQL 对比 `LIMIT offset` 和游标分页;设置 `MYSQL_URL` 后运行
## 在 16 周计划中
- [第 2 周:数据库 + 鉴权(MySQL 对照)](../../../plan/week02-db-auth.md)、[第 14 周:后端扩展](../../../plan/week14-backend-extend-deploy.md)
## 官方资料
- MySQL 8.4 参考手册:https://dev.mysql.com/doc/refman/8.4/en/
- InnoDB 锁:https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html
+71
View File
@@ -0,0 +1,71 @@
---
id: backend.mysql.interview
title: MySQL 面试题
type: interview
domain: backend
stack: [mysql]
tags: [MySQL, InnoDB, 索引, 事务, 面试]
level: intermediate
related: [backend.mysql.index-explain, backend.mysql.tx-locks-mvcc, backend.mysql.ops-scale, backend.postgresql.interview]
week: [2, 14, 16]
---
# MySQL 面试题
> 招聘要求里 MySQL 出现频率很高,这部分建议全部能口述。PostgreSQL 的题见 [PostgreSQL 面试题](../postgresql/interview.md)。
## 基础
### 【基础】InnoDB 为什么用 B+ 树而不是 B 树、红黑树或哈希?
**参考答案**:B+ 树的非叶子节点只存键,一个页能放更多键,树更矮,磁盘 IO 次数少;所有数据在叶子节点且叶子之间用链表相连,范围查询和排序高效。红黑树是二叉树,数据多时太高;哈希只支持等值查询,不支持范围和排序。
### 【基础】什么是聚簇索引、回表和覆盖索引?
**参考答案**:InnoDB 的主键索引叶子节点存整行数据,叫聚簇索引;二级索引叶子节点存索引列和主键值。通过二级索引找到主键再去主键索引取整行叫回表。如果查询需要的列都在二级索引里,就不用回表,叫覆盖索引(EXPLAIN 的 Extra 显示 Using index)。
**追问**:为什么建议主键用自增整数?→ 顺序插入页分裂少;主键越短,二级索引越小。
### 【基础】联合索引的最左前缀原则是什么?
**参考答案**:联合索引 `(a, b, c)` 先按 a 排序,a 相同再按 b,依此类推。查询条件要从 a 开始连续使用才能利用索引定位;只用 b 或 c 用不上;中间遇到范围条件(如 `a > 1`)后,后面的列一般不能再用于定位(可以靠索引下推过滤)。
### 【基础】有哪些常见的索引失效情况?
**参考答案**:索引列上用函数或运算;隐式类型转换(字符串列用数字比较);`LIKE` 以 `%` 开头;不满足最左前缀;`OR` 连接的列有的没索引;优化器判断走索引不如全表扫描(区分度低、要扫的行太多)。
### 【基础】redo log、undo log、binlog 分别是什么?
**参考答案**:redo log 是 InnoDB 的物理日志,记录“某页做了什么修改”,保证崩溃后已提交的事务不丢(持久性);undo log 记录修改前的值,用于回滚和 MVCC 读旧版本(原子性、隔离性);binlog 是 Server 层的逻辑日志,用于主从复制和按时间点恢复。redo log 和 binlog 用两阶段提交保证一致。
## 进阶
### 【进阶】InnoDB 的可重复读是怎么实现的?能解决幻读吗?
**参考答案**:普通 SELECT 是快照读,事务第一次读时生成 Read View,之后一直用它判断数据版本是否可见,所以多次读结果一致,也看不到别人新插入的行。当前读(`FOR UPDATE`、`UPDATE`)读最新数据,通过临键锁(记录锁 + 间隙锁)阻止其他事务在范围内插入,从而避免幻读。但如果事务里先快照读、再当前读,仍可能看到不一致,所以不能说“完全解决”。
### 【进阶】什么情况下会死锁?怎么排查和避免?
**参考答案**:两个事务以不同顺序锁定相同的资源,互相等待。InnoDB 会检测并回滚代价小的那个事务(错误 1213)。排查:`SHOW ENGINE INNODB STATUS` 的 LATEST DETECTED DEADLOCK 段,看两个事务各自持有和等待的锁。避免:固定加锁顺序(如按 id 排序)、缩小事务、更新条件走索引减少锁范围、必要时降低到读已提交减少间隙锁;应用层捕获死锁并重试。
### 【进阶】主从延迟的原因和处理方式?
**参考答案**:原因:大事务、从库重放并行度低、从库负载高或配置差、网络。处理:拆分大事务、开启并行复制、关键读走主库(写后一段时间内读主库)、监控延迟超过阈值时把读流量切回主库。
### 【进阶】什么时候需要分库分表?分片键怎么选?
**参考答案**:单表数据量大到索引优化后仍然慢、或单库写入成为瓶颈,并且归档、缓存、读写分离都用过之后再考虑。分片键选最常用的查询维度(如用户 ID),让大部分请求只访问一个分片,并且数据分布均匀。代价是跨分片查询、分页、事务、全局 ID 和扩容迁移都变复杂。
## 场景题
### 【场景题】“先查库存再扣减”在高并发下超卖了,怎么改?
**参考答案**:查和扣两步之间有并发窗口。改成一条条件更新:`UPDATE stock SET num = num - 1 WHERE sku_id = ? AND num >= 1`,判断影响行数;或者 `SELECT ... FOR UPDATE` 加锁后再扣。并发非常高时(秒杀),数据库行锁会成为瓶颈,在 Redis 里预扣库存、消息队列异步落库(见 Redis 专题的库存场景和跨栈场景“秒杀”)。
### 【场景题】一张几千万行的表,分页越往后越慢,怎么优化?
**参考答案**:`LIMIT offset` 需要扫描并丢弃前面的行。改为游标分页(记住上一页最后一条的排序键和 id);需要跳页时用延迟关联并限制最大页数;导出走异步任务;历史数据归档。详见[深分页场景](scenarios/deep-pagination.md)。
### 【场景题】线上一个接口突然变慢,数据库 CPU 很高,你怎么排查?
**参考答案**:先 `SHOW PROCESSLIST` 看正在执行的慢语句和状态(是否大量 Waiting for lock);查慢查询日志按总耗时排序;对可疑 SQL 做 EXPLAIN,看是不是最近上线的新语句、索引被删、统计信息不准导致走错索引;有锁等待时查 `information_schema` / `performance_schema` 中的锁信息找到阻塞源。处理上可以先限流或下线问题功能止血,再加索引或改 SQL,最后复盘加监控。
@@ -0,0 +1,77 @@
---
id: backend.mysql.scenario.deep-pagination
title: 大表深分页和历史数据:游标分页、延迟关联、归档
type: scenario
domain: backend
stack: [mysql]
tags: [深分页, 游标分页, 延迟关联, 归档, 大表]
level: intermediate
prerequisites: [backend.mysql.index-explain]
est_hours: 2
related: [backend.mysql.ops-scale, backend.mysql.interview]
week: [2, 14]
---
# 大表深分页和历史数据:游标分页、延迟关联、归档
## 业务背景与问题
一个 SaaS 后台的“操作记录 / 订单列表”页面,单表已积累数千万行。用户反馈:前几页很快,翻到很后面或者导出时接口超时;同时这张表的慢查询日志里大部分是 `LIMIT 大数, 20`。
## 约束
- 列表需要按时间倒序展示,支持按用户、状态筛选。
- 产品希望保留“跳到第 N 页”,但能接受在超过一定页数后改成“加载更多”。
- 导出需要导出全部符合条件的数据。
- 不希望马上引入分库分表或搜索引擎。
## 方案设计
1. **找原因**:`LIMIT 199000, 20` 需要先按索引顺序找到 199020 行(并回表),再丢掉前 199000 行,越往后越慢。
2. **游标分页(主方案)**:前端传上一页最后一条的 `(created_at, id)`,查询 `WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20`,配合 `(user_id, created_at, id)` 联合索引,每页都只扫 20 行。适合“加载更多”、无限滚动、App 列表。
3. **延迟关联(保留跳页时用)**:子查询只在覆盖索引上找出这一页的 id,再回表取整行,减少大量回表。仍然要扫描 offset 行,只是更便宜;同时限制最大页数(如 100 页)。
4. **导出**:用游标分页按批读取,后台异步生成文件,不在请求里同步完成。
5. **归档**:超过一定时间(如 1 年)的数据移到历史表或对象存储,主表只保留热数据;历史查询走单独的入口。
## 关键代码
完整可运行对比见 [examples/deep-pagination/paginate.py](../examples/deep-pagination/paginate.py):
```sql
-- 游标分页:时间相同的行用 id 区分,保证不重不漏
SELECT id, created_at, note FROM orders
WHERE user_id = 1 AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC LIMIT 20;
-- 延迟关联:先在覆盖索引上取 id,再回表
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders WHERE user_id = 1 ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 199000) t
ON o.id = t.id
ORDER BY o.created_at DESC, o.id DESC;
```
在本地 10 万~20 万行的测试数据上,深页的游标分页比 `OFFSET` 快一到两个数量级(具体数字与机器有关,以你自己运行的结果为准)。
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 游标分页 | 每页耗时稳定,适合海量数据 | 不能直接跳到第 N 页;排序字段必须唯一或加 id 兜底 |
| 延迟关联 | 保留跳页,改动小 | 仍随 offset 变慢,只是常数更小 |
| 限制最大页数 | 简单直接 | 需要产品接受 |
| 搜索引擎(ES 等) | 复杂筛选、全文检索 | 多一套系统,要做数据同步(见跨栈场景“数据同步”) |
| 归档 | 主表变小,所有查询受益 | 历史数据查询入口要单独设计 |
## 易错点
- 只按 `created_at` 做游标:时间相同的多行会被跳过或重复,必须加 `id` 作为第二排序键。
- 游标条件和 ORDER BY、索引列顺序不一致:用不上索引。
- `SELECT COUNT(*)` 统计总页数在大表上也很慢:可以显示“约 xx 条”,或缓存计数。
- 导出在 HTTP 请求里同步完成:超时、占满连接。改为异步任务。
- 归档用一条大 `DELETE`:长事务、锁、主从延迟。按主键范围分批删除。
## 面试怎么讲
> “列表接口深分页超时,慢查询日志里都是大 offset。原因是 LIMIT offset 要扫描并丢弃前面所有行。我把 App 和导出改成游标分页:传上一页最后一条的时间和 id,配合联合索引,每页耗时稳定;后台管理保留跳页,用延迟关联并限制最大页数;同时按时间归档一年前的数据,主表变小。”
追问准备:为什么要加 id?总数怎么显示?如果按多个字段排序怎么做游标(把所有排序字段都放进游标)?
+109
View File
@@ -0,0 +1,109 @@
---
id: backend.postgresql.schema-sql
title: 表设计和 SQL:约束、JOIN、CTE、窗口函数、JSONB、UPSERT
type: lesson
domain: backend
stack: [postgresql]
tags: [SQL, 表设计, 约束, JOIN, 窗口函数, JSONB]
level: basic
est_hours: 4
related: [backend.postgresql.index-tx-mvcc, backend.postgresql.interview]
week: [2]
order: 1
---
# 表设计和 SQL:约束、JOIN、CTE、窗口函数、JSONB、UPSERT
## 解释说明
**表设计的基本功**
- **主键**:一般用 `BIGINT GENERATED ALWAYS AS IDENTITY`(自增)或 UUID。对外暴露的 ID 用 UUID 或随机字符串可以避免被遍历。
- **约束是最后一道防线**:`NOT NULL`、`UNIQUE`、`CHECK (amount > 0)`、外键 `REFERENCES`。应用代码有 bug 时,数据库约束能挡住脏数据。
- **范式和反范式**:一般按第三范式拆表(一个事实只存一处);为了查询性能可以有意冗余(例如订单里冗余商品名称快照),但要清楚冗余字段谁负责更新。
- **时间**:用 `TIMESTAMPTZ`(带时区)存时间,统一存 UTC,展示时再转换。
- **金额**:用 `NUMERIC(12,2)` 或以“分”为单位的整数,**不要用浮点数**。
**常用 SQL**
- `JOIN`:内连接只保留两边都有的行;左连接保留左表全部行,右边没有就是 NULL。
- `GROUP BY` + 聚合函数;`HAVING` 过滤分组后的结果。
- **CTE**(`WITH ... AS`):把复杂查询拆成几步,可读性好。
- **窗口函数**:`ROW_NUMBER() OVER (PARTITION BY 用户 ORDER BY 时间 DESC)` 不合并行就能做“每组取前 N 条”、排名、累计。
- **UPSERT**:`INSERT ... ON CONFLICT (键) DO UPDATE` 一条语句完成“有则更新、无则插入”,并发下也安全(依赖唯一约束)。
- **RETURNING**:插入、更新后直接返回新值,省一次查询。
- **JSONB**:存半结构化数据(如商品的可变属性),支持索引(GIN)和查询运算符 `->>`、`@>`。结构稳定、经常查询的字段还是建成普通列。
## 案例
电商后台的“每个用户最近 3 笔订单”“按月统计各渠道销售额”“商品扩展属性”都是上面这些语法的典型用途。下面用一个最小的订单库演示。
## 代码示例
```sql
-- 建表:约束写全
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
channel TEXT NOT NULL CHECK (channel IN ('web', 'app', 'mini')),
amount NUMERIC(12, 2) NOT NULL CHECK (amount > 0),
attrs JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO users (email) VALUES ('a@example.com'), ('b@example.com');
INSERT INTO orders (user_id, channel, amount, attrs, created_at) VALUES
(1, 'web', 99.00, '{"coupon": "NEW10"}', now() - interval '3 day'),
(1, 'app', 25.50, '{}', now() - interval '2 day'),
(1, 'app', 300.00, '{"gift": true}', now() - interval '1 day'),
(1, 'web', 12.00, '{}', now()),
(2, 'mini', 45.00, '{"coupon": "VIP"}', now());
-- 1. 每个用户最近 3 笔订单(窗口函数)
SELECT * FROM (
SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders o
) t WHERE rn <= 3;
-- 2. 按渠道统计,只看销售额超过 50 的渠道(GROUP BY + HAVING)
SELECT channel, count(*) AS cnt, sum(amount) AS total
FROM orders GROUP BY channel HAVING sum(amount) > 50 ORDER BY total DESC;
-- 3. CTE:先算每个用户的消费总额,再关联用户表
WITH spend AS (
SELECT user_id, sum(amount) AS total FROM orders GROUP BY user_id
)
SELECT u.email, COALESCE(s.total, 0) AS total
FROM users u LEFT JOIN spend s ON s.user_id = u.id;
-- 4. JSONB 查询:用了优惠券的订单
SELECT id, attrs->>'coupon' AS coupon FROM orders WHERE attrs ? 'coupon';
-- 5. UPSERT:每日统计表,重复执行不会出错
CREATE TABLE daily_sales (day DATE PRIMARY KEY, total NUMERIC(12, 2) NOT NULL);
INSERT INTO daily_sales (day, total)
SELECT created_at::date, sum(amount) FROM orders GROUP BY 1
ON CONFLICT (day) DO UPDATE SET total = EXCLUDED.total
RETURNING *;
```
运行:`docker run -d --name pg -e POSTGRES_PASSWORD=pg -p 5432:5432 postgres:17`,然后 `psql -h localhost -U postgres` 粘贴执行。
## 易错点
- **`NOT IN` 遇到 NULL**:`x NOT IN (子查询)` 只要子查询结果里有一个 NULL,整个条件就不成立,结果为空;改用 `NOT EXISTS`。
- **`count(列)` 不统计 NULL**,`count(*)` 统计所有行。
- **浮点数存金额**:`0.1 + 0.2` 的精度问题会出现在账目里。
- **LEFT JOIN 后在 WHERE 里过滤右表**:会把左连接变回内连接,右表的条件要写在 `ON` 里。
- **先查再插判断唯一**:并发时两个请求都查不到、都插入成功;用唯一约束 + `ON CONFLICT`。
- **JSONB 什么都往里放**:失去约束和类型检查,查询也难优化。
## 面试常问
见 [PostgreSQL 面试题](interview.md):范式、`NOT IN` 和 NULL、UPSERT 的并发安全、JSONB 的使用场景。
@@ -0,0 +1,111 @@
---
id: backend.postgresql.index-tx-mvcc
title: 索引、EXPLAIN、事务、隔离级别和 MVCC
type: lesson
domain: backend
stack: [postgresql]
tags: [索引, EXPLAIN, 事务, 隔离级别, MVCC, 锁]
level: intermediate
prerequisites: [backend.postgresql.schema-sql]
est_hours: 4
related: [backend.postgresql.scenario.order-state-audit, backend.mysql.tx-locks-mvcc, backend.postgresql.interview]
week: [2, 3]
order: 2
---
# 索引、EXPLAIN、事务、隔离级别和 MVCC
## 解释说明
**索引**
- 默认是 **B-tree**,支持等值、范围、排序。其他类型:**GIN**(JSONB、数组、全文检索)、**GiST**(地理位置、范围类型)、**BRIN**(按时间顺序写入的超大表)、pgvector 的 HNSW / IVFFlat(向量)。
- **联合索引**按列顺序排序,查询条件要从最左列开始用(和 MySQL 一样)。
- **部分索引**:`CREATE INDEX ... WHERE status = 'pending'`,只索引需要的行,体积小。
- **表达式索引**:`CREATE INDEX ON users (lower(email))`,查询时用同样的表达式才能命中。
- **覆盖索引**:`INCLUDE (列)` 把额外列放进索引叶子,查询可以只扫索引(Index Only Scan)。
- 线上建索引用 `CREATE INDEX CONCURRENTLY`,不阻塞写入(不能放在事务里执行)。
**EXPLAIN**
- `EXPLAIN` 看计划,`EXPLAIN ANALYZE` 真正执行并给出实际耗时和行数(对写语句要小心,会真的执行)。
- 关注:`Seq Scan`(全表扫描)、`Index Scan` / `Index Only Scan`、`Bitmap Heap Scan`;预估行数(rows)和实际行数差很多时,说明统计信息过时,执行 `ANALYZE 表名`。
**事务和隔离级别**
- PostgreSQL 默认 **读已提交(Read Committed)**:每条语句看到语句开始时已提交的数据,所以同一事务里两次查询结果可能不同。
- **可重复读(Repeatable Read)**:整个事务看到的是第一条语句开始时的快照;如果要修改的行已被别的事务改过,会报序列化错误,应用要重试。
- **可串行化(Serializable)**:最严格,检测到可能的冲突时让事务失败,应用必须有重试逻辑。
**MVCC**
- 每次更新不是原地修改,而是写一个新版本的行,旧版本对还在用旧快照的事务仍然可见。所以读不阻塞写、写不阻塞读。
- 代价:旧版本(死元组)需要 **VACUUM** 清理(通常由 autovacuum 自动完成);长时间不结束的事务会让旧版本无法清理,导致表膨胀。
**锁**
- `SELECT ... FOR UPDATE` 锁住选中的行,其他事务要修改或也想加锁时会等待;`FOR UPDATE SKIP LOCKED` 跳过已被锁的行——常用来实现“多个 worker 从任务表取任务”。
- 乐观锁:表里加 `version` 列,`UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?`,影响行数为 0 就是被别人改过了。
## 案例
- 订单列表按“用户 + 状态 + 时间”查询,建联合索引 `(user_id, status, created_at DESC)`。
- 后台任务表多个 worker 并发取任务:`SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED`,互不阻塞、不会重复领取。
- 订单状态修改用乐观锁,避免“用户取消”和“支付回调”同时修改导致状态错乱(见场景文档)。
## 代码示例
```sql
CREATE TABLE jobs (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
payload JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO jobs (user_id, status, created_at)
SELECT (random() * 1000)::int, (ARRAY['queued', 'running', 'done'])[1 + (random() * 2)::int],
now() - (random() * interval '30 day')
FROM generate_series(1, 50000);
CREATE INDEX jobs_user_status_time ON jobs (user_id, status, created_at DESC);
CREATE INDEX jobs_queued ON jobs (id) WHERE status = 'queued'; -- 部分索引
ANALYZE jobs;
-- 命中联合索引
EXPLAIN ANALYZE SELECT * FROM jobs WHERE user_id = 42 AND status = 'done' ORDER BY created_at DESC LIMIT 20;
-- 只按 status 查:不满足最左前缀,通常是 Seq Scan(数据分布不同,计划可能不同)
EXPLAIN SELECT * FROM jobs WHERE status = 'done';
-- 多个 worker 取任务:在两个 psql 窗口里同时执行,互不阻塞、拿到不同的任务
BEGIN;
SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 5 FOR UPDATE SKIP LOCKED;
-- ……处理任务,然后:UPDATE jobs SET status = 'running' WHERE id IN (...);
COMMIT;
```
隔离级别的对比实验(两个 psql 窗口 A、B):
```sql
-- A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM jobs WHERE status = 'queued';
-- B(自动提交)
UPDATE jobs SET status = 'done' WHERE id = (SELECT min(id) FROM jobs WHERE status = 'queued');
-- A:结果不变(快照);如果把 A 改成默认的 READ COMMITTED,结果会变
SELECT count(*) FROM jobs WHERE status = 'queued';
COMMIT;
```
## 易错点
- **索引列上用函数或类型转换**:`WHERE date(created_at) = '2026-10-01'` 用不上 `created_at` 的索引,改成范围条件 `created_at >= ... AND created_at < ...`。
- **`LIKE '%关键词%'`** 用不上 B-tree 索引,需要 `pg_trgm` 的 GIN 索引或全文检索。
- **索引越多越好**:每个索引都会拖慢写入、占用空间、增加 VACUUM 工作量。
- **长事务**:事务里调用外部接口、等待用户输入,会长时间持有锁和旧快照。事务要短。
- **可重复读 / 可串行化不写重试**:序列化失败(SQLSTATE 40001)是正常现象,应用必须捕获并重试。
- **`CREATE INDEX CONCURRENTLY` 失败**会留下无效索引(INVALID),要删掉重建。
## 面试常问
见 [PostgreSQL 面试题](interview.md):MVCC 和 VACUUM、隔离级别的默认值、`SKIP LOCKED` 的用途、乐观锁和悲观锁。
@@ -0,0 +1,13 @@
module example/orderstate
go 1.25.0
require github.com/jackc/pgx/v5 v5.11.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
@@ -0,0 +1,26 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,97 @@
// Package orderstate 订单状态机:合法流转校验 + 乐观锁 + 审计日志(与状态修改在同一个事务里提交)。
package orderstate
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const Schema = `
CREATE TABLE IF NOT EXISTS orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending',
version INT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS order_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
from_status TEXT NOT NULL,
to_status TEXT NOT NULL,
actor TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
// 允许的状态流转:只有这里列出的才合法
var transitions = map[string][]string{
"pending": {"paid", "cancelled"},
"paid": {"shipped", "refunding"},
"shipped": {"completed"},
"refunding": {"refunded", "paid"},
}
var (
ErrIllegal = errors.New("不允许的状态流转")
ErrConflict = errors.New("订单已被其他操作修改,请刷新后重试")
ErrNotFound = errors.New("订单不存在")
)
func allowed(from, to string) bool {
for _, s := range transitions[from] {
if s == to {
return true
}
}
return false
}
type Order struct {
ID int64
Status string
Version int
}
func Get(ctx context.Context, db *pgxpool.Pool, id int64) (Order, error) {
var o Order
err := db.QueryRow(ctx, `SELECT id, status, version FROM orders WHERE id = $1`, id).Scan(&o.ID, &o.Status, &o.Version)
if errors.Is(err, pgx.ErrNoRows) {
return o, ErrNotFound
}
return o, err
}
// Transition 把订单从 cur 状态改成 to。cur 是调用方读到的版本(乐观锁)。
func Transition(ctx context.Context, db *pgxpool.Pool, cur Order, to, actor, reason string) (Order, error) {
if !allowed(cur.Status, to) {
return cur, fmt.Errorf("%s -> %s: %w", cur.Status, to, ErrIllegal)
}
tx, err := db.Begin(ctx)
if err != nil {
return cur, err
}
defer tx.Rollback(ctx) // 提交成功后再回滚是无害的
tag, err := tx.Exec(ctx,
`UPDATE orders SET status = $1, version = version + 1, updated_at = now()
WHERE id = $2 AND status = $3 AND version = $4`, to, cur.ID, cur.Status, cur.Version)
if err != nil {
return cur, err
}
if tag.RowsAffected() == 0 {
return cur, ErrConflict
}
if _, err := tx.Exec(ctx,
`INSERT INTO order_events (order_id, from_status, to_status, actor, reason) VALUES ($1, $2, $3, $4, $5)`,
cur.ID, cur.Status, to, actor, reason); err != nil {
return cur, err
}
if err := tx.Commit(ctx); err != nil {
return cur, err
}
return Order{ID: cur.ID, Status: to, Version: cur.Version + 1}, nil
}
@@ -0,0 +1,119 @@
package orderstate
import (
"context"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// 需要 PostgreSQL:TEST_DATABASE_URL=postgres://user:pass@localhost:5432/db?sslmode=disable go test ./...
func setup(t *testing.T) *pgxpool.Pool {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("未设置 TEST_DATABASE_URL,跳过集成测试")
}
ctx := context.Background()
schema := fmt.Sprintf("t_order_%d", time.Now().UnixNano())
admin, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
cfg, _ := pgxpool.ParseConfig(url)
cfg.ConnConfig.RuntimeParams["search_path"] = schema // 每次测试用独立的 schema
db, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
_, _ = admin.Exec(ctx, "DROP SCHEMA "+schema+" CASCADE")
admin.Close()
})
if _, err := db.Exec(ctx, Schema); err != nil {
t.Fatal(err)
}
return db
}
func newOrder(t *testing.T, db *pgxpool.Pool) Order {
t.Helper()
var id int64
if err := db.QueryRow(context.Background(), "INSERT INTO orders DEFAULT VALUES RETURNING id").Scan(&id); err != nil {
t.Fatal(err)
}
o, err := Get(context.Background(), db, id)
if err != nil {
t.Fatal(err)
}
return o
}
func TestAllowed(t *testing.T) {
if !allowed("pending", "paid") || allowed("pending", "shipped") || allowed("completed", "paid") {
t.Fatal("状态流转表不对")
}
}
func TestTransitionAndAudit(t *testing.T) {
db := setup(t)
ctx := context.Background()
o := newOrder(t, db)
o, err := Transition(ctx, db, o, "paid", "payment-callback", "")
if err != nil {
t.Fatal(err)
}
if _, err := Transition(ctx, db, o, "completed", "admin", ""); !errors.Is(err, ErrIllegal) {
t.Fatalf("paid -> completed 应该不允许,得到 %v", err)
}
var n int
_ = db.QueryRow(ctx, "SELECT count(*) FROM order_events WHERE order_id = $1", o.ID).Scan(&n)
if n != 1 {
t.Fatalf("审计日志条数 = %d", n)
}
}
func TestConcurrentCancelAndPay(t *testing.T) {
db := setup(t)
ctx := context.Background()
o := newOrder(t, db) // 两个操作读到的是同一个版本
var wg sync.WaitGroup
errs := make([]error, 2)
for i, to := range []string{"paid", "cancelled"} {
wg.Add(1)
go func() {
defer wg.Done()
_, errs[i] = Transition(ctx, db, o, to, "test", "")
}()
}
wg.Wait()
ok, conflict := 0, 0
for _, err := range errs {
switch {
case err == nil:
ok++
case errors.Is(err, ErrConflict):
conflict++
default:
t.Fatal(err)
}
}
if ok != 1 || conflict != 1 {
t.Fatalf("应该一个成功一个冲突,得到 ok=%d conflict=%d", ok, conflict)
}
cur, _ := Get(ctx, db, o.ID)
var n int
_ = db.QueryRow(ctx, "SELECT count(*) FROM order_events WHERE order_id = $1", o.ID).Scan(&n)
if cur.Version != 2 || n != 1 {
t.Fatalf("版本 %d,审计 %d 条", cur.Version, n)
}
}
+28 -3
View File
@@ -4,12 +4,37 @@ title: PostgreSQL
type: index
domain: backend
stack: [postgresql]
tags: []
tags: [数据库, SQL, PostgreSQL]
week: [2, 3, 6]
---
# PostgreSQL
表设计、SQL、索引与 EXPLAIN、事务与 MVCC、JSONB、pgvector。
功能最全面的开源关系数据库之一:标准 SQL 支持好、事务和并发控制严谨,还有 JSONB、全文检索、pgvector(向量检索)等扩展。本计划用它学 SQL 和事务,再对照学习 [MySQL](../mysql/index.md)。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [表设计和 SQL:约束、JOIN、CTE、窗口函数、JSONB、UPSERT](01-schema-sql.md) | 基础 | 4 小时 |
| 2 | [索引、EXPLAIN、事务、隔离级别和 MVCC](02-index-tx-mvcc.md) | 进阶 | 4 小时 |
## 企业场景
- [订单状态机:状态流转、乐观锁和审计日志](scenarios/order-state-audit.md)
## 面试题
- [PostgreSQL 面试题](interview.md)
## 示例代码
- [examples/order-state](examples/order-state/):Go + pgx 实现的订单状态流转(乐观锁 + 审计日志,同一事务提交);设置 `TEST_DATABASE_URL` 后运行集成测试
## 在 16 周计划中
- [第 2 周:数据库 + 鉴权](../../../plan/week02-db-auth.md)、[第 3 周:项目一](../../../plan/week03-project1.md)、[第 6 周:pgvector 对照](../../../plan/week06-rag-2.md)
## 官方资料
- PostgreSQL 中文文档:http://www.postgres.cn/docs/current/ ;英文文档:https://www.postgresql.org/docs/current/
+71
View File
@@ -0,0 +1,71 @@
---
id: backend.postgresql.interview
title: PostgreSQL 面试题
type: interview
domain: backend
stack: [postgresql]
tags: [PostgreSQL, SQL, 事务, 面试]
level: intermediate
related: [backend.postgresql.schema-sql, backend.postgresql.index-tx-mvcc, backend.mysql.interview]
week: [2, 16]
---
# PostgreSQL 面试题
> 在本计划里的体现:`todo-api` / `task-board` 的数据层(第 2~3 周),pgvector 对照(第 6 周)。MySQL 相关题目见 [MySQL 面试题](../mysql/interview.md)。
## 基础
### 【基础】`WHERE` 和 `HAVING` 的区别?
**参考答案**:`WHERE` 在分组前过滤行,不能使用聚合函数;`HAVING` 在 `GROUP BY` 之后过滤分组,可以使用聚合函数。能用 `WHERE` 过滤的尽量用 `WHERE`,减少参与分组的数据量。
### 【基础】`INNER JOIN` 和 `LEFT JOIN` 的区别?LEFT JOIN 的条件写在 ON 和 WHERE 里有什么不同?
**参考答案**:内连接只保留两边都匹配的行;左连接保留左表所有行,右表没匹配的列为 NULL。右表的过滤条件写在 `ON` 里时,不匹配的左表行仍然保留;写在 `WHERE` 里会把右表为 NULL 的行过滤掉,效果等同内连接。
### 【基础】为什么 `NOT IN` 遇到 NULL 会出问题?
**参考答案**:`x NOT IN (a, b, NULL)` 等价于 `x <> a AND x <> b AND x <> NULL`,而 `x <> NULL` 的结果是 UNKNOWN,整个条件永远不为真,查询返回空。用 `NOT EXISTS` 或先过滤掉 NULL。
### 【基础】金额应该用什么类型?时间呢?
**参考答案**:金额用 `NUMERIC(p, s)` 或以“分”为单位的整数,不用浮点数(有精度误差)。时间用 `TIMESTAMPTZ`,统一存 UTC,展示时按用户时区转换。
### 【基础】什么是 UPSERT?为什么比“先查再插”安全?
**参考答案**:`INSERT ... ON CONFLICT (唯一键) DO UPDATE / DO NOTHING`,一条语句完成“有则更新、无则插入”。它依赖唯一约束在数据库内部原子判断;“先查再插”在并发下两个请求都可能查不到然后都插入。
## 进阶
### 【进阶】PostgreSQL 的 MVCC 是怎么实现的?VACUUM 是干什么的?
**参考答案**:更新时写入一个新版本的行,旧版本标记为对新事务不可见但保留给仍在使用旧快照的事务;每个事务根据快照判断哪些版本可见,所以读写互不阻塞。旧版本(死元组)需要 VACUUM 回收空间并更新可见性信息,autovacuum 会自动执行。长事务会阻止死元组被回收,导致表膨胀和性能下降。
**追问**:和 MySQL InnoDB 的 MVCC 有什么不同?→ InnoDB 原地更新、旧版本放在 undo log 里由 purge 线程清理;PostgreSQL 新旧版本都在表里,需要 VACUUM。
### 【进阶】PostgreSQL 默认的隔离级别是什么?可重复读下修改冲突会怎样?
**参考答案**:默认读已提交。可重复读下,事务看到的是开始时的快照;如果要更新的行在快照之后被其他事务修改并提交了,会报 `could not serialize access due to concurrent update`(SQLSTATE 40001),应用需要重试整个事务。
### 【进阶】一条 SQL 很慢,你怎么排查?
**参考答案**:先用 `EXPLAIN (ANALYZE, BUFFERS)` 看执行计划:是不是全表扫描、预估行数和实际行数是否差距大(统计信息过时就 `ANALYZE`)、哪个节点耗时最多。常见优化:按查询条件建联合索引(注意最左前缀和排序方向)、避免在索引列上用函数、用覆盖索引、改写子查询、分页改成游标(按上一页最后一个 ID 继续)。线上用 `pg_stat_statements` 找出总耗时最高的语句。
### 【进阶】`FOR UPDATE SKIP LOCKED` 有什么用?
**参考答案**:选中并锁定行,遇到已被其他事务锁住的行直接跳过。适合“多个 worker 并发从任务表取任务”:每个 worker 拿到不同的任务,互不阻塞,不需要额外的消息队列。
## 场景题
### 【场景题】订单表有几千万行,按用户查最近订单的分页越往后越慢,怎么优化?
**参考答案**:`OFFSET` 越大,数据库要扫描并丢弃的行越多。改成游标分页(keyset):`WHERE user_id = ? AND (created_at, id) < (上一页最后一条的值) ORDER BY created_at DESC, id DESC LIMIT 20`,配合 `(user_id, created_at DESC, id DESC)` 联合索引,每页都是索引范围扫描。代价是不能直接跳到第 N 页,通常用“加载更多”交互。更大规模时按时间分区,历史数据归档。
### 【场景题】线上要给一张大表加索引和加字段,怎么做才不影响业务?
**参考答案**:索引用 `CREATE INDEX CONCURRENTLY`(不阻塞写,不能在事务里执行,失败要删掉无效索引重建)。加字段:PostgreSQL 11 起加带常量默认值的列不需要重写表,很快;但要注意 DDL 需要短暂的排他锁,如果前面有长事务会排队并阻塞后续查询,所以设置 `lock_timeout`,在低峰期执行。迁移脚本要向后兼容:先加字段、发新代码,再清理旧字段。
### 【场景题】用户和支付回调同时修改订单状态,怎么保证状态正确?
**参考答案**:状态流转规则集中定义,修改用带版本号(或旧状态)的条件 UPDATE 做乐观锁,影响行数为 0 时视为冲突,重新读取最新状态再决定;审计日志与状态修改放在同一事务。冲突频繁的场景可以用 `SELECT ... FOR UPDATE` 排队。详见本技术栈的[订单状态机场景](scenarios/order-state-audit.md)。
@@ -0,0 +1,91 @@
---
id: backend.postgresql.scenario.order-state-audit
title: 订单状态机:状态流转、乐观锁和审计日志
type: scenario
domain: backend
stack: [postgresql, go]
tags: [状态机, 乐观锁, 审计, 并发, 事务]
level: intermediate
prerequisites: [backend.postgresql.index-tx-mvcc]
est_hours: 2
related: [backend.postgresql.interview, backend.mq.reliability]
week: [2, 3]
---
# 订单状态机:状态流转、乐观锁和审计日志
## 业务背景与问题
一个 B2B 订单系统,订单状态有待支付、已支付、已发货、已完成、已取消、退款中、已退款。线上出现过几类问题:
1. 用户点“取消”的同时支付回调到达,最后订单状态是“已取消”但钱已经扣了。
2. 运营在后台把“已完成”的订单误改回“已发货”,没人知道是谁改的。
3. 代码里到处是 `UPDATE orders SET status = ?`,什么状态能改成什么状态全靠各处的 if 判断,规则不一致。
## 约束
- 状态修改的并发主要来自:用户操作、支付 / 物流回调、运营后台、定时任务(超时取消)。
- 财务和客服要求:每一次状态变化都能查到“谁、什么时候、从什么状态改成什么状态、原因”。
- 不能引入太重的组件,先在数据库层解决。
## 方案设计
```mermaid
stateDiagram-v2
[*] --> pending
pending --> paid: 支付回调
pending --> cancelled: 用户取消 / 超时
paid --> shipped: 发货
paid --> refunding: 申请退款
refunding --> refunded: 退款成功
refunding --> paid: 退款驳回
shipped --> completed: 确认收货
```
1. **状态流转表集中定义**:一张“从哪个状态可以到哪个状态”的表,所有修改都走同一个 `Transition` 函数,非法流转直接拒绝。
2. **乐观锁**:`orders` 表加 `version` 列;修改时 `WHERE id = ? AND status = ? AND version = ?`,影响行数为 0 说明被别人抢先修改,返回“请刷新重试”或由调用方决定(例如支付回调遇到已取消的订单,进入“自动退款”流程)。
3. **审计日志**:`order_events` 表记录每次流转,和状态修改**在同一个事务里**提交,保证“状态改了就一定有记录”。
4. **对外通知**:状态变化后需要通知其他系统(发短信、更新搜索)时,把消息写进同一事务里的发件箱表,再由后台程序发送(见[消息可靠性](../../mq/03-reliability.md))。
## 关键代码
完整代码和并发测试见 [examples/order-state](../examples/order-state/):
```go
tag, err := tx.Exec(ctx,
`UPDATE orders SET status = $1, version = version + 1, updated_at = now()
WHERE id = $2 AND status = $3 AND version = $4`, to, cur.ID, cur.Status, cur.Version)
if tag.RowsAffected() == 0 {
return cur, ErrConflict // 被别人抢先修改了
}
_, err = tx.Exec(ctx, `INSERT INTO order_events (order_id, from_status, to_status, actor, reason)
VALUES ($1, $2, $3, $4, $5)`, cur.ID, cur.Status, to, actor, reason)
// 两条语句同一个事务提交
```
并发测试:同一个订单同时执行“支付”和“取消”,结果一定是一个成功、一个冲突,审计日志只有一条。
## 取舍
| 方案 | 适用 | 代价 |
| --- | --- | --- |
| **乐观锁(本方案)** | 冲突不频繁的业务修改 | 冲突时需要重试或提示用户 |
| 悲观锁 `SELECT ... FOR UPDATE` | 冲突频繁、必须排队执行 | 持锁期间其他事务等待,事务内不能做慢操作 |
| 只靠 `WHERE status = 旧状态` | 简单场景 | 无法区分“同状态下的两次修改”,版本号更通用 |
| 状态机框架 / 工作流引擎 | 流程复杂、经常变化 | 学习和运维成本 |
审计日志放数据库表而不是只写应用日志:可以按订单查询、可以和状态修改放进同一事务;数据量大时按月分区或归档。
## 易错点
- 先 `SELECT` 状态判断、再 `UPDATE` 不带条件:两步之间别人可能已经改了。条件必须写进 `UPDATE` 的 `WHERE`。
- 审计日志在事务外写:状态改成功了但日志写失败(或反过来),数据对不上。
- 事务里调用支付网关等外部接口:长事务持锁;外部调用放在事务外,用状态(如“退款中”)表示进行中。
- 冲突后盲目自动重试:要先重新读最新状态,再判断这次操作是否还有意义。
- 定时任务批量“超时取消”时,也必须走 `Transition`,不能直接批量 `UPDATE`。
## 面试怎么讲
> “订单状态曾经出现过并发问题,比如用户取消和支付回调同时到达。我把状态流转规则集中成一张表,所有修改都走一个函数:先校验流转是否合法,再用带版本号的条件 UPDATE 做乐观锁,影响行数为 0 就是冲突;同一个事务里写审计日志,保证每次变化都有记录。写了并发测试,同时发起支付和取消,验证一定是一个成功一个冲突。”
可能的追问:为什么不用悲观锁?支付成功但订单已取消怎么处理(自动退款流程,人工兜底)?审计表越来越大怎么办(分区、归档)?
+102
View File
@@ -0,0 +1,102 @@
---
id: backend.redis.data-structures
title: 数据结构和常用命令:String、Hash、List、Set、ZSet、Stream
type: lesson
domain: backend
stack: [redis]
tags: [String, Hash, ZSet, Stream, 过期时间]
level: basic
est_hours: 2
related: [backend.redis.cache-patterns, backend.redis.interview]
week: [3]
order: 1
---
# 数据结构和常用命令:String、Hash、List、Set、ZSet、Stream
## 解释说明
| 类型 | 是什么 | 常见用途 | 常用命令 |
| --- | --- | --- | --- |
| String | 字符串 / 数字 | 缓存 JSON、计数器、验证码 | `SET k v EX 60 NX`、`GET`、`INCR`、`INCRBY` |
| Hash | 字段 → 值 | 对象(用户资料、购物车) | `HSET`、`HGET`、`HGETALL`、`HINCRBY` |
| List | 双端链表 | 最新列表、简单队列 | `LPUSH`、`RPOP`、`LRANGE`、`BRPOP` |
| Set | 无序去重集合 | 标签、点赞用户、共同好友 | `SADD`、`SISMEMBER`、`SINTER` |
| ZSet | 按分数排序的集合 | 排行榜、延迟队列(分数=时间) | `ZADD`、`ZINCRBY`、`ZREVRANGE`、`ZRANGEBYSCORE` |
| Stream | 追加式日志,支持消费组 | 轻量消息队列 | `XADD`、`XREADGROUP`、`XACK` |
还有 Bitmap(签到)、HyperLogLog(UV 近似计数)、Geo(附近的人)。
**关键概念**
- **单线程执行命令**:每条命令是原子的;多条命令要原子执行用 Lua 脚本或 `MULTI/EXEC`。
- **过期时间**:`EX` 秒 / `PX` 毫秒;`TTL` 查看剩余时间。过期删除 = 惰性删除(访问时检查)+ 定期抽样删除。
- **内存淘汰策略**:内存满时按 `maxmemory-policy` 淘汰,缓存场景常用 `allkeys-lru` 或 `allkeys-lfu`。
- **持久化**:RDB(定时快照)和 AOF(追加命令日志),可以同时开启。
## 案例
- 文章阅读数:`INCR article:42:views`,定时批量写回数据库。
- 积分排行榜:`ZINCRBY rank:2026-10 10 user:7`,`ZREVRANGE rank:2026-10 0 9 WITHSCORES` 取前 10。
- 登录验证码:`SET sms:13800000000 123456 EX 300 NX`,NX 保证 5 分钟内不重复发送。
## 代码示例
```go
package main
import (
"context"
"fmt"
"os"
"github.com/redis/go-redis/v9"
)
func main() {
url := os.Getenv("REDIS_URL") // 例如 redis://localhost:6379/0
if url == "" {
fmt.Println("未设置 REDIS_URL,跳过")
return
}
opt, err := redis.ParseURL(url)
if err != nil {
panic(err)
}
rdb := redis.NewClient(opt)
defer rdb.Close()
ctx := context.Background()
// 计数器
n, _ := rdb.Incr(ctx, "demo:views:42").Result()
fmt.Println("阅读数", n)
// 排行榜
rdb.ZIncrBy(ctx, "demo:rank", 30, "alice")
rdb.ZIncrBy(ctx, "demo:rank", 50, "bob")
rdb.ZIncrBy(ctx, "demo:rank", 10, "alice")
top, _ := rdb.ZRevRangeWithScores(ctx, "demo:rank", 0, 9).Result()
for i, z := range top {
fmt.Printf("第 %d 名 %v %.0f 分\n", i+1, z.Member, z.Score)
}
// 对象用 Hash
rdb.HSet(ctx, "demo:user:7", map[string]any{"name": "张三", "level": 3})
u, _ := rdb.HGetAll(ctx, "demo:user:7").Result()
fmt.Println(u["name"], u["level"])
rdb.Del(ctx, "demo:views:42", "demo:rank", "demo:user:7")
}
```
## 易错点
- **大 key**:一个 Hash / ZSet 里放几百万元素,`HGETALL`、`DEL` 会阻塞 Redis。拆分 key,删除用 `UNLINK`。
- **`KEYS *`**:线上会阻塞,用 `SCAN` 分批遍历。
- **忘记设过期时间**:缓存类 key 一律带 TTL,否则内存只增不减。
- **用 List 做可靠队列**:`RPOP` 后消费者崩溃,消息就丢了;需要可靠性用 Stream(消费组 + ACK)或专业 MQ。
- **把 Redis 当主存储**:持久化有丢数据窗口,重要数据以数据库为准。
## 面试常问
见 [Redis 面试题](interview.md):Redis 为什么快、有哪些数据结构和场景、过期和淘汰策略、持久化方式。
+176
View File
@@ -0,0 +1,176 @@
---
id: backend.redis.cache-patterns
title: 缓存模式:旁路缓存、穿透 / 击穿 / 雪崩、一致性
type: lesson
domain: backend
stack: [redis]
tags: [旁路缓存, 缓存穿透, 缓存击穿, 缓存雪崩, 一致性, singleflight]
level: intermediate
prerequisites: [backend.redis.data-structures]
est_hours: 3
related: [backend.redis.lock-ratelimit, backend.redis.interview]
week: [3, 6]
order: 2
---
# 缓存模式:旁路缓存、穿透 / 击穿 / 雪崩、一致性
## 解释说明
**旁路缓存(Cache Aside)**——最常用:
- 读:先读缓存,命中直接返回;未命中读数据库,写入缓存(带过期时间)再返回。
- 写:**先更新数据库,再删除缓存**(不是更新缓存)。下一次读自然会加载新值。
为什么删除而不是更新缓存:并发写时两个请求更新缓存的顺序可能和更新数据库的顺序不一致,导致缓存里留下旧值;删除更简单,也避免为不常读的数据计算缓存。
**三个经典问题**
| 问题 | 现象 | 解决 |
| --- | --- | --- |
| 穿透 | 查询**不存在**的数据,每次都打到数据库(常见于恶意请求) | 缓存空值(短 TTL);参数校验;布隆过滤器 |
| 击穿 | 一个**热点 key** 过期瞬间,大量请求同时查库 | 同一个 key 只让一个请求回源(singleflight / 互斥锁);热点 key 逻辑过期 |
| 雪崩 | **大量 key 同时过期**或 Redis 宕机,请求全部压到数据库 | 过期时间加随机值;Redis 高可用;限流降级 |
**一致性**:“先更新库再删缓存”仍有极小概率不一致(读请求在删除前读到旧值并在删除后写回)。常见补充:延迟双删(更新后过一会儿再删一次)、订阅 binlog 异步删除缓存、给缓存设较短 TTL 兜底。缓存只能做到**最终一致**,强一致需求(如余额)不要读缓存。
## 案例
商品详情页 QPS 高,数据库扛不住:
- 详情缓存 10 分钟 + 0~60 秒随机值,防止同时过期。
- 不存在的商品 ID 缓存空值 1 分钟,防穿透。
- 热门商品过期时用 singleflight 合并回源请求,防击穿。
- 后台修改商品后删除缓存。
## 代码示例
旁路缓存 + 空值缓存 + singleflight(`golang.org/x/sync/singleflight` 合并同一进程内的并发回源):
```go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"os"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
Price int64 `json:"price"`
}
var ErrNotFound = errors.New("商品不存在")
const nullValue = "null" // 空值标记,防穿透
type ProductCache struct {
rdb *redis.Client
group singleflight.Group
load func(ctx context.Context, id int64) (*Product, error) // 查数据库
}
func (c *ProductCache) Get(ctx context.Context, id int64) (*Product, error) {
key := fmt.Sprintf("product:%d", id)
s, err := c.rdb.Get(ctx, key).Result()
if err == nil {
if s == nullValue {
return nil, ErrNotFound
}
var p Product
if err := json.Unmarshal([]byte(s), &p); err == nil {
return &p, nil
}
} else if !errors.Is(err, redis.Nil) {
// Redis 出错时降级直接查库(也可以选择返回错误,看业务)
return c.load(ctx, id)
}
// 未命中:同一个 key 的并发请求只有一个去查库
v, err, _ := c.group.Do(key, func() (any, error) {
p, err := c.load(ctx, id)
if errors.Is(err, ErrNotFound) {
c.rdb.Set(ctx, key, nullValue, time.Minute)
return nil, err
}
if err != nil {
return nil, err
}
b, _ := json.Marshal(p)
ttl := 10*time.Minute + time.Duration(rand.IntN(60))*time.Second // 随机过期,防雪崩
c.rdb.Set(ctx, key, b, ttl)
return p, nil
})
if err != nil {
return nil, err
}
return v.(*Product), nil
}
// Update:先更新数据库,再删除缓存
func (c *ProductCache) Update(ctx context.Context, p *Product, save func(*Product) error) error {
if err := save(p); err != nil {
return err
}
return c.rdb.Del(ctx, fmt.Sprintf("product:%d", p.ID)).Err()
}
func main() {
url := os.Getenv("REDIS_URL")
if url == "" {
fmt.Println("未设置 REDIS_URL,跳过")
return
}
opt, _ := redis.ParseURL(url)
rdb := redis.NewClient(opt)
defer rdb.Close()
ctx := context.Background()
rdb.Del(ctx, "product:1", "product:404")
var dbHits atomic.Int32
c := &ProductCache{rdb: rdb, load: func(ctx context.Context, id int64) (*Product, error) {
dbHits.Add(1)
time.Sleep(50 * time.Millisecond) // 模拟慢查询
if id == 404 {
return nil, ErrNotFound
}
return &Product{ID: id, Name: "键盘", Price: 19900}, nil
}}
var wg sync.WaitGroup
for range 100 { // 100 个并发请求同一个未缓存的商品
wg.Add(1)
go func() { defer wg.Done(); _, _ = c.Get(ctx, 1) }()
}
wg.Wait()
fmt.Println("100 个并发请求,查库次数:", dbHits.Load()) // 1
_, err := c.Get(ctx, 404)
_, err2 := c.Get(ctx, 404)
fmt.Println(err, err2, "查库次数:", dbHits.Load()) // 第二次命中空值缓存,仍是 2
rdb.Del(ctx, "product:1", "product:404")
}
```
## 易错点
- **先删缓存再更新数据库**:删除后、更新前有读请求进来,会把旧值重新写进缓存,不一致窗口更大。
- **更新数据库后更新缓存**:并发写时可能写入旧值;且很多数据写多读少,白白计算。
- **空值缓存 TTL 太长**:数据后来被创建了,用户仍然看到“不存在”。
- **singleflight 只在单个进程内生效**:多实例部署时每个实例仍会各回源一次,一般可以接受;需要全局只回源一次时用分布式锁。
- **缓存序列化格式变了**:新版本读不了旧缓存,key 带版本号(如 `product:v2:1`)。
- **所有接口都上缓存**:缓存带来一致性问题,只给读多写少、能接受短暂旧数据的接口加。
## 面试常问
见 [Redis 面试题](interview.md):穿透 / 击穿 / 雪崩、缓存和数据库一致性、为什么删缓存而不是更新缓存。
+117
View File
@@ -0,0 +1,117 @@
---
id: backend.redis.lock-ratelimit
title: 分布式锁和限流:SET NX、Lua、令牌释放、固定窗口与滑动窗口
type: lesson
domain: backend
stack: [redis]
tags: [分布式锁, Lua, 限流, 滑动窗口, 令牌桶]
level: intermediate
prerequisites: [backend.redis.data-structures]
est_hours: 3
related: [backend.redis.scenario.stock-reservation, backend.redis.interview, backend.gin.middleware-auth]
week: [3, 14]
order: 3
---
# 分布式锁和限流:SET NX、Lua、令牌释放、固定窗口与滑动窗口
## 解释说明
**分布式锁**:多个服务实例之间互斥,例如“同一时间只有一个实例执行定时任务”“同一个订单只处理一次回调”。
- 加锁:`SET lock:key <随机令牌> NX PX 30000`。`NX` 保证只有一个人能设置成功,`PX` 设置过期时间防止持有者崩溃后锁永远不释放。**加锁和设置过期必须是一条命令**(不要 `SETNX` 再 `EXPIRE`)。
- 解锁:用 Lua 脚本“GET 比较令牌 → 相等才 DEL”,保证只删自己的锁。直接 `DEL` 可能删掉别人的锁:自己执行超时、锁已过期被别人拿到后,再 `DEL` 就把别人的锁删了。
- 过期时间怎么定:大于业务最长执行时间;执行时间不确定时由后台协程定期续期(Redisson 的“看门狗”就是这个思路)。
- 局限:Redis 主从切换时锁可能丢失。对正确性要求极高的场景,用数据库唯一约束 / 乐观锁兜底,或者在资源侧校验“防护令牌”(递增的版本号)。
**限流算法**
| 算法 | 思路 | 特点 |
| --- | --- | --- |
| 固定窗口 | `INCR rl:user:1:202610231530`,首次设置 60 秒过期,超过阈值拒绝 | 最简单;窗口交界处可能瞬间放过 2 倍流量 |
| 滑动窗口 | ZSET 存每次请求时间,删掉窗口外的再计数 | 精确;每次请求占一个元素,内存随请求量增长 |
| 令牌桶 | 按固定速率放令牌,请求取令牌,允许一定突发 | 平滑且允许突发;实现稍复杂 |
| 漏桶 | 请求进队列,按固定速率处理 | 输出最平滑,不允许突发 |
单实例服务用进程内限流就够(Go 的 `golang.org/x/time/rate` 是令牌桶);多实例要共享计数时才用 Redis。
**为什么用 Lua**:多条命令(判断 + 修改)需要原子执行。Redis 执行 Lua 脚本期间不会执行其他命令,所以脚本要短小,不能有耗时循环。
## 案例
- 支付回调可能重复到达:处理前 `TryLock("lock:pay-callback:" + orderID, 30s)`,拿不到锁直接返回“处理中”;真正的幂等仍由数据库状态和唯一约束保证。
- 登录接口防暴力破解:同一 IP 每分钟最多 10 次、同一账号每小时最多 20 次,用滑动窗口。
- 调用大模型 API:按租户限流,避免某个租户把额度用光。
## 代码示例
完整代码和测试在 [examples/stock-lock-ratelimit](examples/stock-lock-ratelimit/)(`lock.go`、`ratelimit.go`),下面是核心片段:
```go
// 只有令牌一致才删除,防止误删别人的锁
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0`)
func TryLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) {
token := randomToken()
ok, err := rdb.SetNX(ctx, key, token, ttl).Result() // SET key token NX PX ttl
if err != nil {
return nil, err
}
if !ok {
return nil, ErrLockNotAcquired
}
return &Lock{rdb: rdb, key: key, token: token}, nil
}
```
滑动窗口限流脚本:
```lua
local key, now, window, limit, member = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), ARGV[4]
redis.call("ZREMRANGEBYSCORE", key, 0, now - window) -- 删掉窗口外的请求
if redis.call("ZCARD", key) >= limit then
return 0 -- 超限
end
redis.call("ZADD", key, now, member)
redis.call("PEXPIRE", key, window)
return 1
```
在 Gin 里接成中间件(示意):
```go
func RateLimit(sw *stocklock.SlidingWindow) gin.HandlerFunc {
return func(c *gin.Context) {
ok, err := sw.Allow(c.Request.Context(), "rl:ip:"+c.ClientIP(), time.Now())
if err != nil { // Redis 故障时放行还是拒绝,要按业务决定(这里选择放行并记录日志)
log.Printf("限流检查失败:%v", err)
c.Next()
return
}
if !ok {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 42900, "message": "请求太频繁,请稍后再试"})
return
}
c.Next()
}
}
```
测试里验证了:锁不能被重复获取;锁过期后旧持有者解锁不会删掉新持有者的锁;滑动窗口限 3 次时第 4、5 次被拒,窗口滑过后放行。
## 易错点
- **`SETNX` 和 `EXPIRE` 分两条命令**:中间崩溃就留下永不过期的锁。
- **解锁直接 `DEL`**:可能删掉别人的锁。
- **锁的过期时间小于业务执行时间**:两个实例同时执行。要么设足够长,要么续期,并且业务本身做幂等。
- **把分布式锁当成唯一的正确性保证**:网络分区、GC 停顿、主从切换都可能导致锁失效,关键数据要有数据库层面的约束兜底。
- **限流 key 没有过期时间**:key 越来越多。
- **按 `X-Forwarded-For` 限流却没配置可信代理**:客户端可以伪造 IP 绕过(Gin 用 `SetTrustedProxies` 配置)。
## 面试常问
见 [Redis 面试题](interview.md):分布式锁怎么实现、为什么要用 Lua、锁过期了业务还没执行完怎么办、几种限流算法的区别。
@@ -0,0 +1,11 @@
module example/stocklock
go 1.26.0
require github.com/redis/go-redis/v9 v9.23.0
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
go.uber.org/atomic v1.12.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
@@ -0,0 +1,20 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBPL3s=
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
@@ -0,0 +1,50 @@
// Package stocklock 演示 Redis 的三个高频用法:分布式锁、滑动窗口限流、库存预扣。
package stocklock
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"time"
"github.com/redis/go-redis/v9"
)
var ErrLockNotAcquired = errors.New("没有拿到锁")
// 只有持有者(令牌一致)才能释放,防止误删别人的锁
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0`)
type Lock struct {
rdb *redis.Client
key string
token string
}
// TryLock 用 SET key token NX PX ttl 加锁。ttl 必须大于业务最长执行时间(或者自己续期)。
func TryLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return nil, err
}
token := hex.EncodeToString(b)
ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return nil, err
}
if !ok {
return nil, ErrLockNotAcquired
}
return &Lock{rdb: rdb, key: key, token: token}, nil
}
// Unlock 返回 false 表示锁已经过期或被别人持有(说明业务执行超过了 ttl,需要排查)
func (l *Lock) Unlock(ctx context.Context) (bool, error) {
n, err := unlockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Int()
return n == 1, err
}
@@ -0,0 +1,42 @@
package stocklock
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
// 滑动窗口:ZSET 里存每次请求的时间戳(毫秒),先删掉窗口外的,再数窗口内的
var slidingScript = redis.NewScript(`
local key, now, window, limit, member = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), ARGV[4]
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
if redis.call("ZCARD", key) >= limit then
return 0
end
redis.call("ZADD", key, now, member)
redis.call("PEXPIRE", key, window)
return 1`)
type SlidingWindow struct {
rdb *redis.Client
limit int
window time.Duration
seq func() string // 生成唯一成员,避免同一毫秒的请求互相覆盖
}
func NewSlidingWindow(rdb *redis.Client, limit int, window time.Duration) *SlidingWindow {
var n atomic.Uint64 // 并发安全的序号;多实例部署时成员里再加上实例标识或用 UUID
return &SlidingWindow{rdb: rdb, limit: limit, window: window, seq: func() string {
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), n.Add(1))
}}
}
// Allow 判断 key(如 "rl:user:42")在当前窗口内是否还能通过
func (s *SlidingWindow) Allow(ctx context.Context, key string, now time.Time) (bool, error) {
n, err := slidingScript.Run(ctx, s.rdb, []string{key},
now.UnixMilli(), s.window.Milliseconds(), s.limit, s.seq()).Int()
return n == 1, err
}
@@ -0,0 +1,40 @@
package stocklock
import (
"context"
"errors"
"github.com/redis/go-redis/v9"
)
var (
ErrSoldOut = errors.New("库存不足")
ErrDuplicate = errors.New("每人限购一件,已经抢过了")
)
// 原子地:检查是否已购买 → 检查库存 → 扣库存 → 记录用户
var reserveScript = redis.NewScript(`
if redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1 then
return -2
end
local left = tonumber(redis.call("GET", KEYS[1]) or "0")
if left <= 0 then
return -1
end
redis.call("DECR", KEYS[1])
redis.call("SADD", KEYS[2], ARGV[1])
return left - 1`)
// Reserve 预扣库存。成功后应把订单消息发到 MQ,由消费者异步落库。
func Reserve(ctx context.Context, rdb *redis.Client, sku, userID string) (left int, err error) {
n, err := reserveScript.Run(ctx, rdb, []string{"stock:" + sku, "stock:" + sku + ":buyers"}, userID).Int()
switch {
case err != nil:
return 0, err
case n == -1:
return 0, ErrSoldOut
case n == -2:
return 0, ErrDuplicate
}
return n, nil
}
@@ -0,0 +1,120 @@
package stocklock
import (
"context"
"errors"
"fmt"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/redis/go-redis/v9"
)
// 需要 Redis:REDIS_URL=redis://localhost:6379/0 go test ./...
func client(t *testing.T) (*redis.Client, string) {
t.Helper()
url := os.Getenv("REDIS_URL")
if url == "" {
t.Skip("未设置 REDIS_URL,跳过集成测试")
}
opt, err := redis.ParseURL(url)
if err != nil {
t.Fatal(err)
}
rdb := redis.NewClient(opt)
prefix := fmt.Sprintf("test:%d:", time.Now().UnixNano())
t.Cleanup(func() {
ctx := context.Background()
iter := rdb.Scan(ctx, 0, prefix+"*", 100).Iterator()
for iter.Next(ctx) {
rdb.Del(ctx, iter.Val())
}
rdb.Close()
})
return rdb, prefix
}
func TestLock(t *testing.T) {
rdb, p := client(t)
ctx := context.Background()
l1, err := TryLock(ctx, rdb, p+"lock", 200*time.Millisecond)
if err != nil {
t.Fatal(err)
}
if _, err := TryLock(ctx, rdb, p+"lock", time.Second); !errors.Is(err, ErrLockNotAcquired) {
t.Fatalf("锁被重复获取:%v", err)
}
time.Sleep(300 * time.Millisecond) // 锁过期,别人拿到锁
l2, err := TryLock(ctx, rdb, p+"lock", time.Second)
if err != nil {
t.Fatal(err)
}
if ok, _ := l1.Unlock(ctx); ok {
t.Fatal("过期的旧持有者不应该删掉新持有者的锁")
}
if ok, _ := l2.Unlock(ctx); !ok {
t.Fatal("持有者应该能释放锁")
}
}
func TestSlidingWindow(t *testing.T) {
rdb, p := client(t)
ctx := context.Background()
sw := NewSlidingWindow(rdb, 3, time.Second)
now := time.Now()
var got []bool
for i := range 5 {
ok, err := sw.Allow(ctx, p+"rl", now.Add(time.Duration(i)*10*time.Millisecond))
if err != nil {
t.Fatal(err)
}
got = append(got, ok)
}
if fmt.Sprint(got) != "[true true true false false]" {
t.Fatalf("窗口内限 3 次,得到 %v", got)
}
// 1.1 秒后前面的请求滑出窗口
if ok, _ := sw.Allow(ctx, p+"rl", now.Add(1100*time.Millisecond)); !ok {
t.Fatal("窗口滑过后应该放行")
}
}
func TestReserveNoOversell(t *testing.T) {
rdb, p := client(t)
ctx := context.Background()
sku := p + "sku1"
rdb.Set(ctx, "stock:"+sku, 10, 0)
t.Cleanup(func() { rdb.Del(ctx, "stock:"+sku, "stock:"+sku+":buyers") })
var success, soldOut, dup atomic.Int32
var wg sync.WaitGroup
for i := range 100 { // 100 个用户抢 10 件
for range 2 { // 每个用户重复点两次
wg.Add(1)
go func() {
defer wg.Done()
_, err := Reserve(ctx, rdb, sku, fmt.Sprint("u", i))
switch {
case err == nil:
success.Add(1)
case errors.Is(err, ErrSoldOut):
soldOut.Add(1)
case errors.Is(err, ErrDuplicate):
dup.Add(1)
default:
t.Error(err)
}
}()
}
}
wg.Wait()
left, _ := rdb.Get(ctx, "stock:"+sku).Int()
buyers, _ := rdb.SCard(ctx, "stock:"+sku+":buyers").Result()
if success.Load() != 10 || left != 0 || buyers != 10 {
t.Fatalf("成功 %d,剩余 %d,购买人数 %d", success.Load(), left, buyers)
}
t.Logf("成功 %d,售罄 %d,重复 %d", success.Load(), soldOut.Load(), dup.Load())
}
+31 -3
View File
@@ -4,12 +4,40 @@ title: Redis
type: index
domain: backend
stack: [redis]
tags: []
tags: [缓存, Redis, 分布式锁, 限流]
week: [3, 6, 14]
---
# Redis
数据结构、缓存模式、分布式锁、限流、Streams。
内存数据库,后端面试必考:缓存、计数、排行榜、分布式锁、限流、简单队列。本计划在项目一里做缓存和限流(第 3 周),在 RAG 服务里缓存检索结果(第 6 周),第 14 周补齐高并发相关题目。
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
## 学习路线
| 顺序 | 课程 | 级别 | 预计 |
| --- | --- | --- | --- |
| 1 | [数据结构和常用命令:String、Hash、List、Set、ZSet、Stream](01-data-structures.md) | 基础 | 2 小时 |
| 2 | [缓存模式:旁路缓存、穿透 / 击穿 / 雪崩、一致性](02-cache-patterns.md) | 进阶 | 3 小时 |
| 3 | [分布式锁和限流:SET NX、Lua、令牌释放、固定窗口与滑动窗口](03-lock-ratelimit.md) | 进阶 | 3 小时 |
## 企业场景
- [抢购库存预扣:Lua 原子扣减、防重复下单、异步落库](scenarios/stock-reservation.md)
## 面试题
- [Redis 面试题](interview.md)
## 示例代码
- [examples/stock-lock-ratelimit](examples/stock-lock-ratelimit/):Go + go-redis 实现库存预扣、分布式锁、滑动窗口限流;设置 `REDIS_URL` 后运行集成测试
## 在 16 周计划中
- [第 3 周:项目一(缓存、限流)](../../../plan/week03-project1.md)、[第 6 周:RAG 服务缓存](../../../plan/week06-rag-2.md)、[第 14 周:后端扩展](../../../plan/week14-backend-extend-deploy.md)
## 官方资料
- Redis 文档:https://redis.io/docs/latest/
- 命令参考:https://redis.io/docs/latest/commands/
- go-redis:https://redis.uptrace.dev/
+65
View File
@@ -0,0 +1,65 @@
---
id: backend.redis.interview
title: Redis 面试题
type: interview
domain: backend
stack: [redis]
tags: [Redis, 缓存, 分布式锁, 面试]
level: intermediate
related: [backend.redis.data-structures, backend.redis.cache-patterns, backend.redis.lock-ratelimit]
week: [3, 14, 16]
---
# Redis 面试题
## 基础
### 【基础】Redis 为什么快?
**参考答案**:数据在内存里;核心命令执行是单线程的,没有锁竞争和线程切换;使用 IO 多路复用(epoll)同时处理大量连接;数据结构针对场景做了优化(如压缩列表、跳表)。Redis 6 起网络读写可以多线程,命令执行仍是单线程。
### 【基础】Redis 有哪些数据类型?各举一个使用场景。
**参考答案**:String(缓存、计数器、验证码)、Hash(对象、购物车)、List(最新消息列表)、Set(标签、去重、共同关注)、ZSet(排行榜、延迟队列)、Stream(轻量消息队列);另有 Bitmap(签到)、HyperLogLog(UV 统计)、Geo(附近的人)。
### 【基础】过期的 key 是怎么删除的?内存满了怎么办?
**参考答案**:惰性删除(访问时发现过期就删)+ 定期删除(周期性抽样检查过期 key)。内存达到 `maxmemory` 时按淘汰策略处理:`noeviction`(拒绝写入)、`allkeys-lru`、`allkeys-lfu`、`volatile-lru`(只淘汰设置了过期的 key)等,缓存场景常用 `allkeys-lru` / `allkeys-lfu`。
### 【基础】RDB 和 AOF 的区别?
**参考答案**:RDB 定时生成内存快照,文件小、恢复快,但可能丢失最后一次快照之后的数据;AOF 追加记录写命令,可配置每秒刷盘,丢数据更少,但文件更大,需要重写压缩。生产常两者都开,Redis 4 起支持混合持久化。
## 进阶
### 【进阶】缓存穿透、击穿、雪崩分别是什么?怎么解决?
**参考答案**:穿透是查询不存在的数据,每次都打到数据库——缓存空值、参数校验、布隆过滤器。击穿是热点 key 过期瞬间大量请求同时回源——互斥锁 / singleflight 只让一个请求回源,或逻辑过期后台刷新。雪崩是大量 key 同时过期或 Redis 不可用——过期时间加随机值、Redis 高可用、服务限流降级。
### 【进阶】怎么保证缓存和数据库的一致性?
**参考答案**:常用旁路缓存:先更新数据库,再删除缓存。删除而不是更新,是为了避免并发写导致缓存写入旧值。仍有极小概率不一致,可以用延迟双删、订阅 binlog 异步删除、短 TTL 兜底。缓存只能做到最终一致,要求强一致的数据(余额、库存最终值)以数据库为准。
**追问**:为什么不先删缓存再更新数据库?→ 删除后、更新前的读请求会把旧值写回缓存,不一致窗口更大。
### 【进阶】Redis 分布式锁怎么实现?有什么问题?
**参考答案**:`SET key 随机令牌 NX PX 过期时间` 加锁;用 Lua 脚本比较令牌后删除来解锁,防止误删别人的锁。问题:业务执行时间超过过期时间会导致两个持有者同时存在(续期 / 看门狗);主从异步复制,主节点宕机时锁可能丢失(Redlock 有争议);所以关键业务还要用数据库唯一约束、乐观锁等做最终保证。
### 【进阶】Redis 的主从、哨兵、集群分别解决什么问题?
**参考答案**:主从复制解决读扩展和数据冗余;哨兵(Sentinel)监控主节点,故障时自动选新主,解决高可用;Cluster 把数据按 16384 个槽分到多个主节点,解决容量和写扩展,每个主节点也可以有从节点。Cluster 下多 key 操作(包括 Lua)要求 key 在同一个槽,可以用哈希标签 `{}`。
## 场景题
### 【场景题】秒杀场景怎么防止超卖和重复购买?
**参考答案**:库存提前加载到 Redis;Lua 脚本原子完成“是否已购买 → 库存是否充足 → 扣减 → 记录用户”;成功后发 MQ 异步创建订单,数据库唯一约束兜底;失败或超时未支付回补库存;前端、网关层层限流。详见[库存预扣场景](scenarios/stock-reservation.md)。
### 【场景题】线上 Redis 突然变慢,怎么排查?
**参考答案**:`SLOWLOG GET` 看慢命令(`KEYS *`、大 key 的 `HGETALL` / `DEL`、复杂 Lua);`INFO` 看内存、连接数、命中率、是否在做 RDB / AOF 重写(fork 导致的卡顿);`--bigkeys` / `MEMORY USAGE` 找大 key;检查是否有热点 key 集中在一个节点。处理:改用 `SCAN`、`UNLINK`,拆分大 key,热点 key 加本地缓存。
### 【场景题】一个接口要按用户限流,每分钟最多 60 次,多实例部署,怎么做?
**参考答案**:用 Redis 共享计数。简单做法是固定窗口 `INCR` + 过期时间;要求精确时用 ZSET 滑动窗口(Lua 脚本里删除过期记录、计数、写入);允许突发用令牌桶。返回 429 并带上重试时间。要考虑 Redis 故障时的策略(放行还是拒绝)。详见[分布式锁和限流](03-lock-ratelimit.md)。
@@ -0,0 +1,85 @@
---
id: backend.redis.scenario.stock-reservation
title: 抢购库存预扣:Lua 原子扣减、防重复下单、异步落库
type: scenario
domain: backend
stack: [redis, mq, mysql]
tags: [秒杀, 库存, Lua, 防超卖, 削峰]
level: advanced
prerequisites: [backend.redis.lock-ratelimit]
est_hours: 3
related: [backend.redis.lock-ratelimit, backend.mq.reliability, backend.redis.interview]
week: [14]
---
# 抢购库存预扣:Lua 原子扣减、防重复下单、异步落库
## 业务背景与问题
电商的限时抢购活动:100 件商品,开抢瞬间几万个请求。之前直接在数据库里 `UPDATE stock SET num = num - 1 WHERE num > 0`,结果数据库行锁排队、连接池被占满,连带其他业务一起变慢;还出现过同一个用户买了多件。
## 约束
- 绝对不能超卖;每人限购 1 件。
- 抢购接口要快速返回结果(成功进入下单 / 已抢光)。
- 数据库是最终的数据来源,Redis 可能丢数据。
- 不能影响其他业务。
## 方案设计
```mermaid
flowchart LR
U[用户] --> N[Nginx / 网关限流] --> A[抢购接口]
A -->|Lua 原子预扣| R[(Redis 库存 + 已购用户)]
A -->|预扣成功:发消息| Q[[消息队列]]
Q --> C[下单消费者] -->|事务:建订单 + 扣数据库库存| D[(MySQL)]
C -->|失败:回补 Redis 库存| R
```
1. **层层削峰**:前端按钮防重复点击、网关按用户 / IP 限流、活动未开始直接拒绝。
2. **Redis 预扣**:活动开始前把库存加载到 Redis;Lua 脚本原子执行“是否已购买 → 库存是否大于 0 → 扣减 → 记录用户”。
3. **异步下单**:预扣成功后发消息到 MQ,接口立即返回“抢购成功,订单生成中”;消费者按数据库能承受的速度建订单,用数据库唯一约束 `(活动ID, 用户ID)` 兜底防重复。
4. **对账和回补**:下单失败或超时未支付时回补 Redis 库存;定时对账 Redis 和数据库的库存。
## 关键代码
完整代码和并发测试见 [examples/stock-lock-ratelimit](../examples/stock-lock-ratelimit/)(`stock.go`):
```lua
-- KEYS[1] = 库存,KEYS[2] = 已购用户集合,ARGV[1] = 用户 ID
if redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1 then
return -2 -- 已经买过
end
local left = tonumber(redis.call("GET", KEYS[1]) or "0")
if left <= 0 then
return -1 -- 售罄
end
redis.call("DECR", KEYS[1])
redis.call("SADD", KEYS[2], ARGV[1])
return left - 1
```
测试:100 个用户各点两次、共 200 个并发请求抢 10 件,结果固定是成功 10 次、剩余库存 0、购买人数 10。
## 取舍
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| 数据库条件更新 | 简单、强一致 | 高并发下行锁排队,拖垮数据库 |
| **Redis Lua 预扣 + MQ 异步下单** | 扛高并发、保护数据库 | 链路长,要处理 Redis 与数据库不一致、消息丢失 |
| Redis `DECR` 后判断负数再 `INCR` 回去 | 不用 Lua | 不好同时做限购;有短暂负数 |
| 分布式锁包住“查 + 扣” | 好理解 | 所有请求串行抢锁,吞吐很低 |
## 易错点
- 先 `GET` 判断再 `DECR`(不是原子的)→ 超卖。
- 预扣成功但消息发送失败 → 库存少了但没有订单。处理:发送失败时回补库存,或者使用可靠消息(见[消息可靠性](../../mq/03-reliability.md))。
- 消费者重复消费 → 重复建单。用唯一约束和幂等处理。
- 用户抢到后不支付 → 超时取消订单并回补库存(延迟消息,见 MQ 专题)。
- Redis Cluster 下 Lua 脚本涉及的多个 key 必须在同一个槽:key 用相同的哈希标签,如 `stock:{sku1}` 和 `stock:{sku1}:buyers`。
## 面试怎么讲
> “抢购的核心是不超卖和保护数据库。我在 Redis 用 Lua 脚本把‘限购检查、库存检查、扣减、记录用户’做成一个原子操作,预扣成功再发 MQ 异步建订单,数据库用唯一约束兜底防重复;下单失败或超时未支付时回补库存,定时对账。我写了并发测试:200 个请求抢 10 件,成功数固定是 10。”
追问准备:Redis 宕机怎么办(主从 + 对账,极端情况下暂停活动)?为什么不用分布式锁?消息丢了怎么办?