第 5 期:从一次 HTTP 请求看懂 Python Web 服务
发布时间:2026/9/30 6:41:23来源:尧图网络
用户点击一次“确认”只看到按钮短暂地转了一圈。服务器却必须认真回答这个请求是谁发来的、能不能执行、数据是否已经改变以及响应丢失后再次请求会发生什么。写在前面我第一次接触 Web 框架时注意力几乎都放在路由上app.get(/hello)defhello()-dict[str,str]:return{message:hello}一个装饰器一个函数一个字典。浏览器里出现 JSONWeb 服务似乎就这样成立了。这种简单非常珍贵。它让人能在几分钟内得到反馈也让 Python 成为很多人进入服务端开发的第一站。但工作一段时间后会发现真正难处理的问题很少发生在这个函数最顺利的时候。用户说自己点击了确认页面却提示超时。订单到底有没有确认接口返回了500日志里为什么只有一条数据库错误看不到是哪个请求触发的明明写的是async def并发增加后服务为什么还是卡住本地测试只需要十几毫秒经过网关、鉴权、数据库和缓存后线上为什么变成了两秒这些问题无法只靠记住更多 FastAPI 装饰器解决。一次 HTTP 请求看起来只是在调用一个函数实际却穿过了一条很长的协作链路。链路中的每一层都在替下一层收窄不确定性也可能在边界处理不当时制造新的误解。这一期我们跟随一次“确认订单”请求从客户端出发经过网络和反向代理进入 Python Web 应用再走到数据库事务、日志和响应。框架仍然使用 FastAPI但重点不是学会某个 API而是看清一个请求为什么要经过这些地方。一个请求从点击开始假设用户在订单页面点击“确认”客户端发出下面的请求POST /api/orders/6fbe6d79-9c25-4f2c-a96f-481c89907aa6/confirm HTTP/1.1 Host: orders.example.com Authorization: Bearer redacted Content-Type: application/json X-Request-ID: 8ae399e6-8ce9-4d3d-9370-a587ef4e86d6 Content-Length: 38 {note:客户已完成信息核对}如果处理成功服务返回HTTP/1.1 200 OK Content-Type: application/json X-Request-ID: 8ae399e6-8ce9-4d3d-9370-a587ef4e86d6 { id: 6fbe6d79-9c25-4f2c-a96f-481c89907aa6, status: confirmed, confirmed_by: user-1842 }这几行文本已经包含了一份契约POST表示客户端希望触发一次状态变化。URL 指定要确认哪一笔订单。Authorization提供调用身份。请求体携带本次操作需要的数据。状态码和响应体告诉客户端结果应该如何理解。X-Request-ID让客户端、网关和服务端可以谈论同一次请求。HTTP 并不理解“订单确认”的业务含义。它只负责用一套双方都认识的格式传递意图和结果。订单能否确认仍然要由应用判断。在请求真正到达 Python 代码之前通常还会发生几件事。客户端先解析 URL通过 DNS 找到域名对应的地址。随后建立 TCP 连接如果使用 HTTPS还要完成 TLS 握手验证证书并协商加密参数。连接建立后客户端才会发送 HTTP 数据。这不是说每次点击都完整经历一遍 DNS、TCP 和 TLS。DNS 会缓存HTTP/1.1 可以复用长连接HTTP/2 还能在同一条连接上并行承载多个请求。理解这些细节的意义不在于背诵握手次数而在于排查延迟时先问清楚慢在连接建立还是慢在服务处理如果域名无法解析请求根本没有到达服务器如果 TLS 证书过期Python 应用也不会收到路由调用。不要在应用日志里寻找一个从未进入应用的请求。反向代理站在应用门口线上服务通常不会让 Uvicorn 直接暴露在公网。请求先到负载均衡器、API 网关或 Nginx再被转发到某个应用实例。这一层经常负责终止 TLS。选择后端实例并复用连接。限制请求体大小和请求速率。设置连接、读取和响应超时。补充转发地址、协议和请求标识。在实例异常时停止分发流量。反向代理适合处理所有请求都需要的网络规则却不应该替应用决定“已取消订单能否确认”。前者是流量边界后者是业务规则。这里还有一个很容易踩到的信任问题。X-Forwarded-For、X-Forwarded-Proto之类的头部可以由客户端自行伪造。只有请求确实来自受信任代理并且服务器正确配置了代理头处理时应用才能把它们当作真实来源。否则一条看似普通的头部就可能绕过 IP 限制或者让应用误判外部协议。代理还会有自己的超时。假设网关等待 30 秒后向客户端返回504并不代表后端代码在第 30 秒自动停止。应用可能仍在执行数据库甚至可能在第 31 秒提交。这个事实值得记住客户端没有收到成功响应不等于服务端没有完成业务操作。因此只要接口会产生副作用就要认真考虑重复请求。订单确认可以被设计为幂等操作订单已经确认时再次确认仍返回当前确认结果而不是重复扣款、重复发消息或创建第二条记录。网络无法替业务保证这一点。请求怎样进入 Python反向代理选中一个应用实例后会把请求转发给监听端口的 Uvicorn。Uvicorn 是 ASGI 服务器它负责处理网络连接、解析 HTTP并按照 ASGI 约定与 Python 应用通信。可以把 ASGI 理解成服务器与应用之间的一份接口协议。服务器把请求方法、路径、头部等信息放进scope应用通过receive接收请求体再通过send发出响应。它的核心形态并不复杂asyncdefapplication(scope,receive,send)-None:assertscope[type]httprequestawaitreceive()awaitsend({type:http.response.start,status:200,headers:[(bcontent-type,btext/plain)],})awaitsend({type:http.response.body,body:bok,})真实的 FastAPI 应用替我们处理了消息拼接、路由匹配、依赖调用、数据校验和响应序列化。Uvicorn 不需要知道订单是什么FastAPI 也不需要自己实现 TCP。这种分工很重要。服务偶尔出现连接重置应该先观察服务器和网络某个路径总是返回422更可能与路由参数或请求模型有关数据库连接池耗尽则已经进入更深的资源层。看到500就直接修改业务函数往往只是碰运气。请求进入 FastAPI 后大致会经过下面这条路径中间件 - 路由匹配 - 依赖解析与鉴权 - 路径、查询参数和请求体校验 - 接口函数 - 业务逻辑与数据库 - 响应模型序列化 - 中间件这条路径不是要求每个项目都堆满中间件和依赖。它只是帮助我们判断一段逻辑应该放在哪里。请求标识和访问日志属于整条请求链路可以放在中间件当前用户身份由多个接口共同使用适合作为依赖“已取消订单不能确认”只属于订单业务不应该藏在鉴权代码或路由装饰器里。在边界处拒绝含糊的数据确认订单接口需要处理三类输入路径中的订单 ID。请求体中的备注。鉴权信息中解析出的当前用户。FastAPI 和 Pydantic 可以先完成格式层面的校验fromuuidimportUUIDfrompydanticimportBaseModel,ConfigDict,FieldclassConfirmOrderRequest(BaseModel):note:str|NoneField(defaultNone,max_length200,)classOrderResponse(BaseModel):model_configConfigDict(from_attributesTrue)id:UUID status:strconfirmed_by:str|None接口函数可以声明order_id为UUID。如果路径中传入的不是合法 UUID框架会在调用业务逻辑之前返回422。备注超过 200 个字符也会在边界被拒绝。格式正确不代表业务合法。一个合法 UUID 可能对应不存在的订单一笔存在的订单可能已经取消通过鉴权的用户也未必拥有确认这笔订单的权限。这些判断依赖当前业务数据不能全部塞进 Pydantic 模型。我习惯把两类校验分开输入校验回答“数据是否符合接口格式”。业务校验回答“当前状态下是否允许执行”。把它们混在一起会让请求模型需要访问数据库也会让同一条业务规则在命令行任务、消息消费和 Web 接口中难以复用。校验还应尽量发生在昂贵操作之前。无效 UUID 没有必要占用数据库连接超大请求体也不应该等到 Python 完整读入内存后才拒绝。请求体大小可以先在网关限制字段规则再由应用校验。边界越早明确系统浪费的工作越少。事务里只放必须一起完成的事订单状态真正改变的地方在数据库。下面使用 SQLAlchemy 2.x 的异步接口描述核心模型。示例省略了与主题无关的表名约定和迁移配置fromdatetimeimportdatetime,timezonefromenumimportStrEnumfromuuidimportUUIDfromsqlalchemyimportDateTime,String,Uuidfromsqlalchemy.ormimportDeclarativeBase,Mapped,mapped_columnclassBase(DeclarativeBase):passclassOrderStatus(StrEnum):PENDINGpendingCONFIRMEDconfirmedCANCELLEDcancelledclassOrderStateConflict(RuntimeError):passclassOrder(Base):__tablename__ordersid:Mapped[UUID]mapped_column(Uuid,primary_keyTrue)status:Mapped[str]mapped_column(String(20),defaultOrderStatus.PENDING,)confirmed_by:Mapped[str|None]mapped_column(String(64),nullableTrue,)confirmed_at:Mapped[datetime|None]mapped_column(DateTime(timezoneTrue),nullableTrue,)note:Mapped[str|None]mapped_column(String(200),nullableTrue)defconfirm(self,actor_id:str,note:str|None)-bool:ifself.statusOrderStatus.CANCELLED:raiseOrderStateConflict(已取消的订单不能确认)ifself.statusOrderStatus.CONFIRMED:returnFalseself.statusOrderStatus.CONFIRMED self.confirmed_byactor_id self.confirmed_atdatetime.now(timezone.utc)self.notenotereturnTrueconfirm()不知道 HTTP 状态码也不访问数据库。它只表达订单状态如何变化。订单已经确认时返回False调用方可以把重复确认视为成功这让当前操作具备幂等性。数据库会话通过依赖进入接口而不是在每个请求里重新创建引擎fromcollections.abcimportAsyncIteratorfromsqlalchemy.ext.asyncioimport(AsyncSession,async_sessionmaker,create_async_engine,)enginecreate_async_engine(settings.database_url,pool_pre_pingTrue,)SessionFactoryasync_sessionmaker(engine,expire_on_commitFalse,)asyncdefget_session()-AsyncIterator[AsyncSession]:asyncwithSessionFactory()assession:yieldsession数据库引擎和连接池是重量级资源应该在应用生命周期内复用并在应用关闭时统一释放。每个请求获得自己的AsyncSession不能把同一个会话对象交给多个并发请求共享。确认逻辑在一个短事务中完成fromdataclassesimportdataclassfromuuidimportUUIDfromsqlalchemyimportselectfromsqlalchemy.ext.asyncioimportAsyncSessionclassOrderNotFound(RuntimeError):passdataclass(frozenTrue,slotsTrue)classConfirmResult:order:Order changed:boolasyncdefconfirm_order(session:AsyncSession,order_id:UUID,actor_id:str,note:str|None,)-ConfirmResult:asyncwithsession.begin():statement(select(Order).where(Order.idorder_id).with_for_update())orderawaitsession.scalar(statement)iforderisNone:raiseOrderNotFound(f订单不存在{order_id})changedorder.confirm(actor_id,note)returnConfirmResult(orderorder,changedchanged)SELECT ... FOR UPDATE会锁定目标行。两个请求同时确认同一订单时后到的事务需要等待前一个事务结束然后读取最新状态。它看到订单已经确认便不再重复改变数据。锁不是免费的。事务中不要调用远端 HTTP、发送邮件或执行耗时计算否则数据库行会在等待外部系统时一直被占用。事务应该只包住必须一起成功或一起失败的数据库操作。提交也必须发生在返回成功之前。SQL 语句执行成功不等于事务已经成功提交连接中断、约束冲突或数据库故障都可能让提交失败。如果先构造200再尝试提交客户端会收到一个并不存在的成功。这里采用悲观锁是为了清楚展示并发状态变化。读多写少、冲突很少的场景也可以使用版本号做乐观锁。选择哪一种取决于冲突概率和业务语义不能只看哪段代码更短。路由函数应该保持薄但不能只剩转发路由层负责把 HTTP 世界翻译成业务调用再把业务结果翻译回 HTTPfromdataclassesimportdataclassfromuuidimportUUIDfromfastapiimportAPIRouter,Dependsfromsqlalchemy.ext.asyncioimportAsyncSession routerAPIRouter(prefix/api/orders,tags[orders])dataclass(frozenTrue,slotsTrue)classActor:user_id:strasyncdefget_current_actor()-Actor:实际项目中由鉴权依赖返回当前用户。...router.post(/{order_id}/confirm,response_modelOrderResponse,)asyncdefconfirm_order_endpoint(order_id:UUID,payload:ConfirmOrderRequest,actor:ActorDepends(get_current_actor),session:AsyncSessionDepends(get_session),)-OrderResponse:resultawaitconfirm_order(sessionsession,order_idorder_id,actor_idactor.user_id,notepayload.note,)returnOrderResponse.model_validate(result.order)这段函数不直接写 SQL也没有自己解析令牌。它仍然做了一件明确的事把经过校验的 HTTP 输入组装成一次订单确认并声明响应长什么样。“路由要薄”不等于所有代码都必须藏进一个名字模糊的service.py。如果接口只是读取一条记录几行查询未必值得增加新层如果一项业务会被定时任务、消息消费者和多个接口共同调用像confirm_order()这样独立出来就很自然。缓存也应该服从这个边界。确认订单后如果系统缓存了订单详情可以在事务提交后删除对应缓存键ifresult.changed:try:awaitcache.delete(forder:{result.order.id})exceptCacheError:logger.warning(订单已确认但缓存失效失败order_id%s,result.order.id,)缓存删除失败时数据库中的确认不能跟着回滚因为 Redis 与数据库不在同一个本地事务里。可以通过较短 TTL、重试任务或版本化缓存限制陈旧时间。是否因此让接口返回失败需要结合业务决定如果数据库已经提交再返回500客户端重试时必须能够安全得到同一结果。缓存是加速手段不应成为订单真实状态的唯一来源。为了少一次数据库查询而引入无法解释的一致性问题通常得不偿失。async def不会自动带来高并发FastAPI 支持同步和异步接口但它们不是新旧两种写法。当请求的大部分时间花在数据库、Redis 或 HTTP 等 I/O 等待上并且使用的客户端提供真正的异步接口时async def可以让事件循环在等待期间处理其他请求。如果在异步函数中调用阻塞代码app.get(/blocking)asyncdefblocking_endpoint()-dict[str,bool]:time.sleep(2)return{ok:True}这两秒会阻塞当前事件循环同一进程里的其他协程也无法正常推进。应该改用异步等待app.get(/non-blocking)asyncdefnon_blocking_endpoint()-dict[str,bool]:awaitasyncio.sleep(2)return{ok:True}如果第三方库只有同步接口可以把普通def路由交给框架线程池或者明确使用线程执行阻塞调用。CPU 密集型计算则不会因为加上await变快通常需要进程池、独立任务系统或更适合的计算服务。异步也不意味着可以无限创建任务。数据库连接池只有 20 个连接时放进来 2,000 个并发查询只会让大量协程排队并占用内存。入口限流、连接池大小、下游容量和超时应该一起设计。只有互相独立的 I/O 才适合谨慎并行。用户信息和商品信息如果没有依赖可以使用asyncio.gather()同时读取先查订单才能得到后续查询参数就不应该为了形式上的“并发”强行拆开。在异步服务中请求上下文也不能使用线程级变量保存。多个请求可能在同一线程上交替执行线程本地存储无法区分它们。请求标识这类上下文应该使用ContextVar。日志要把同一个请求重新拼起来一次请求可能经过网关、应用、数据库和缓存。出现问题时人需要沿着同一个标识把分散事件重新连起来。下面的中间件接收合法的请求 ID如果客户端没有提供或者格式不可信就生成一个新的 UUID。ContextVar会把标识绑定到当前异步上下文不会让并发请求互相污染importloggingfromcontextvarsimportContextVarfromtimeimportmonotonicfromuuidimportUUID,uuid4fromfastapiimportFastAPI,Request appFastAPI()loggerlogging.getLogger(__name__)request_id_context:ContextVar[str]ContextVar(request_id,default-,)defresolve_request_id(raw_request_id:str|None)-str:ifraw_request_idisnotNone:try:returnstr(UUID(raw_request_id))exceptValueError:passreturnstr(uuid4())app.middleware(http)asyncdefadd_request_context(request:Request,call_next):request_idresolve_request_id(request.headers.get(X-Request-ID))context_tokenrequest_id_context.set(request_id)started_atmonotonic()status_code500try:responseawaitcall_next(request)status_coderesponse.status_code response.headers[X-Request-ID]request_idreturnresponseexceptException:logger.exception(请求发生未处理异常)raisefinally:elapsed_ms(monotonic()-started_at)*1000logger.info(请求结束method%s path%s status%d elapsed_ms%.2f,request.method,request.url.path,status_code,elapsed_ms,)request_id_context.reset(context_token)日志过滤器或结构化日志处理器可以读取request_id_context.get()把请求 ID 自动加入同一上下文中的每条日志。查询参数可能含有令牌、邮箱或搜索内容访问日志通常只记录路径敏感头部和请求体更不应该无条件打印。请求 ID 解决的是关联问题分布式链路追踪还会记录服务之间的父子关系、耗时和状态。接入 OpenTelemetry 后应用应继续传递标准traceparent而不是每经过一层就生成一套互不相干的标识。日志数量也需要克制。正常请求的一条访问日志通常足够预期中的404和409不必每次都打印完整堆栈未知异常则要保留堆栈和上下文。日志是给排查的人看的不是程序运行过程的逐句旁白。异常应该变成稳定的接口语言数据库和 Python 异常不应该原样暴露给客户端。客户端需要的是稳定的状态码、错误代码和可以理解的信息。领域层只抛出自己认识的错误Web 层负责翻译fromfastapiimportRequestfromfastapi.responsesimportJSONResponsedeferror_body(code:str,message:str)-dict[str,str]:return{code:code,message:message,request_id:request_id_context.get(),}app.exception_handler(OrderNotFound)asyncdefhandle_order_not_found(request:Request,error:OrderNotFound,)-JSONResponse:returnJSONResponse(status_code404,contenterror_body(ORDER_NOT_FOUND,str(error)),)app.exception_handler(OrderStateConflict)asyncdefhandle_order_state_conflict(request:Request,error:OrderStateConflict,)-JSONResponse:returnJSONResponse(status_code409,contenterror_body(ORDER_STATE_CONFLICT,str(error)),)客户端可以依据code决定展示和后续动作不必解析一段随时可能调整的中文消息。request_id则方便用户把一次失败准确地反馈给服务维护者。状态码也不是装饰400表示请求整体无法理解或不符合接口约定。401表示尚未通过身份认证。403表示身份明确但没有执行权限。404表示目标资源不存在。409表示请求与资源当前状态冲突。422常用于字段格式和校验失败。500表示服务遇到了未预期错误。不必为了“前端处理方便”让所有响应都返回200再在 JSON 里放一个失败码。代理、监控、SDK 和调用方都理解 HTTP 状态码放弃它等于放弃一整套已经存在的协作语言。未知异常应该返回统一的500但不能把 SQL、文件路径、堆栈或密钥暴露在响应里。详细证据留在带请求 ID 的服务端日志中客户端只需要知道服务没有按预期完成。测试要覆盖请求的承诺接口测试不应该只验证“能返回 JSON”。对确认订单接口至少要关心这些行为合法的待确认订单返回200状态变为confirmed。非法 UUID 或过长备注被拒绝。不存在的订单返回404。已取消订单返回409。重复确认不会重复产生副作用。两个并发请求不会把状态写坏。数据库提交失败时不能返回成功。响应与日志都带有可追踪的请求 ID。使用 HTTPX 可以直接通过 ASGI 调用 FastAPI 应用fromuuidimportuuid4importpytestfromhttpximportASGITransport,AsyncClientpytest.mark.anyioasyncdeftest_confirm_order_returns_current_state(app_with_pending_order,pending_order_id,)-None:transportASGITransport(appapp_with_pending_order)asyncwithAsyncClient(transporttransport,base_urlhttp://test,)asclient:first_responseawaitclient.post(f/api/orders/{pending_order_id}/confirm,json{note:verified},headers{X-Request-ID:str(uuid4())},)second_responseawaitclient.post(f/api/orders/{pending_order_id}/confirm,json{note:verified},)assertfirst_response.status_code200assertsecond_response.status_code200assertfirst_response.json()[status]confirmedassertsecond_response.json()[status]confirmedassertX-Request-IDinfirst_response.headers这个测试覆盖了路由、参数解析、中间件、异常映射和响应序列化但它绕过了真实网络、TLS 和反向代理。它也不天然证明 PostgreSQL 行锁正确工作。事务、唯一约束和并发行为应该在与生产一致的数据库上做集成测试。SQLite 很适合轻量测试却不能替 PostgreSQL 证明FOR UPDATE、隔离级别和并发冲突。测试环境越方便越要清楚它替我们省略了什么。网关超时、代理头、请求体限制和容器探针则需要部署后的冒烟测试或端到端测试补齐。没有一种测试能够独自覆盖整条链路测试边界本身也应该被记录。从本地运行到真实环境本地可以用 Uvicorn 启动应用uvicorn order_service.main:app\--host0.0.0.0\--port8000进入容器和生产环境后还要做几项不太显眼、却直接影响稳定性的工作。应用需要区分存活和就绪。存活探针回答“进程是否还能工作”不应该因为数据库短暂波动就反复重启进程就绪探针回答“当前实例是否适合接收流量”可以在关键依赖不可用时暂时摘除实例。进程收到终止信号后应先停止接收新请求为正在处理的请求留出宽限时间再关闭数据库连接池和其他客户端。发布系统的终止宽限期必须大于应用的优雅退出时间否则所谓优雅退出仍会被强制结束。工作进程和实例数量要结合 CPU、内存、连接池与下游容量设置。四个进程各自建立 20 个数据库连接实际就是 80 个连接。只增加副本不检查数据库容量扩容可能让故障更快到来。数据库结构变更应该通过独立、可审查、可回滚的迁移流程执行。生产环境不要在应用启动时自动执行 DDL多个实例同时启动、迁移耗时或锁表都可能让一次普通发布变成不可控变更。反向代理、应用服务器和外部调用都要设置彼此协调的超时。上层等待时间如果短于下层最坏执行时间客户端会先放弃后端仍继续工作下层完全没有超时上层再宽容也只是在延迟故障。最后还要为一次请求建立延迟预算。假设接口目标是 300 毫秒就要知道网关、鉴权、数据库、缓存和序列化各自用了多少。优化不能只盯着最容易修改的 Python 代码而应该根据指标找到真正占用时间的部分。结语一次 HTTP 请求的旅程比一个路由函数长得多。它可能从一次点击开始经过 DNS、连接和加密被反向代理接住再由 Uvicorn 交给 FastAPI。输入在边界处变得明确业务规则判断当前状态事务保护数据变化缓存需要面对一致性日志和追踪则把分散的过程重新连起来。每一层都不应该知道所有事情。代理不决定订单能否确认数据库模型不返回 HTTP 状态码路由也不应该独自承担鉴权、事务和全部业务规则。边界清楚以后问题发生时才有地方可找需求变化时也有地方可改。更重要的是请求背后始终有人在等待结果。超时之后他不知道该不该再点一次错误发生时他需要一句可以理解的话反馈问题时他希望维护者能找到那次请求而不是让他重新描述所有经过。一个成熟的 Web 服务不只是能在正常路径上返回正确 JSON。它也会认真对待重复、并发、失败和误解让调用者知道发生了什么让维护者有证据继续追查。前五期写到这里我们已经从 Python 的能力地图和对象模型走到项目结构、自动化脚本再走进一条完整的 Web 请求。工具会继续变化但这些问题不会很快过时数据由谁拥有边界在哪里失败怎样恢复系统又如何向人解释自己。
网站建设高端定制企业官网