后端接口错误码到底该怎么设计?我见过最烂的和最优雅的两种方案
后端接口错误码到底该怎么设计?我见过最烂的和最优雅的两种方案
标签:
#接口设计#后端开发#最佳实践#代码规范
适合:所有写 RESTful / RPC 接口的后端开发者
一个真实故事
新人小王刚入职,接到一个需求:写一个"用户充值"接口。
写完接口,前端同学拿来调,调通后开始抓狂:
"为什么余额不足返回 -1,参数错误返回 400,账号封禁返回 -99999?" "errno 是数字还是字符串?我看你这接口给了我个 "0001"" "为什么有时候返回 {code: 0},有时候返回 {errno: 0}?" "成功时 data 是数组,失败时 data 是 null,我前端要写多少 if?"小王挠头。这就是缺少错误码规范的痛苦。
我见过最烂的 3 种错误码设计
烂方案 1:用 HTTP 状态码表达业务错误
HTTP/1.1 400 Bad Request {"error": "余额不足"}HTTP/1.1 403 Forbidden {"error": "兑换码已使用"}问题:
- HTTP 400/403/500 都是协议层错误,不该表达业务
- 网关、CDN、监控工具都会对 4xx/5xx 报警 → 业务正常报错变运维告警
- 接口聚合时(一个网关聚合多个接口)状态码冲突
- HTTP 状态码就那么几个,业务错误几百个,根本不够分
烂方案 2:错误码"野生生长"
// 接口 A:{"err":-1,"msg":"..."}// 接口 B:{"code":"FAIL","data":null}// 接口 C:{"status":false,"info":{...}}// 接口 D(成功):{"errno":0,"data":[...]}问题:
- 前端每个接口都要单独写错误判断
- 拦截器/统一处理写不出来
- 团队协作灾难
烂方案 3:错误码 + 错误消息散落各处
// handler1.goreturnResponse{Code:-1,Msg:"参数错误"}// handler2.goreturnResponse{Code:10001,Msg:"参数错"}// 同样的语义,不同的码和消息// handler3.goreturnResponse{Code:-1,Msg:"兑换码不存在"}// 复用了 -1,但语义不同问题:
- 错误码无法穷举
- 同一个错误在不同接口码不同
- 文档永远跟不上代码
最优雅的方案:集中注册的错误码体系
设计原则
- 错误码就是数字— 不要用字符串
- 业务错误一律 HTTP 200— HTTP 状态码只表达协议层错误
- 响应格式统一—
{errno, errmsg, data}三件套 - 错误码集中注册— 一个文件管所有错误码 + 对应文案
- 错误码按业务模块分段— 一眼看出错误来自哪个模块
实现示例
1. 集中注册错误码(errors.go)
packageerrorsvarErrorMessages=map[int]string{// ── 通用错误(10000-19999)──10001:"内部错误",10002:"参数错误",10003:"解析失败",10010:"验证失败",11001:"未登录",11002:"权限不足",// ── DB / 缓存错误(12000-13999)──12001:"数据库连接失败",12003:"数据库操作失败",13001:"Redis 操作失败",// ── 兑换业务(40000-40999)──40001:"兑换失败",40002:"兑换码无效",40003:"兑换码已被锁定",40005:"已购买过该商品",40007:"当前平台不支持兑换此商品",// ── 会员业务(50000-50999)──50001:"不是会员",50002:"会员已过期",50003:"权益已用完",// ── 限流(60000-60999)──60001:"请求过于频繁",}// Show 根据错误码渲染响应funcShow(codeint)[]byte{msg,ok:=ErrorMessages[code]if!ok{msg="未知错误"}res,_:=json.Marshal(map[string]interface{}{"errno":code,"errmsg":msg,"data":nil,})returnres}// ShowSuc 成功响应funcShowSuc(datainterface{})[]byte{res,_:=json.Marshal(map[string]interface{}{"errno":0,"errmsg":"ok","data":data,})returnres}2. 统一响应封装(response.go)
typeBaseResponseWriterstruct{}func(b*BaseResponseWriter)RenderJsonErrno(w http.ResponseWriter,codeint){w.Header().Set("Content-Type","application/json; charset=UTF-8")w.Write(errors.Show(code))}func(b*BaseResponseWriter)RenderJsonSuc(w http.ResponseWriter,datainterface{}){w.Header().Set("Content-Type","application/json; charset=UTF-8")w.Write(errors.ShowSuc(data))}3. Handler 调用(清爽到没朋友)
func(h*ExchangeHandler)Exchange(w http.ResponseWriter,r*http.Request){cdkey:=r.URL.Query().Get("cdkey")ifcdkey==""{h.RenderJsonErrno(w,10002)// 参数错误return}errno,data:=exchangeService.Exchange(userId,cdkey)iferrno!=0{h.RenderJsonErrno(w,errno)// 业务错误,直接返回错误码return}h.RenderJsonSuc(w,data)}4. Service 返回 (errno, data) 而不是 error
func(s*ExchangeService)Exchange(userId,cdkeystring)(int,*Result){record,err:=keyRepo.GetByCdkey(cdkey)iferr!=nil{return12003,nil}// DB 错误ifrecord==nil{return40002,nil}// 兑换码无效ifrecord.Status!=Available{return40002,nil}if!keyRepo.Lock(userId,record.Id){return40003,nil// 锁定失败}orderNo,payCode:=payClient.CreateOrder(...)ifpayCode==ErrAlreadyOwned{return40005,nil// 已购买}return0,&Result{OrderNo:orderNo}}为什么 Service 返回 (errno, data) 而不是 error?
这点很多人不理解,我详细讲讲:
方案 A:返回 error
func(s*Service)Exchange(...)(*Result,error){if...{returnnil,ErrCdkeyInvalid}if...{returnnil,ErrLocked}}// Handler 要做映射errno:=ErrToErrno(err)// ❌ 又要写一遍映射函数h.RenderJsonErrno(w,errno)方案 B:返回 (errno, data)
func(s*Service)Exchange(...)(int,*Result){if...{return40002,nil}if...{return40003,nil}}// Handler 直接转发h.RenderJsonErrno(w,errno)优势:
- 错误码即文档,Service 一看就知道返什么码
- Handler 不需要做 error → errno 映射
- 业务错误码本来就是给客户端的,提前确定编号更自然
何时仍然用 error?
- 工具函数(如
aes.Decrypt) - Model 层(数据库错误)
- External 层(外部调用错误)
业务接口用 errno,底层工具用 error,分工明确。
错误码分段约定(必看)
按业务模块划分错误码段,一眼看出问题在哪:
| 段 | 用途 |
|---|---|
| 0 | 成功 |
| 10000-19999 | 通用错误(参数、登录、权限) |
| 12000-13999 | 数据存储相关(DB、Redis) |
| 14000-19999 | 系统错误(限流、熔断) |
| 20000-29999 | 用户中心 |
| 30000-39999 | 支付 |
| 40000-49999 | 兑换、虚拟商品 |
| 50000-59999 | 会员、权益 |
| 60000+ | 各业务自由扩展 |
排查问题时,看到errno: 40003,立刻知道是兑换业务的"锁定失败"。
客户端如何统一处理?
错误码体系最大的好处是客户端可以写统一拦截器:
// 前端拦截器(axios 示例)axios.interceptors.response.use(resp=>{if(resp.data.errno!==0){// 统一的错误处理if(resp.data.errno===11001){redirectToLogin()}elseif(resp.data.errno>=60000&&resp.data.errno<70000){showToast("请求过于频繁,请稍后")}else{showToast(resp.data.errmsg)}returnPromise.reject(resp.data)}returnresp.data.data})业务代码完全不用关心错误处理:
constdata=awaitapi.exchange({cdkey:'XXX'})// 直接拿到 data,错误已被拦截器处理一些"血泪经验"
经验 1:永远不要复用错误码
// ❌ 危险constErrFail=-1returnErrFail// 兑换失败也用这个,登录失败也用这个// ✅ 正确constErrExchangeFail=40001constErrLoginFail=11001排查时按错误码搜代码,一秒定位。
经验 2:错误码文档要在线生成
// 在错误码定义旁边加注释,工具自动生成 markdown 文档const(// 10002 参数错误:请求参数缺失或格式不对ErrInvalidParam=10002// 40002 兑换码无效:兑换码不存在、已使用或已过期ErrCdkeyInvalid=40002)经验 3:日志一定带错误码
log.Errorf("exchange failed, errno=%d, userId=%s, cdkey=%s",errno,userId,cdkey)ELK 里按 errno 聚合,问题分布一目了然。
经验 4:监控告警关键错误码
# 监控规则-errno == 12003 且 QPS>10 → DB 异常告警-errno == 60001 且 QPS>100 → 限流告警-errno == 40005 占比>30% → 业务异常告警小结
错误码设计 5 条铁律:
- 业务错误用 HTTP 200,HTTP 状态码只表达协议层
- 错误码是数字,集中注册在一个文件
- 响应格式三件套:
{errno, errmsg, data} - 按业务模块分段:10000/20000/30000/…
- Service 返回 (errno, data),省掉 error → errno 映射
错误码体系做好了,团队协作效率 × 2,排查问题速度 × 5。
下一篇:Go 微服务里的"客户端代理"是什么?为什么要在启动时初始化?
如果觉得有用,点赞收藏关注 ⭐
