一条流水线在中途分成两路,一路把结果递回柜台,另一路把单据推进未完成的箱子

一、分界线画在依赖上,不画在耗时上

那篇教程列了必须异步和禁止异步两类工作,禁止异步那一类点名了三样:登录态、权限判断、核心事务。这三样看起来都很轻——读一次会话、比一次角色、提交一段事务,耗时远不如一张报表。把它们留在同步路径上的理由不是快,而是依赖:这三步的结论就是当前响应的组成部分。登录态没读到,响应里“你是谁”是空的;权限判断没出结果,“允不允许”没有依据;核心事务没提交,客户端拿到的成功状态对应一个还不存在的事实。

异步化这三样的实质,是把一个未决状态当成已决状态返回。客户端只看状态码,看到成功就认为登录完成、认为有权、认为数据已落库。这类问题上线初期往往没人发现,因为队列通常真的会执行,只是慢一点;等队列积压或消费端出错,界面就会出现“我登录了但下一步说我没登录”“提示提交成功但列表里没有那条记录”。

所以我们把判据写成一句可以对着每个接口问的话:这个值不拿到,响应还能不能正确生成。能,才谈异步;不能,就把它锁在同步路径里,然后通过加超时、加索引、加缓存去压它的耗时,而不是把它挪走。这句话的好处是不需要争“这个接口到底算不算慢”。

二、四类耗时工作与各自处置

类别 判定特征 处置 常见错误
响应内必需 去掉它响应就缺内容或结论错 同步执行并显式设超时与降级 图省事丢进队列,靠“应该来得及”赌
响应后可延迟 结论与本次响应无关,只影响后续 异步执行并建状态可查 异步了但没有状态入口,失败无人知
可预计算 结果由相对稳定的输入推导 离线算好写进缓存 每次请求现算,把恒定成本摊到热路径
可批处理 单次调用小且密集 设合并窗口成批做 每条记录一个任务,队列被碎片打满

四类里第三、第四类最容易被忽略,因为它们的收益不需要改调用方式,只需要改生产方式。洹拓接手过一些接口整改项目,前两类怎么划基本靠问,后两类往往是真正能把响应时间压下来的地方,但要把数据新鲜度和口径先谈清楚,否则预计算结果和业务实时状态会长期不一致。

三、队列化的五个隐性代价与对应的实现要求

队列不是把耗时搬走就结束,它搬走的同时留下五笔账。

隐性代价 具体表现 必须补的实现要求
状态不一致 用户看到的与真实执行进度不同 任务状态表,前端轮询或推送,状态字段带可解释文案
失败不可见 任务失败只存在于消费端日志 失败原因落库并可查,重试次数与最终状态一起记
顺序不保证 同一对象的两次修改先后颠倒 按业务键做分区顺序,同一键的改动进同一分区
重复消费 同一任务被执行两次 消费端去重表,以业务键与尝试标识为唯一约束
幂等要自己做 重放或补跑时产生重复副作用 明确任务重放边界,把可重放段设计成可安全重跑

这五件事里最贵的是第二件。失败不可见的直接后果是整改无从下手:用户报“没生效”,你要在队列、消费端、业务表三处之间人肉对照。我们的交付标准里有一条硬指标:任何异步任务都必须能从一次用户请求里的一个标识出发,查到它在队列里的当前状态与失败原因,查不到就不算完成整改。

四、慢查询看比值,分页看排序键

那篇文章给的两个动作都很具体。定位慢查询靠比较检查过的行数与返回的行数——Rows_examined 远大于 Rows_sent,说明数据库为了给你这几行数据翻了远超必要的内容,问题多半在索引缺失或过滤条件太宽。为什么不看总耗时:耗时受缓存冷热、并发排队、机器负载影响,一个刚被缓存喂饱的接口会显得健康,而它的扫描量一直在涨。比值是工作量本身,不容易骗人。

