Skip to content

第12章 面试题精选 —— 工程实战

这是真实面试中项目细节追问的高频领域。


★ 基础题

Q1:你这个项目为什么分三层(handler / service / repository)?

关注点分离

  • Handler 只关心 HTTP 协议(解码请求、编码响应、状态码)
  • Service 只关心业务规则(校验、组合、事务)
  • Repository 只关心数据存储

好处:

  1. 换 HTTP 框架(标准库 → gin)不影响 service
  2. 换数据库(内存 → MySQL → Redis)不影响 service
  3. 各层可独立测试(service 用 mock repo)

Q2:为什么 service 依赖的是 Repository 接口而不是 *MemoryRepo

依赖倒置原则:高层模块(service)不应该依赖低层模块(repo),两者都应该依赖抽象(interface)。

go
type TodoService struct {
    repo Repository   // 接口,不是具体类型
}

测试时可以传入 mock:

go
type fakeRepo struct{}
func (f *fakeRepo) Create(t *Todo) (*Todo, error) { ... }
svc := NewTodoService(&fakeRepo{})

Q3:怎么处理并发安全?

MemoryReposync.RWMutex

  • 写操作(Create/Update/Delete)用 Lock()
  • 读操作(Get/List)用 RLock()

如果用 MySQL,则数据库本身负责并发控制。

go
func (r *MemoryRepo) List() []*Todo {
    r.mu.RLock()
    defer r.mu.RUnlock()
    // ...
}

Q4:json.NewDecoder(r.Body).Decode(&v)json.Unmarshal 好在哪?

  • 不需要先 io.ReadAll 整个 body 到内存(节省内存)
  • 流式解析,body 大时性能更好

但有个:解析完后,body 里如果有"额外字符",Decode 不会报错。如果想严格校验,要:

go
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&v); err != nil { ... }
if dec.More() { /* 有多余内容 */ }

Q5:你的 handler 怎么处理 panic?

Recovery 中间件:

go
func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w, r) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic: %v\n%s", rec, debug.Stack())
                http.Error(w, "internal server error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Go 标准库的 net/http 自身已经捕获过 panic 防止整个 server 崩,但只会打到 stderr,不能定制错误响应。所以业务自己再加一层 recovery。


★★ 进阶题

Q6:你怎么实现优雅关闭?

go
// 1. 启动 server(异步)
go srv.ListenAndServe()

// 2. 等待信号
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig

// 3. 停止接受新请求 + 等已有请求完成
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)

关键点

  • Shutdown 会立即停止 listener,但继续处理已建立连接
  • 用 context 限制最长等待时间(一般 10-30s)
  • K8s 滚动升级时,pod 会先收到 SIGTERM,要在 terminationGracePeriodSeconds 内完成

Q7:HTTP 服务的几个超时怎么设?

go
srv := &http.Server{
    Addr:              ":8080",
    Handler:           h,
    ReadHeaderTimeout: 3 * time.Second,   // 读 header 最长 3s
    ReadTimeout:       10 * time.Second,  // 读完整请求 10s
    WriteTimeout:      10 * time.Second,  // 写响应 10s
    IdleTimeout:       60 * time.Second,  // keep-alive 空闲 60s
    MaxHeaderBytes:    1 << 20,           // header 最大 1MB
}

不设的话,恶意 client 可以慢速攻击(slowloris)耗尽你的连接池。


Q8:你的项目怎么做单元测试?

handler:用 httptest

go
req := httptest.NewRequest("POST", "/todos", body)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != 201 { t.Fatal(...) }

service:传入 mock repo:

go
type stubRepo struct{ called bool }
func (s *stubRepo) Create(t *Todo) (*Todo, error) { s.called = true; return t, nil }

repo(内存):直接调方法即可。

repo(MySQL):用 testcontainers 启临时容器,或用 sqlmock。


Q9:handler 里出现错误,你怎么决定返回 4xx 还是 5xx?

核心规则

错误来源状态码
用户输入有问题400 Bad Request
用户没登录401 Unauthorized
登录了但没权限403 Forbidden
资源不存在404 Not Found
业务规则禁止(如重复操作)409 Conflict / 400
客户端请求过快429 Too Many Requests
服务端 bug / panic500 Internal Error
上游依赖挂了502 Bad Gateway
服务器临时不可用503 Service Unavailable
请求超时504 Gateway Timeout

