1. "最佳实践"先祛魅:REST 和 RPC 的分岔路口
1.1 为什么很多项目写着 REST,实际上在写 RPC
我参加过不少 Python 后端项目的代码评审,几乎每个团队都会在技术方案里写一句"采用 RESTful API 设计"。但只要打开路由文件,看到的往往是这样的接口:
POST /api/user/register POST /api/order/getDetail POST /api/order/changeStatus GET /api/order/exportExcel你发现没有,这类接口的特征是:URL 本身就是动词或动词短语,HTTP 方法清一色用 POST,客户端要对接时根本猜不到"这个接口应该怎么调用",只能靠文档一个一个查。这不是 REST,这是披着 HTTP 外衣的 RPC(远程过程调用)。
REST 和 RPC 的本质区别,一句话就能说清:RPC 关心"我要做什么动作",REST 关心"我要操作哪个资源"。前者把接口设计成函数调用,后者把接口设计成资源仓库。资源仓库的表达能力来自 HTTP 协议自带的几个动词:GET 取、POST 建、PATCH 改、DELETE 删。如果你把动词塞进 URL,等于把协议已经免费提供的东西又重新发明了一遍,而且发明得还不一定比协议好。
1.2 判断一个接口是不是 RESTful,两个标准就够
很多人在 REST 的纯度问题上纠结很久,什么 HATEOAS、超媒体、信息架构都搬出来,最后团队内耗严重,接口质量反而没上去。我的判断标准很简单,只有两个。
第一,接口的意图是否完全由"HTTP 方法 + URL"表达。比如 GET /api/v1/orders/{id},语义是"取某个订单",任何人不用看文档也能猜个八九分;而 POST /api/order/getDetail 必须看文档才知道它要干什么。第二,同一个资源的所有操作是否集中在同一个路径上。用户这个资源的增删改查都应该挂在 /api/v1/users 下面,通过方法区分,而不是拆成 /api/user/register、/api/user/updatePassword 这种散装的路径。
这两个标准不需要任何理论素养就能落地,实际评审时也最有效。只要你把这两条守住,接口就算不上"完美 REST",也已经在及格线以上了。至于无状态、可缓存、统一接口这些更抽象的原则,等你把前面两条做到之后,自然会发现它们也是顺水推舟的事。
1.3 REST 不是银弹:什么时候不要硬上
讲"最佳实践"必须同时讲边界,不然就成了教条。我见过不少团队把 REST 用在不合适的场景里,最后维护成本比 RPC 还高。
第一种是内部高频服务之间的调用,比如订单系统要实时查询库存,一次调用几十毫秒,吞吐量要求极高。这种场景 REST 的 HTTP 开销、JSON 序列化开销都不可忽略,用高性能的 RPC 框架往往更合适。第二种是复杂业务编排,比如支付流程里"创建支付单—对账—退款"这种动作链,硬要抽象成资源会发现资源很难定义,倒是塞进去一堆"伪资源",比如 POST /api/v1/payments/{id}/refund 还算能接受,但 POST /api/v1/payments/{id}/refund/confirm 这种层级就非常别扭。
我的经验是:对外部第三方提供的 API,REST 目前仍然是最容易被理解和集成的约定俗成方案;对内部服务,务实一点,谁好用用谁。最佳实践不是让你在所有场景都套用同一套模板,而是让你在大多数场景下不用反复纠结。
2. 资源建模:URL、嵌套与"非 CRUD"操作的取舍
2.1 命名三件套:名词、复数、小写
一个资源集合的 URL 命名,我建议固定用这三条规则:全部使用名词、使用复数形式、统一小写并用连字符分隔。
为什么用名词复数?因为 /api/v1/users 指向的是"用户集合",GET 它取列表、POST 它创建新用户,集合语义非常明确;而 /api/v1/user 在语义上既指集合又指单个对象,容易产生歧义。小写和连字符则是为了可读性,URL 里大小写混用会让日志、缓存、路由匹配都出现不必要的麻烦。下划线虽然也可以用,但社区默认的 URL 风格是连字符,比如 /api/v1/blog-posts 就比 /api/v1/blog_posts 更顺眼。
路径参数方面,单个资源用 /api/v1/users/{user_id},不要在路径里再叠加额外的动词前缀。比如 /api/v1/users/get/{user_id} 就是典型的画蛇添足,GET 方法本身已经表达"取"了。
2.2 嵌套资源的两个层级上限
资源之间的从属关系用路径嵌套表达是常见做法,比如一个用户有多笔订单,直觉上是 /api/v1/users/{user_id}/orders。这个写法没问题,但我强烈建议把嵌套层级控制在两层以内。
两层以内的嵌套语义清晰:/api/v1/users/{user_id}/orders/{order_id} 一眼就能看出"这个订单属于哪个用户"。一旦超过两层,比如 /api/v1/users/{user_id}/orders/{order_id}/items/{item_id},可读性和路由维护成本都会急剧上升,而且很难处理"这个商品条目同时属于多个订单"这类真实业务关系。
超过两层怎么办?我的做法是拉平:把最深层的资源独立成顶级资源,用查询参数表达关联关系,比如 /api/v1/order-items?order_id={order_id}。判断标准很简单:当你发现某个资源的访问路径必须依赖两级以上的父资源才能定位时,说明它其实应该是一个独立的资源,只是业务上碰巧有从属关系而已。
2.3 不属于 CRUD 的操作,三条出路
后端开发最常遇到的问题是:资源有了,但某个业务动作没法简单映射到增删改查。比如"取消订单""支付订单""审核通过",这些动作怎么设计?
方案一:把动作本身建模成子资源。比如取消订单就是创建一条取消记录:POST /api/v1/orders/{id}/cancellations。这样做的好处是取消操作有了自己的资源,可以被查询、被记录,将来要支持"撤销取消"也有地方落脚。
方案二:直接使用动作后缀。POST /api/v1/orders/{id}/pay、POST /api/v1/orders/{id}/cancel。这种写法直白、实现快,适合动作语义简单、不需要额外资源承载数据的场景。
方案三:用状态字段 + PATCH 表达。如果订单的状态机是草稿→已支付→已发货,用 PATCH /api/v1/orders/{id} 带上 {"status": "paid"},由服务端校验状态迁移是否合法。这是最接近纯 REST 的做法,但需要团队对状态机有共识,否则很容易出现"谁能改状态、改到什么值"的混乱。
我的倾向是:简单动作用方案二的 action 后缀,需要审计或有复杂副作用的操作用方案一,状态机明确的业务优先考虑方案三。这三种方式在很多成熟团队里是并存的,关键在于团队内部约定统一,不要一个项目里三种风格混着来。
2.4 一个完整示例:订单服务的路由设计
把上面的规则串起来,一个订单服务的路由设计大概是这样的:
from fastapi import APIRouter, Query, status router = APIRouter(prefix="/api/v1/orders", tags=["orders"]) @router.get("", response_model=list[OrderOut]) async def list_orders( status_: int | None = Query(None, alias="status"), page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100), ): ... @router.post("", response_model=OrderOut, status_code=status.HTTP_201_CREATED) async def create_order(payload: OrderCreate): ... @router.get("/{order_id}", response_model=OrderOut) async def get_order(order_id: int): ... @router.patch("/{order_id}", response_model=OrderOut) async def update_order(order_id: int, payload: OrderPatch): ... @router.post("/{order_id}/pay", response_model=OrderOut) async def pay_order(order_id: int): ... @router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_order(order_id: int): ...注意几个细节:列表接口用复数路径不带 /list;创建接口返回 201 而不是 200;删除接口返回 204 不带响应体;支付这种动作挂在订单资源下用二级路径。这个结构基本涵盖了订单服务 90% 的接口需求,评审时也能一眼看出每个接口的意图。
3. 状态码与错误响应体:把"哪里错了"一次说清
3.1 HTTP 状态码不是越全越好,要按语义收敛
我刚带项目那会儿,喜欢把状态码用得特别细,206、301、302、304 全都安排上,结果前端同学接接口时第一句话就是"我们只处理 200 和 4xx 行不行"。后来我才明白,状态码的选择标准不是"标准里有多少种",而是"你的调用方能不能无歧义地处理"。大多数业务 API 把下面这张表守住就够:
| HTTP 状态码 | 使用场景 | Python 框架里的写法 |
|---|---|---|
| 200 | 查询、更新成功 | 直接返回对象 |
| 201 | 创建成功 | FastAPI 传 status_code=201 |
| 204 | 删除成功、无返回内容 | 返回空响应体 |
| 400 | 请求格式错误、缺参数 | 主动抛 400 |
| 401 | 未认证或认证已过期 | 鉴权中间件返回 |
| 403 | 已认证但没有权限 | 权限检查抛 403 |
| 404 | 资源不存在或路径错误 | 捕获未找到抛 404 |
| 409 | 资源状态冲突、重复创建 | 幂等冲突抛 409 |
| 422 | 字段级校验失败 | Pydantic 默认行为 |
| 429 | 触发限流 | 限流中间件返回 |
| 500 | 服务端未捕获异常 | 交给全局异常处理 |
3.2 错误响应体的统一格式
比状态码更重要的是错误响应体的格式。很多项目状态码写对了,错误信息却千奇百怪,有的返回 {"err_msg": "xxx"},有的返回 {"message": "xxx"},有的直接把数据库报错原样抛给客户端。调用方为了兼容这些格式,得写一堆 if-else,这是典型的接口债。
我推荐一个简单的统一格式,三要素固定不变:
{ "error": { "code": "ORDER_NOT_FOUND", "message": "订单不存在或已删除", "detail": { "order_id": "abc-123" } } }code 是机器可读的错误码,用来在前端做分支判断;message 是人类可读的描述,展示给用户或排查问题;detail 是可选字段,放一些辅助排查的上下文。在 FastAPI 里,我习惯定义一个全局异常类:
from fastapi import FastAPI, Request from fastapi.responses import JSONResponse class ApiError(Exception): def __init__(self, code: str, message: str, status_code: int = 400, detail: dict | None = None): self.code = code self.message = message self.status_code = status_code self.detail = detail or {} app = FastAPI(title="order-service") @app.exception_handler(ApiError) async def api_error_handler(request: Request, exc: ApiError): return JSONResponse( status_code=exc.status_code, content={ "error": { "code": exc.code, "message": exc.message, "detail": exc.detail, } }, )业务代码里只要 raise ApiError("ORDER_NOT_FOUND", "订单不存在或已删除", status_code=404),所有错误路径就自动统一了,前端和监控系统都能只认这一种结构。
3.3 400、422、409 的分工
三个最容易混用的状态码是 400、422、409,我专门给团队整理过一套判断规则。
参数没传、类型不对、格式非法,这些是"请求本身长得不对",归 400 或 422。区别在于框架的校验粒度:在 FastAPI 里,路径参数、查询参数的类型错误会返回 422,请求体字段缺失也会被 Pydantic 拦截为 422;如果团队希望把请求体结构问题和字段值问题分开,可以让框架统一返回 422,只在主动抛异常时用 400。关键在于内部约定一致,别让同一个错误在不同接口里一会儿是 400 一会儿是 422。
409 则是"请求本身合法,但和资源当前状态冲突"。典型场景有两个:一是创建订单时带了相同的幂等键重复提交,二是状态机迁移不合法,比如对已取消的订单执行支付。这类冲突用 409 表达,前端拿到后可以直接提示"操作冲突,请刷新后再试",而不是像 400 那样让人以为是自己参数写错了。
另外注意 403 和 404 的取舍:如果用户没权限访问某个资源,直接返回 404 可以避免暴露资源是否存在,对公开接口更安全;内部系统为了排查方便,用 403 也完全可以。我一般建议公开接口优先 404,内部接口优先 403,理由很简单——不要让外部调用方通过状态码探测你的数据。
4. 版本、分页与筛选:让接口陪你跑过三年
4.1 版本号放 URL,还是放 Header
接口一旦发布,消费者就开始依赖它。版本策略的本质不是技术问题,而是"你在多大程度上保障对旧客户的兼容"。
最常见的做法是把主版本号放在 URL 里:/api/v1/users、/api/v2/users。优点是直观,浏览器里一敲就能测,路由调试、日志排查都方便;缺点是一口气要维护多套路由,代码里容易出现 v1 和 v2 逻辑混杂。另一种做法是把版本号放在请求头里,通过 Accept 头协商,比如 Accept: application/vnd.myapi.v2+json。这种方案更优雅,URL 永远清爽,但缺点是调试不直观,很多调用方根本不知道要设这个头,一旦漏了就会跑到默认版本上。
我的建议是:对外公开 API 用 URL 版本,这是目前认知成本最低的方案;内部服务如果只有两三个消费者,甚至可以不走版本号,直接约定破坏性变更前通知对方升级即可。无论选哪种,都要提前规划"旧版本下线时间表",比如在 v2 上线时同步在 v1 响应里加 Deprecation 标记,给客户端至少半年的迁移窗口。
4.2 分页的三种常见方案
分页是接口设计里被低估的细节,等数据涨到百万级再改,代价非常大。三种主流方案对比如下:
| 方案 | 请求形式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 页码分页 | ?page=1&size=20 | 直观,可跳页 | 深翻页性能急剧下降 | 后台管理、数据量小 |
| 偏移分页 | ?offset=0&limit=20 | 灵活,和数据库 offset 对应 | 数据变动时产生重复或缺漏 | 可控列表 |
| 游标分页 | ?cursor=xxx&size=20 | 稳定,可扩展到大表 | 无法跳页、实现稍复杂 | 信息流、无限滚动 |
我踩过的坑是:后台管理系统图省事,一开始选了页码分页,数据量到了几十万行后,用户点进第几百页时,数据库的 OFFSET 深翻页直接把慢查询打满,接口 P95 延迟从 80ms 涨到 3 秒。后来改成基于主键的 keyset 分页才解决,核心逻辑就是记住上一页最后一条记录的游标,下一页从游标继续取:
from fastapi import APIRouter, Query router = APIRouter(prefix="/api/v1/articles") @router.get("") async def list_articles( cursor: str | None = Query(None), size: int = Query(20, ge=1, le=50), ): # cursor 内部就是 base64 编码的上一页最后一条 id query = select(Article).order_by(Article.id).limit(size) if cursor: query = query.where(Article.id > decode_cursor(cursor)) rows = await db.execute(query) ...对外的新接口我建议直接默认游标分页,同时把 size 上限卡死(比如 50),避免有人一次拉全量把数据库打崩。
4.3 筛选、排序与字段选择的约束
筛选条件放查询参数是最自然的做法:GET /api/v1/orders?status=paid&channel=web,语义清楚,还能被缓存系统天然命中。但有两个常见的失控点。
第一个失控点是筛选参数膨胀。一个订单查询接口可能有三四十个过滤维度,全放顶层查询参数,路由代码会变得又臭又长。我的处理方法是:高频筛选维度(状态、渠道、时间范围)留在查询参数里,低频或组合复杂的筛选维度,允许调用方用一个 JSON 序列化的过滤对象传进来,比如 filter={"amount_min":100,"amount_max":500},服务端解析后统一转成查询条件。这个方案不是最纯洁的 REST,但胜在业务可用性高。
排序和字段选择也是容易忽视的隐性约定。排序参数我会用 -created_at 表示倒序,-price,created_at 表示多字段排序,逗号分隔,减号开头表示降序。字段选择用 ?fields=id,title 这种白名单方式,服务端做映射,绝不能把用户传入的字段名直接拼进 SQL。
5. Python 框架选型:FastAPI、Flask、DRF 怎么选
5.1 三个框架的定位差异
Python 生态里做 REST API 的主流选择就三个:FastAPI、Flask 加扩展、Django REST Framework。很多新手纠结"哪个最强",其实三个框架定位完全不同,选错才会出问题。
| 维度 | FastAPI | Flask + 扩展 | Django REST Framework |
|---|---|---|---|
| 性能 | 高(ASGI 异步) | 中(WSGI) | 中(WSGI) |
| 数据校验 | Pydantic 原生集成 | 需要自己加 | Serializer 体系 |
| 文档生成 | OpenAPI 自动生成 | 需要扩展 | 基础支持 |
| 生态限制 | 轻量,无绑定 | 轻量,无绑定 | 必须使用 Django |
| ORM | 自由选择 | 自由选择 | 绑定 Django ORM |
| 团队门槛 | 需理解异步 | 低 | 需理解 Django 全家桶 |
| 适合场景 | 新项目、微服务、高并发 | 极轻量服务、快速原型 | 已在 Django 生态的项目 |
我个人的选型逻辑很简单:全新项目、前后端分离、想快速拿到自动文档和类型校验,直接选 FastAPI;团队只会 Flask、服务就是一个几千行的轻量应用,那就用 Flask 加蓝图手搓,没必要为了框架升级折腾;项目本来就在 Django 里,直接上 DRF,硬拆成 FastAPI 反而两头不讨好。
5.2 FastAPI 项目里的分层实践
框架选好只是开始,团队最容易在 FastAPI 里犯的错,是把所有业务逻辑都堆在路由函数里。一个文件几百行、数据库查询和业务判断全部耦合,等到要加一个调用方时就大规模重构。
我推荐的分层结构是这样的:
app/ main.py # 应用入口、全局异常注册 core/ config.py # pydantic-settings 读环境变量 security.py # 鉴权、密码哈希 exceptions.py # ApiError 定义 api/ routes/ users.py # 路由只做参数解析和结果返回 orders.py schemas/ user.py # Pydantic 请求/响应模型 order.py services/ order_service.py # 业务校验、状态机、事务 repositories/ order_repo.py # 数据库访问 models/ order.py # ORM 模型路由层只负责三件事:声明路径和方法、委托给 service、把结果转换为 schema 返回。业务校验全部收拢到 service 层,数据库访问收拢到 repository 层。这样做的最大好处是可测试性——测试 service 不需要启动 HTTP 服务,直接 mock repository 即可。
5.3 Pydantic 的边界:校验不等于业务校验
Pydantic 在 FastAPI 生态里太方便了,以至于很多人把校验逻辑全塞进 schema 里,这是个隐蔽的坑。
Pydantic 擅长的是数据形状校验:类型对不对、长度够不够、枚举值合不合法、日期的格式对不对。但"这个订单能不能支付"这类业务校验,必须放在 service 层,因为它依赖数据库当前状态、依赖其他服务的结果,本质上不是数据形状问题。把业务校验写进 Pydantic validator,会导致三件事:schema 难以复用(不同接口对同一字段的约束不同)、validator 里出现数据库查询导致同步代码阻塞事件循环、异常类型混杂让全局错误处理失效。
另外提醒一个细节:FastAPI 的 response_model 会自动做序列化,但 ORM 对象要配合 from_attributes=True 才能正确转换。新版 Pydantic v2 里写法是 model_config = ConfigDict(from_attributes=True),老项目升级时容易在这里踩坑。
6. 鉴权、限流与幂等:上线前最容易被拖进泥潭的三件事
6.1 鉴权方案怎么选
鉴权方案没有银弹,只有适合你调用形态的选择。
前后端分离的 Web 应用,现在最普遍的是 JWT:登录成功后发一个短期 Access Token(比如 30 分钟),过期后用 Refresh Token 换新的。JWT 无状态,天然适合水平扩展,缺点是"被动登出"很难做到,因为 token 在过期前一直有效。解决办法是引入 jti 标识并在 Redis 里维护黑名单,登出时把 jti 塞进黑名单直到过期——这等于又在服务端存了状态,取舍要衡量好。
传统服务端渲染的应用,用 Session Cookie 更顺手,浏览器自动带 Cookie,Python 侧用 Redis 存 session 也很成熟。第三方平台接入场景,用标准的 OAuth2 授权码流程,别自己发明协议。FastAPI 里配套的 OAuth2PasswordBearer 加 pyjwt 可以快速搭出一套 JWT 鉴权,关键是把密钥轮换和 token 过期时间放进配置,别写死在代码里。
这里还要提醒一个低级但致命的错误:不要在 URL 里传 token,比如 /api/v1/users?token=xxx。token 会进日志、进浏览器历史、进各种代理缓存,泄露面比放在 Authorization 头里大得多。
6.2 限流在 Python 里的落地
接口上线后,最先被挑战的不是业务正确性,而是限流。没有限流的接口,一次爬虫脚本就能把数据库连接池打满。
限流方案取决于部署形态。单机场景最简单:Flask 可以直接用现成的扩展,FastAPI 用中间件在内存里按 IP 计数就行,适合开发环境和小规模部署。生产环境只要有多台实例,就必须用 Redis 做分布式计数,否则把负载均衡一挂,每台机器的计数是独立的,限流形同虚设。
我的推荐实现是基于 Redis 的滑动窗口或令牌桶,按用户维度限流,比按 IP 更准确。返回 429 时务必带上 Retry-After 响应头,告诉调用方多少秒后可以重试,没有这个头,客户端就不知道退避策略,只会傻乎乎地不断重试,把服务进一步拖垮。
from fastapi import Request, HTTPException async def rate_limit(request: Request, user_id: str, limit: int = 100, window: int = 60): key = f"rl:{user_id}:{int(time.time()) // window}" count = await redis.incr(key) if count == 1: await redis.expire(key, window) if count > limit: raise HTTPException(status_code=429, headers={"Retry-After": str(window)})6.3 幂等:POST 重复提交谁来兜底
REST 语义里 GET、PUT、DELETE 天然具备幂等属性(重复执行结果一致),真正危险的是 POST——用户点击一次下单按钮,网络超时后客户端自动重试,结果生成了两笔订单,这种事故我在生产环境见过不止一次。
解决标准做法是幂等键:客户端在请求头里带上 X-Idempotency-Key(一个 UUID),服务端在 Redis 里以这个键记住处理结果,相同键的重复请求直接返回第一次的结果。典型实现:
from fastapi import Header @app.post("/api/v1/orders") async def create_order( payload: OrderCreate, idempotency_key: str = Header(..., alias="X-Idempotency-Key"), ): lock_key = f"idem:{idempotency_key}" # SET NX 保证同一键只有第一个请求能进入 acquired = await redis.set(lock_key, "processing", nx=True, ex=86400) if not acquired: cached = await redis.get(f"idem:{idempotency_key}:result") if cached: return json.loads(cached) raise HTTPException(status_code=409, detail="请求正在处理中") # 业务处理... await redis.set(f"idem:{idempotency_key}:result", result_json, ex=86400)幂等键要注意两个工程细节:一是 TTL 一定要设置,否则 Redis 里的脏数据会像雪球一样堆积;二是幂等键要能在网关层透传,如果前面还有一层网关把自定义 Header 剥掉了,客户端带了也白带,这是一旦出现重复创建极难排查的原因。
7. 测试、文档与监控:把 API 当成产品交付
7.1 OpenAPI 文档要"活",不要"死后补"
接口文档这种东西,写的时候没人感激,缺的时候人人骂娘。FastAPI 最大的红利是从代码自动生成 OpenAPI 文档,启动后 /docs 就能看 Swagger UI,但这不意味着你可以不写字段说明。
实践中我发现,文档质量差通常不是"格式问题",而是"态度问题"。每个字段的 description、example、必填与否都要写清楚,特别是枚举值的含义、单位的约定、时间格式(UTC 还是本地时间)——这些细节决定调用方要不要反复来问。response_model 一定要显式声明,不要直接返回 ORM 对象,这样文档里的响应结构才可控。代码评审时要养成看文档 diff 的习惯,接口改了而文档没跟着改,比没有文档更危险,因为调用方会照着一个已经失效的契约去实现。
7.2 用 pytest 把接口契约锁死
接口是团队之间最脆弱的契约,守住它最有效的办法是自动化测试。FastAPI 配合 httpx 的 ASGI 模式可以不起真实端口直接测,CI 里跑得飞快:
import pytest from httpx import ASGITransport, AsyncClient from main import app @pytest.fixture async def client(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c async def test_create_order_returns_201(client): resp = await client.post("/api/v1/orders", json={ "user_id": 1, "amount": 100, }) assert resp.status_code == 201 body = resp.json() assert body["order_id"] is not None async def test_create_order_missing_field_returns_422(client): resp = await client.post("/api/v1/orders", json={"user_id": 1}) assert resp.status_code == 422测试的重点不只是 happy path。404、401、409、429 这些错误路径才是最容易被改坏的,因为它们往往横跨鉴权、限流、幂等多个中间件。我的习惯是:每个关键接口至少覆盖一个 2xx 成功路径、一个 4xx 业务失败路径、一个参数校验失败路径。这三条锁住,接口契约就基本稳定了。
7.3 上线后的监控:4xx 上升也是事故信号
代码发布不是终点。一个接口在线上表现如何,需要从监控里持续观察。我常跟团队强调的指标只有四类:请求量、延迟分位数(P50/P95/P99)、错误率、资源饱和度。
错误率要按状态码分段看,不要只盯 5xx。5xx 是服务端故障,响应会很及时;但 4xx 异常上升往往意味着调用方用了错误的方式在调接口,或者是某个参数规则变了而文档没有同步,这种"契约漂移"比瞬时 5xx 更难发现,因为每个请求都在正常返回,只是都在返回错误。日志里一定要带上 request_id,贯穿网关到应用到数据库,否则出现故障时你连"同一个请求到底经历了什么"都拼不出来。
7.4 我惯用的接口发布前检查清单
最后分享一个比较笨但有效的东西。我在多个项目里反复迭代出一份检查清单,每个接口上线前过一遍,大概 10 分钟:
- [ ] URL 用名词复数,动词只出现在 HTTP 方法里
- [ ] 嵌套资源不超过两层,深层关系用查询参数拉平
- [ ] 创建返回 201,删除返回 204,错误码集中在 4xx 语义区间
- [ ] 错误响应体统一 error.code / error.message / error.detail
- [ ] 列表接口有分页,size 有上限,排序参数白名单化
- [ ] 版本号在路径前缀,v1/v2 计划明确
- [ ] 核心 POST 接口接入了幂等键
- [ ] 限流存在且 429 响应带 Retry-After
- [ ] OpenAPI 文档里的字段说明、示例齐全
- [ ] 关键接口有 2xx + 4xx 双路径的自动化测试
- [ ] 日志带 request_id,监控告警覆盖 4xx 上升
这套流程看起来琐碎,实际上每一条都是在真实事故里花钱买来的教训。我自己刚带团队时也嫌这是在"守旧规矩",后来发现,最容易出事的接口,无一例外都是跳过这些检查清单的接口。围绕 RESTful 的理论争论永远不会结束,但工程层面的事情很朴素:把词说准、把错误说清、把变化的余地留好,比任何漂亮架构都更能让一个接口活得久。