分页那一条是把偏移量翻页换成游标条件 WHERE id > ?。差别在深页:偏移量翻页要真的把前面那些行读出来再丢掉,页码越深丢掉的比例越大;游标方式只读要展示的那一段。但有一个前置条件必须写进方案,否则换了写法只是换了一种错法——游标依赖的排序键必须唯一。按一个可重复的字段(比如创建时间)做游标,同一时刻的多条记录会让边界条件既可能漏也可能重,翻页表现为“有的数据永远看不见”。真实做法是排序键取唯一列,或者把唯一列追加到排序末尾当决胜字段。

这两招的写法带着具体框架的印记,我们只借它的判据:看工作量比值而不是看响应时间,看游标是否依赖唯一键。判据可以搬,写法不能当成放之四海都成立。

五、缓存分四层,每层的失效边界不同

来源给出的分层是四层:配置缓存(config:cache 那条路子里的发布动作)、按键缓存(Cache::remember,带键名与有效期)、按标签失效(Cache::tags)、以及把空结果也缓存住。四层的失效边界要分开谈,混着谈是缓存事故的主要来源。

  • 配置层的失效靠发布。配置变了但不重新发布,缓存里读到的还是旧值;它不是靠时间过期,是靠部署动作。
  • 键值层的失效靠有效期。它决定的是数据最多陈旧多久,与业务变更无关,写入后到期前不会自己更新。
  • 标签层的失效靠关联清除。一次业务变更要能精确点名它影响哪些标签,标签设计太粗会一次清掉一大批,太细则容易漏挂。
  • 空结果层的失效靠写操作打断。这层的意义是挡住穿透——一个永远查不到的键如果不在缓存里留痕,每次请求都会打到数据库。代价也很直接:数据刚被创建,读侧仍然命中那条空值,出现“刚提交却在列表里找不到”。所以空值缓存要短,来源里给的量级是 60 秒这一档,同时写操作必须主动打断对应的空值键,二者缺一。

六、连接池的三条防护与给接口定档的验收表

把服务改成常驻运行并开启数据库连接池,性能收益之外会带来三类新问题,每一类都对应一条防护。

连接持有时间变长:池里的连接不是每次请求新建再关闭,于是数据库侧的连接上限、单条连接上的会话状态、长时间空转后被服务端断开的风险都出现了。防护是把池的上限按并发而不是按核力量,并把连接的存活与回收做成可核对的配置项,纳入变更清单。空闲超时不匹配:如果池认为连接还健康而数据库已经把它断了,第一次请求就会撞到一个失效连接。防护是让池侧的空闲回收时间明确短于数据库侧的断开时间,并开启取出连接时的一次可用性检查。事务跨请求未回滚:最严重的一类,代码里开了事务但异常路径没提交也没回滚,连接带着未结束的事务回到池里,下一个请求复用它,行为取决于该数据库如何处理挂起事务,通常表现为数据诡异或锁等待。防护是事务边界严格与服务边界对齐,请求结束时无条件回滚未完成事务,并把“事务里不做外部调用”作为评审硬项。同一次改造里还要把缓存客户端从纯 PHP 扩展的实现换成运行环境内置的扩展实现,理由是内置那层才能启用对应的钩子;这条属于“运行时行为变了但接口没变”的改动,上线时容易被漏在变更说明里,我们的做法是把它和连接池参数写在同一张变更单上,回滚时一起回滚。

整改交付时我们不再用“这个接口改成异步了”这种描述,而是按三列给每个接口定档:能否容忍延迟、能否容忍失败不可见、能否容忍重复。三列都填“能”,才允许进队列;填“不能”的那一列同时决定要补的实现——延迟那一列对应状态表与推送,失败那一列对应可查的失败原因与告警,重复那一列对应去重表与重放边界。三列里只要有一列填“不能”却又无法补齐,结论就回到第一节那条判据:这一步的结论是当前响应的组成部分,它不该被异步化,该被优化的是它自己。