一条请求时间线上客户端在中途收线,服务端仍在继续产出

一、超时落在五个时间点的哪一段

一篇排查记录把一个判据说得很直白:客户端超时不等于服务端取消。这句话的工程含义是,日志里那条超时记录往往意味着费用已经发生,只是结果没有回到调用方手里。本文引用的字段名与取值都来自这篇排查,我们把它当作一种可行的记法来讨论,它不是通用协议,也不针对任何一家服务商的计费行为。

要把这件事讲清楚,得先把一次调用摊开成五个时间点:请求发出、服务端受理、开始产出、产出完成、结果被客户端消费。超时可能落在这五点之间的任何一段,而计费的判定依据通常落在第二点或第三点,从不落在第五点。这就产生了四类完全不同的局面,业务代码却常常用同一个异常把它们盖住:

  • 落在第一点之前:请求根本没出去,没有费用,应当重试;
  • 落在第二与第三点之间:服务端已经受理,费用大概率已计入,重试等于再付一次;
  • 落在第三与第四点之间:产出已经发生但没送回来,最坏情况是钱花了、结果谁都没拿到;
  • 落在第五点:结果已到手,只是客户端处理时出了错,此时任何重试都是纯粹浪费。

判据只有一条:不要凭”我这边没拿到结果”推断”那边没做事”。这一条决定了两件事——超时之后的重试要单独设计,不能跟普通失败共用一套逻辑;日志必须把”不知道”这个状态原样记下来,而不是归并成失败。

二、执行状态与用量状态必须分成两列

最常见的写法是一个布尔字段表示成功或失败。问题出在合并的那一刻:执行未知被归进失败,用量未知被当成零成本。合并一次,账就系统性低估一次,而且低估的部分永远不出现在任何告警里,因为它在数据上表现为”没有发生”。

所以要把两列分开。一列是执行到哪一步,取值分成功、失败、未知三种;另一列是计费确认到哪一步,取值分待确认、已确认、确认为零三种。两列各自独立演化:执行成功但用量仍在待确认,是上报有延迟时的常态;执行未知但用量已确认,恰恰是超时那一段的典型形状。

排查记录给出的判定口径也应该照抄进封装层:服务端请求标识缺失,或者执行状态为未知,就把用量状态标成待确认,而不是当作零成本;不能只凭本地成败推断计费。这条规则的价值不在准确,在于它把”不确定”保留成了一个可统计的类别。真正对账时,待确认这一类的数量与占比直接决定你的成本数据可信到什么程度;把它抹平成零,等于每一次核对都自欺一次。

三、七个字段各自补上哪一段链路

日志字段的写法不是越多越好,每个字段要能回答一个具体的对账问题,缺一个就有一段链断掉。下面这张表是这篇排查给出的组合,也是我们认为最小可用的集合。

字段 它回答的问题 缺了会断在哪一段
task_id 这是哪一次业务任务 多次请求无法归到同一笔业务,去重无从下手
attempt_no 这是这个任务里的第几轮尝试 看不出实际重试了几次,请求放大无法量化
retry_owner 这一轮重试是谁发起的,业务层还是 SDK 层 查不出是哪一层把请求放大
retry_reason 为什么重试,例如读超时 无法区分超时类失败与确定性失败,策略调不准
provider_request_id 服务端到底有没有受理这次请求 无法与账单逐条核对,只能对总额
execution_state 执行走到了哪一步 未知被记成失败,重试与费用判断一起失真
usage_state 计费确认走到了哪一步 未知当零成本,成本长期低估

其中两个字段是整条链的枢纽。缺服务端请求标识,本地记录与账单之间就没有连接键,只能做总额核对,而总额核对永远查不出是哪一个环节放大了请求;缺重试归属方,最坏六次请求这件事就只能靠猜,因为业务层看到的是”一次调用一个结果”,底层实际发了几次,只有归属字段能说明。这两个字段都要在调用发起前生成,不能等响应回来再补。

四、重试会分层相乘,只允许一层负责

