超时不是一个数字:给网络请求分配时间预算
卷中目录
把超时理解成一个全局整数,是很多线上故障的起点。连接超时、读超时、写超时、排队等待、下游处理、重试间隔,这些时间并不是同一种东西。它们共享的是调用方还能接受的剩余时间,而不是同一个可以随便复用的常量。一次用户请求往往要穿过网关、应用服务、缓存、数据库和第三方接口。任何一环如果只看自己的本地配置,就容易出现一种难看的局面:上游已经放弃,下游仍在认真计算;或者上游等得很久,下游却在几毫秒内失败并反复重试,把短暂抖动放大成整条链路的拥塞。

时间预算更接近一张会递减的账单。请求进入系统时带上截止时刻,每一跳在发起下游调用前先看剩余额度,再决定这次调用允许使用多少。剩余额度要扣掉本机已经消耗的排队、序列化和业务判断,也要给响应回传、日志和收尾留出余量。若剩余时间已经小于一次有意义调用所需的下限,就不必再发出请求。此时直接返回可识别的超时或降级结果,比把一个注定来不及的调用送进下游更干净。预算的单位应该是绝对截止时间,而不是“再等多少毫秒”这种相对值。相对值在经过多段网络和时钟读取之后会不断漂移,下游无法知道上游到底何时放弃。
把一种等待拆开
连接建立、请求写出、响应读完,失败形态并不一样。连接超时过长,会在对端不可达或端口未监听时占住工作线程和连接池;过短,又会在跨机房或短暂 DNS 抖动时误伤正常请求。读超时才是多数业务真正关心的处理时限,它应该覆盖对端开始处理和把响应送回的时间,而不是把连接阶段再算一遍。写超时容易被忽略。请求体较大、对端接收窗口很小,或者中间链路出现背压时,写操作本身就会挂住。若写没有独立上限,一个缓慢的消费者能把生产者的连接池耗尽。
数据库、缓存和 HTTP 下游也不该共用一个数字。缓存命中的预算通常远小于数据库查询;数据库里的点查又小于跨多表的报表。把它们都写成同一套三秒,结果往往是缓存故障时所有请求一起等到三秒,数据库连接也被这些迟到的重试占满。更稳妥的做法是按操作类型登记预算,并让总预算约束局部预算。点查可以等待较久,因为结果对主流程不可替代;推荐位、角标、非关键计数则应该更早放弃,主交易仍可继续。
排队时间必须计入预算。请求在应用线程池、数据库连接池或消息消费前的等待,用户已经在付出时间。若超时只从真正执行 SQL 的那一刻起算,监控上会看到语句本身很快,用户侧却大量超时。这类偏差会让人误判瓶颈在数据库,实际上瓶颈在池的等待队列。把等待也放进同一条时间线,超时日志才分得清是慢查询、锁等待,还是根本没拿到连接。
截止时间怎么往下传
进程内部可以用上下文携带截止时刻,跨进程则需要明确的协议字段。有的团队把剩余毫秒放在请求头里,有的使用绝对时间戳。绝对时间戳要求机器时钟偏差处于可接受范围,否则下游会因为自己的时钟偏快而立刻拒绝,或因为偏慢而过度等待。剩余毫秒不依赖时钟对齐,但在请求还堵在网卡缓冲区时就会变旧。实践里可以同时带上上游计算该值的时刻,下游用本地单调时钟估算传递耗时,再做一次校正。校正不必追求极细的精度,重要的是避免把已经过期的预算当成新的完整配额。
下游收到预算后,不应原样把全部剩余时间设成自己的读超时。它自己的框架、序列化和可能的重试都要占用时间。常见的做法是预留一个固定的回传余量,再按调用扇出把剩余时间分给各个下游。扇出若是并行的,总预算大致等于最慢的那一支,而不是各支相加;若是串行的,则必须相加,并且越靠后的调用越要看到更小的剩余值。并行扇出里还有一个隐蔽问题:某一支很快失败,其余支是否继续?如果失败的是主数据,其余装饰性调用应尽快取消;如果失败的是可降级分支,主支仍应走完自己的预算。取消要能传到真正占用资源的那一层,否则应用层虽然返回了,数据库里的语句还在跑。
重试是预算最容易被击穿的地方。一次调用超时后立刻重试,等于把同一份时间再花一遍,还会把流量叠加到已经吃力的下游。重试前应检查剩余预算是否还够一次完整尝试,并加上退避。退避若是固定间隔,多个客户端会在同一时刻一起回来。带抖动的退避能把重试打散。重试次数也不该在配置文件里写成与预算无关的常数。预算只够一次,就不要第二次;预算很宽,也不代表可以连试许多次,因为宽预算可能只是为了等待一个慢依赖,而不是为了放大流量。
一个下单链路的分法
设想确认订单的接口对用户承诺的响应上限是八百毫秒。这个数字不是数据库超时,也不是支付渠道超时,而是整次交互的外壳。网关和鉴权预留几十毫秒。应用要读库存、写订单草稿、调用支付预下单,并组装返回。库存点查如果走缓存,预算可以压到几十毫秒;缓存未命中再给数据库一小段,并且这段必须从总预算里扣。支付渠道历史上的尾延迟更高,可以分到较大的一块,但应用要在发出前确认剩余时间仍高于渠道的最小可用窗口。若前面的库存查询已经把时间吃掉大半,支付调用要么走异步受理,要么直接告诉用户稍后再查,而不是带着几十毫秒去打一个通常要更久的外部接口。
支付渠道自身还有连接和读两段。连接失败往往说明出口或对端接入层异常,短超时加上有限重试有意义。读超时则要尊重渠道的处理时间,过短会造成一种危险的不确定:本地认为失败,对端可能已经创建了支付单。这时重试必须带上幂等标识,并且下一次尝试使用的仍是剩余预算,不是重新申请一份八百毫秒。订单草稿的写入也要有自己的上限。数据库若在等锁,应用不能无限挂起,否则用户重试会堆出更多事务。锁等待超时应该短于请求总预算,失败后返回可重试的状态,而不是让事务一直占着连接。
日志里至少留下四段时间:进入本服务的时刻、开始访问下游的时刻、下游返回或取消的时刻、写出响应的时刻。只记录“超时了”没有诊断价值。再配上剩余预算、是否发生重试、取消是否成功,就能分辨是预算分配过紧,还是某一跳真的变慢。指标上不要只看平均延迟。超时是否合理,取决于尾部。可以按接口看较高分位的延迟,并对照超时阈值:阈值若紧贴日常尾部,正常波动就会被算成失败;阈值若远高于用户愿意等待的时间,超时配置就失去了保护意义,只是在推迟失败。
超时与取舍
时间预算解决的是“还要不要继续等”,不解决结果是否正确。有些操作超时后状态不明,例如外部扣款、短信发送、消息是否入队。这类调用的超时策略必须和幂等、查询补偿放在一起设计。只把超时调短,会制造更多未知状态。另一些操作是纯读取,超时后可以返回旧缓存、空推荐或明确的部分失败,用户仍能完成主路径。把这两类调用放进同一个重试器,是预算设计里常见的混用。
预算也不是越短越安全。过短的超时会提高误杀率,让健康但稍慢的依赖持续被熔断,系统进入抖动:熔断打开,请求改走降级,依赖压力下降,熔断关闭,流量又瞬间打回,延迟再次升高。熔断和超时要互相配合。超时负责单次调用的上界,熔断负责在错误比例持续偏高时停止发送;半开探测要使用很小的流量,并且探测请求同样受预算约束。限流则保护入口,避免预算再宽也救不了超出容量的并发。三者缺一,单靠超时数字都会失真。
线程和连接是预算的物理载体。同步模型里,一个等待中的请求占着一条线程。超时设得过宽,线程池会被慢调用堆满,后续请求在队列里耗尽预算,还没开始执行就失败。这时应该缩短无意义等待、限制并发,或者把慢下游改成异步。异步模型不占线程,但仍占连接、内存和下游配额。忘记给异步任务设置截止时间,任务会在内存里无限累积,最后以更突然的方式失败。取消信号在异步边界尤其容易丢。回调线程池、批处理框架和手工创建的任务,都要显式继承截止时刻。
客户端也有自己的预算。浏览器或 App 若无限等待,服务端预算就失去用户侧意义;客户端若显著短于服务端,服务端仍会做完已无主人的工作。两端可以用同一套用户可感知的上限,服务端略短于客户端,以便在客户端放弃前返回明确错误。移动网络的抖动更大,重试更要克制,并且只对可安全重放的读请求自动重试。写请求交给幂等层,而不是交给通用的 HTTP 重试拦截器。
配置应当靠近调用点,并写明单位、默认依据和哪些失败可以重试。散落在框架默认值里的超时最难审计。一次依赖升级可能把读超时从数秒改成无限,故障时所有连接静静挂住。上线前用故障注入把某一下游的延迟抬到阈值附近,观察取消是否生效、连接是否归还、重试有没有放大流量、用户得到的是部分结果还是含糊的失败。这些比再调一个“看起来圆整”的整数更有用。
收束
超时是把用户愿意等待的时间,分成每一跳可以消耗的额度,并在额度用尽时停止。连接、读写、排队、重试和回传各自占不同的份额;并行与串行的算法不同;状态不明的写操作还要和幂等、补偿一起看。一个孤立的超时配置可以防止线程永久挂起,却不能防止预算在重试和扇出中被重复花费。把截止时刻传下去,让每一跳对剩余时间负责,超时才从一种事后补救变成链路的一部分。
见字如晤