主题
第12章 面试题精选 —— 工程实战
这是真实面试中项目细节追问的高频领域。
★ 基础题
Q1:你这个项目为什么分三层(handler / service / repository)?
答:关注点分离。
- Handler 只关心 HTTP 协议(解码请求、编码响应、状态码)
- Service 只关心业务规则(校验、组合、事务)
- Repository 只关心数据存储
好处:
- 换 HTTP 框架(标准库 → gin)不影响 service
- 换数据库(内存 → MySQL → Redis)不影响 service
- 各层可独立测试(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:怎么处理并发安全?
MemoryRepo 用 sync.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 / panic | 500 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 是什么?
- 健康检查
/health给 K8s liveness/readiness 用 - 日志结构化(slog/zap),输出到 stdout
- 指标:Prometheus
/metrics - 超时:HTTP server 各种超时、客户端超时、ctx 超时
- 优雅关闭:SIGTERM 处理
- 资源限制:max idle conn、max open conn、限流
- 熔断:上游挂了不要拖死自己
- 观测:metrics + logs + traces 三件套
- CI:单测、benchmark、go vet、staticcheck
- 安全:CORS 严格化、不要把内部错误返给客户端
Q16:怎么测一段并发代码?
用 -race:
bash
go test -race ./...Go 的竞态检测器会在运行期发现数据竞争。任何并发代码上线前都要跑一遍 -race。
📌 速记
三层架构 + 接口隔离 + 中间件 + 优雅关闭 + 可测试 = 工程级 Go 服务
🎯 自测练习
- 把
MemoryRepo替换成基于 SQLite 的SQLRepo - 加一个 JWT 认证中间件
- 给
/todos加分页:?page=1&size=20 - 用 Prometheus 暴露请求总数、错误率、延迟分布
- 写一个 benchmark 对比 sync.Mutex 和 sync.RWMutex 在你 repo 上的差异
- 用
testcontainers-go启动一个 MySQL 容器跑集成测试
🎓 全部 12 章完结撒花!
把这套文档过一遍,面试 / 工作中 80% 的 Go 问题你都能从容应对了。
继续探索:
🚀 Happy coding!