errors.Is 把业务错误映射成 HTTP 状态

go
switch {
case errors.Is(err, service.ErrNotFound):
    writeError(w, 404, ...)
case errors.Is(err, service.ErrTitleEmpty):
    writeError(w, 400, ...)
default:
    writeError(w, 500, ...)
}

Q10:你这个内存存储,进程重启数据就丢了,怎么解决?

生产方案:换成持久化存储。设计已经预留了——只要新写一个 MySQLRepo 实现 Repository 接口即可:

go
type MySQLRepo struct { db *sql.DB }

func (r *MySQLRepo) Create(t *Todo) (*Todo, error) {
    res, err := r.db.Exec("INSERT INTO todos ...", t.Title)
    // ...
}

main 里只改一行:

go
repo := repository.NewMySQLRepo(db)

这就是分层 + 接口的好处:替换实现,零修改业务。


★★★ 深度题

Q11:怎么给项目加上链路追踪(Tracing)

用 OpenTelemetry:

go
import "go.opentelemetry.io/otel"

func TracingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w, r) {
        ctx, span := otel.Tracer("api").Start(r.Context(), r.URL.Path)
        defer span.End()
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

调用下游时把 ctx 传下去,每层自动接上 span。最后 export 到 Jaeger / Tempo。


Q12:怎么做限流

令牌桶(标准库实现):

go
import "golang.org/x/time/rate"

limiter := rate.NewLimiter(100, 200)  // 每秒 100 个,突发 200

func RateLimit(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w, r) {
        if !limiter.Allow() {
            http.Error(w, "too many requests", 429)
            return
        }
        next.ServeHTTP(w, r)
    })
}

更复杂的可以用 Redis 做集群级限流。


Q13:怎么避免 N+1 查询问题?

例如:列出所有 todo 时,每个再查 user。

坏方法:循环里查

go
for _, t := range todos {
    t.User = userRepo.Get(t.UserID)  // N 次查询!
}

好方法:批量查 + 拼接

go
ids := []int{}
for _, t := range todos { ids = append(ids, t.UserID) }
users := userRepo.GetByIDs(ids)  // 一次查询
userMap := mapByID(users)
for _, t := range todos {
    t.User = userMap[t.UserID]
}

Q14:怎么做配置管理

简单:flag + env

go
port := flag.Int("port", 8080, "...")
dbURL := os.Getenv("DB_URL")

复杂:viper + config.yaml

yaml
server:
  port: 8080
db:
  dsn: "user:pass@tcp(host)/db"

按"12-Factor App"原则:配置外置,不进二进制。


Q15:项目上线前你的 checklist 是什么?

  1. 健康检查 /health 给 K8s liveness/readiness 用
  2. 日志结构化(slog/zap),输出到 stdout
  3. 指标:Prometheus /metrics
  4. 超时:HTTP server 各种超时、客户端超时、ctx 超时
  5. 优雅关闭:SIGTERM 处理
  6. 资源限制:max idle conn、max open conn、限流
  7. 熔断:上游挂了不要拖死自己
  8. 观测:metrics + logs + traces 三件套
  9. CI:单测、benchmark、go vet、staticcheck
  10. 安全:CORS 严格化、不要把内部错误返给客户端

Q16:怎么测一段并发代码?

-race

bash
go test -race ./...

Go 的竞态检测器会在运行期发现数据竞争。任何并发代码上线前都要跑一遍 -race


📌 速记

三层架构 + 接口隔离 + 中间件 + 优雅关闭 + 可测试 = 工程级 Go 服务


🎯 自测练习

  1. MemoryRepo 替换成基于 SQLite 的 SQLRepo
  2. 加一个 JWT 认证中间件
  3. /todos 加分页:?page=1&size=20
  4. 用 Prometheus 暴露请求总数、错误率、延迟分布
  5. 写一个 benchmark 对比 sync.Mutex 和 sync.RWMutex 在你 repo 上的差异
  6. testcontainers-go 启动一个 MySQL 容器跑集成测试

🎓 全部 12 章完结撒花!

把这套文档过一遍,面试 / 工作中 80% 的 Go 问题你都能从容应对了。

继续探索:

🚀 Happy coding!