重试的来源有三处:业务代码、SDK、代理与网关。三层各自都觉得自己该兜一次,于是变成乘法。排查记录里的例子很具体:业务代码尝试两轮,SDK 每轮再做三次,最坏会产生六次真实底层请求,而对外只返回一个结果。这个比例在成本上就是六倍,在下游容量上也是一次六倍的突发。

规则只有一条:同一类调用只允许一层负责重试,其它层显式关闭。关掉冗余重试的顺序也要固定,不能随手挑一层。第一步确认哪一层最贴近错误语义——通常是能够读到错误码的那一层,因为只有它能区分可自愈的抖动与确定性失败;第二步把其它层的重试次数明确设为零,并在配置里留下这一行;第三步把这个决定记录下来,写清由哪一层负责、为什么是它、末次调整由谁确认。第三步经常被省掉,结果是不知什么时候有人看到某一层重试次数为零,以为是漏配,顺手打开,请求放大立刻回来。

还有一条与它配套的判据:大模型接口通常不幂等。这一句直接决定了重试的性质——重试不是”再来一次同样的事”,而是”再做一件新的事”。

五、不幂等带来三件事,超时时长按时延分布定

不幂等的三个后果:重试会产生多份账单;同一任务可能生成多份内容;带副作用的调用会被执行多次,而副作用恰恰是回滚不掉的。对应的三条实现要求要一起做,只做其一都不算闭环:以任务标识做去重,把一次业务意图固定成一个标识;生成物入库之前先查同一任务标识下是否已有记录,有则不再落第二份;有副作用的调用改成显式两段式,先申请拿到一个可追踪的标识,再执行这一次申请。

超时时长不要按平均值设,要按时延分布设。把 P95 与 P99 分开看,是因为长尾正是这类调用的常态而非例外:按平均耗时装一个略高于平均的超时,等于把 P95 以上那部分请求全部转成”服务端做完了但客户端已经走了”的账单。长任务不要把超时设得太短,更稳妥的做法是用流式或异步查询替代同步等待,让等待这件事在协议里显式存在,而不是靠一个长超时硬撑。

流式响应中断时的记账最容易被漏。已经产出的那部分通常已经计费,客户端必须记录自己消费到第几段,否则账单与本地记录之间会差一个整段,而且每次差的长度不一样,对账时看不出来。落点是两个字段:段序号与已消费的最后一段,二者都要与任务标识绑在一起写。

用量上报有延迟,这件事对预算控制的影响是具体的:进行中的请求无法撤销,按实时读数做拦截,要么拦早——额度其实够用却被切断,要么拦晚——超限已经发生才报警。两种处置选一种并写进设计:留出一段缓冲额度,把未确认部分的空间提前扣掉;或者直接把待确认用量计入已用额度,宁可保守。这两条都比”等上报回来再判断”好,因为后者根本没有判断时机。

六、对账不要抹平,交付要有一页成本口径

对账的正确做法是两条线分别聚合:本地按任务标识聚合,账单按服务端请求标识聚合,然后做差集。关键是差集的处理方式——对不上的那一部分必须单独成一类,保留明细并回过头补日志字段,不能在报表里用”其他”或者按比例摊平。摊一次,之后再想找原因就找不回来了。这一类的占比本身就是指标:它高说明字段缺项或者分层重试没关干净,它低说明这套记录勉强可用。

给客户的一页成本口径说明,我们只写四项:超时的计费规则,即超时在什么情况下算已经发生;重试策略由哪一层负责,其它层是什么配置;流式中断如何计量,按已产出还是按已消费;未确认用量的处理方式,计不计入当期额度。四项都指向同一个问题——费用归属的判定权在谁手里。这一页写不出来的团队,通常也解释不了自己那个月的调用账单。

最后留一条判断标准,它可以当作上线前的检查:如果一次超时之后,你无法从日志里说出这笔费用到底算不算发生过,那这套调用日志还不适合用来做预算管理。洹拓这边接这类系统时,第一件事也不是调低超时值或者关掉重试,而是先确认执行状态与用量状态是不是两列、服务端请求标识有没有在发起前落下来。这两件补齐,后面所有的降本动作才有可读的前后对比;缺这两件,任何降本说法都无法验证。