[{"data":1,"prerenderedAt":2108},["ShallowReactive",2],{"\u002F2026-07-23-fa-018-subpath":3,"\u002F2026-07-23-fa-018-subpath-rel":437},{"id":4,"title":5,"body":6,"column":418,"date":419,"description":12,"extension":420,"hero_image":421,"meta":422,"navigation":423,"path":424,"seo":425,"series_id":426,"severity":421,"stem":427,"summary":428,"tags":429,"__hash__":436},"posts\u002F2026-07-23-FA-018-共享存储的subPath陷阱.md","一个 PVC 装所有用户，当时看不出有什么问题",{"type":7,"value":8,"toc":409},"minimark",[9,13,16,21,24,27,35,89,92,102,106,112,115,128,131,135,138,141,144,162,165,173,176,191,195,198,201,212,223,226,229,232,300,303,310,313,316,342,345,356,359,362,402,405],[10,11,12],"p",{},"平台从 Docker Swarm 迁移到 Kubernetes，存储架构做了一个看似理想的调整：用共享 PVC + subPath 替代原来的\"每用户独占 PVC\"。方案优势显而易见——管理简单（1 个 PVC 对 574 个）、自动扩展、成本低。当时看不出有什么缺点。",[10,14,15],{},"但实施中遇到了四个隐蔽的问题。",[17,18,20],"h2",{"id":19},"权限错配bind-型数据的属主陷阱","权限错配：bind 型数据的属主陷阱",[10,22,23],{},"旧系统中，用户数据通过两种方式持久化：bind mount 和 Docker volume。迁移时需要从这两个来源完整导出数据。",[10,25,26],{},"对于 bind mount 型数据，文件属主通常是 10001（或其他固定 UID）。当我登上旧集群的宿主机，以普通用户身份尝试读取这些文件时，只能看到 13 个文件。权限拒绝。同一目录里其实有 4778 个文件，但大多数因为属主权限不匹配而不可见。",[10,28,29,30,34],{},"只有用 sudo 才能完整读到。所以迁移脚本必须用 ",[31,32,33],"code",{},"sudo tar"," 来打包：",[36,37,42],"pre",{"className":38,"code":39,"language":40,"meta":41,"style":41},"language-bash shiki shiki-themes github-light github-dark","sudo tar -czf \u002Ftmp\u002Fmig\u002F${uid}\u002Fdata.tgz \\\n  -C \u002Fmnt\u002Fyun-claw\u002Fusers\u002F${uid} .\n","bash","",[31,43,44,74],{"__ignoreMap":41},[45,46,49,53,57,61,64,68,71],"span",{"class":47,"line":48},"line",1,[45,50,52],{"class":51},"sScJk","sudo",[45,54,56],{"class":55},"sZZnC"," tar",[45,58,60],{"class":59},"sj4cs"," -czf",[45,62,63],{"class":55}," \u002Ftmp\u002Fmig\u002F",[45,65,67],{"class":66},"sVt8B","${uid}",[45,69,70],{"class":55},"\u002Fdata.tgz",[45,72,73],{"class":59}," \\\n",[45,75,77,80,83,86],{"class":47,"line":76},2,[45,78,79],{"class":59},"  -C",[45,81,82],{"class":55}," \u002Fmnt\u002Fyun-claw\u002Fusers\u002F",[45,84,85],{"class":66},"${uid} ",[45,87,88],{"class":55},".\n",[10,90,91],{},"这不是脚本设计的问题，而是 Kubernetes 环境下容器权限与宿主权限映射的一个陷阱。容器内运行的网关进程可能以容器用户身份运行，但宿主上的文件属主是不同的 UID。当两个权限体系接不上，访问就会失败。",[10,93,94,95,98,99,101],{},"新方案中，用户数据通过 subPath 挂到容器的 ",[31,96,97],{},"\u002Fdata"," 目录。如果 subPath 指向的目录是由 Kubernetes 在宿主机上创建的，属主可能是 root 或其他用户，容器内进程（以容器指定的 UID 运行）仍然会遭遇权限问题。这个风险需要在 Dockerfile 和容器启动时明确处理：容器内应该预先创建 ",[31,100,97],{}," 并设置正确的属主，或者使用 securityContext 的 fsGroup 或 runAsUser 来强制权限。",[17,103,105],{"id":104},"subpath-目录创建的时机与属主处理","subPath 目录创建的时机与属主处理",[10,107,108,109],{},"Kubernetes 对 subPath 的处理有个微妙之处：如果挂载的 subPath 目录在宿主机上不存在，kubelet 会自动创建它。但 ",[45,110,111],{},"待补：这个自动创建的目录的属主是什么？是 root 还是其他用户？容器的 securityContext 能否保证挂载后的权限符合预期？",[10,113,114],{},"设计文档中没有明确说明这一点。在实施前，需要验证：",[116,117,118,122,125],"ul",{},[119,120,121],"li",{},"首次 subPath 目录不存在时，kubelet 创建它并将其属主设置为谁",[119,123,124],{},"容器的 securityContext（fsGroup、runAsUser）是否能在挂载时生效",[119,126,127],{},"是否应该预先在共享 PVC 上创建所有 subPath 目录并设置好属主，而不是依赖 Kubernetes 的隐式创建",[10,129,130],{},"没有明确的答案，就容易踩坑。一个保险做法是在 destroy 用户账户时清空 subPath 目录，同时在 provision 时让一个 init-container 验证或修复目录属主，确保容器进程有写权限。",[17,132,134],{"id":133},"单-pvc-中的节点级故障域","单 PVC 中的节点级故障域",[10,136,137],{},"这是最严重的问题，也是与 FA-015（节点假 Ready）的关联点。",[10,139,140],{},"当一个节点的 NFS 客户端或到 SFS 的网络链路发生 hang 时，所有依赖共享 PVC 的 Pod 都会受影响。这不是共享 PVC 本身的问题，而是故障隔离的问题。",[10,142,143],{},"具体现象：",[116,145,146,153,156,159],{},[119,147,148,149,152],{},"如果 NFS 挂载使用了硬 mount（",[31,150,151],{},"hard"," 参数是 NFS 的默认值），而网络故障或存储服务中断，NFS 客户端会无限重试",[119,154,155],{},"重试期间，所有试图访问 NFS 的进程都会被阻塞在 I\u002FO 上（D 状态，不可中断）",[119,157,158],{},"如果 kubelet 或 containerd 的某个操作（如创建容器的卷挂载步骤）陷入 I\u002FO 阻塞，整个节点就会冻死",[119,160,161],{},"该节点上的所有实例（无论是否在访问存储）都会因为无法创建或删除 Pod 而故障",[10,163,164],{},"这与独立 PVC 的效果截然不同。旧架构中，每个用户有独立 PVC，意味着如果某个 PVC 对应的存储有问题，只会影响这一个用户。其他用户的 PVC 可能分散在不同的存储卷甚至不同的节点上，相对独立。",[10,166,167,168,172],{},"新架构下，单个共享 PVC 承载所有用户，一旦这个 PVC 对应的网络链路或挂载出问题，",[169,170,171],"strong",{},"同节点上所有实例都会被殃及","。如果该节点碰巧堆积了 50 个实例，一次节点级的存储 hang 就会导致 50 个实例集体故障，且外表看起来是\"节点 Ready，但实例卡住\"——kubelet 的心跳正常，Pod 状态却卡在 ContainerCreating 或 Terminating。",[10,174,175],{},"这正是 FA-015 在 2026-06-05 观察到的现象：节点假 Ready，但大量实例网关无响应。防护措施包括：",[116,177,178,185,188],{},[119,179,180,181,184],{},"NFS 挂载参数改为软 mount（",[31,182,183],{},"soft,timeo=100,retrans=3","），让 I\u002FO 超时而不是无限等待",[119,186,187],{},"节点加健康检测，检查 ContainerCreating 堆积数和网关探测失败率，及早发现节点冻死",[119,189,190],{},"实例分散部署，用 topologySpreadConstraints 避免单节点堆积",[17,192,194],{"id":193},"无-per-subpath-硬配额","无 per-subPath 硬配额",[10,196,197],{},"共享存储的架构决定了无法在存储层面按 subPath 限制用户配额。SFS（及 NFS 一般）没有 per-directory 的硬配额功能，只能在整个卷级别限制。",[10,199,200],{},"这意味着：",[116,202,203,206,209],{},[119,204,205],{},"无法阻止某个用户的数据膨胀而挤占其他用户的空间",[119,207,208],{},"无法在存储层面实现\"超额用户的写入失败\"",[119,210,211],{},"只能在应用层进行监控、告警和逻辑控制",[10,213,214,215,218,219,222],{},"新系统的方案是应用层监控：定期 ",[31,216,217],{},"du -sb \u002Fdata"," 扫描每个用户的目录大小，落库到 Instance 表，",[31,220,221],{},"\u002Fstatus"," 接口返回超额标志位。前端据此提示用户\"存储已满\"。这是纯监控，不阻断。如果用户无视提示继续写入，直到共享卷真的满了，才会出现\"所有用户集体写入失败\"的惨淡局面。",[10,224,225],{},"这个限制是方案选择的代价。",[17,227,228],{"id":228},"为什么仍然选了这个方案",[10,230,231],{},"尽管有这些问题，平台仍然采用了共享 PVC + subPath 的方案。原因是对比了替代方案：",[233,234,235,254],"table",{},[236,237,238],"thead",{},[239,240,241,245,248,251],"tr",{},[242,243,244],"th",{},"方案",[242,246,247],{},"优点",[242,249,250],{},"缺点",[242,252,253],{},"故障隔离",[255,256,257,272,286],"tbody",{},[239,258,259,263,266,269],{},[260,261,262],"td",{},"独立 PVC（旧架构）",[260,264,265],{},"天然的每用户隔离；故障域清晰",[260,267,268],{},"管理复杂（574 个 PVC）；扩展性差；成本高",[260,270,271],{},"✓ 最好",[239,273,274,277,280,283],{},[260,275,276],{},"共享 PVC + subPath（新架构）",[260,278,279],{},"管理简单（1 个 PVC）；自动扩展；成本低",[260,281,282],{},"故障隔离性差；无硬配额；权限管理复杂",[260,284,285],{},"✗ 较差",[239,287,288,291,294,297],{},[260,289,290],{},"对象存储（S3\u002FOSS）",[260,292,293],{},"真正的多租户隔离；天然分布",[260,295,296],{},"延迟高；成本更高；应用改造大",[260,298,299],{},"✓ 最好，但代价大",[10,301,302],{},"独立 PVC 的管理开销是关键问题。574 个用户意味着 574 个 PVC 对象、574 条 PV 绑定、574 个存储卷。每次实例创建、删除或迁移都要涉及 PVC 的生命周期管理。扩展到 5000 用户时，这种开销会成为瓶颈。对象存储虽然隔离性最好，但需要应用层改造（兼容 S3 API、处理延迟、调整备份策略），而且成本更高。",[10,304,305,306,309],{},"共享 PVC 方案的核心优势是",[169,307,308],{},"运维简洁","：增删用户只需改 subPath（一行 YAML），不涉及存储层操作。代价是故障隔离性从\"用户级\"降到\"节点级\"。",[17,311,312],{"id":312},"适用边界",[10,314,315],{},"这个方案适合以下场景：",[116,317,318,324,330,336],{},[119,319,320,323],{},[169,321,322],{},"用户数量有上限","（几百到几千）。用户过多时，共享卷的单点压力会成问题。",[119,325,326,329],{},[169,327,328],{},"用户数据量可控","（单用户通常 GB 级）。如果单用户数据量达 TB，一次 du 扫描会拖累整体。",[119,331,332,335],{},[169,333,334],{},"可以接受节点级故障隔离","。只要网络和存储配置足够稳定（硬化 NFS 参数、冗余链路），节点 hang 的概率不会很高。",[119,337,338,341],{},[169,339,340],{},"能够实施应用层监控和告警","。无硬配额，就必须有实时监控。",[10,343,344],{},"不适合的场景包括：",[116,346,347,350,353],{},[119,348,349],{},"超大规模用户（万级以上）",[119,351,352],{},"用户数据特别不均衡（少数用户占大头）的场景",[119,354,355],{},"对故障隔离要求极高的系统（比如金融交易）",[17,357,358],{"id":358},"防护与调整",[10,360,361],{},"基于这四个问题，实施中做了以下调整：",[363,364,365,374,384,390,396],"ol",{},[119,366,367,370,371,373],{},[169,368,369],{},"权限处理","：容器 Dockerfile 中预先创建 ",[31,372,97],{}," 并设置正确的属主；启动脚本检查并修复权限。",[119,375,376,379,380,383],{},[169,377,378],{},"NFS 参数硬化","：StorageClass 的挂载选项改为 ",[31,381,382],{},"soft,timeo=100,retrans=3,intr","，避免硬 mount 导致节点冻。",[119,385,386,389],{},[169,387,388],{},"节点健康检测","：cron 每 60 秒检查各节点的 ContainerCreating 堆积和网关探测失败率，及早发现故障。",[119,391,392,395],{},[169,393,394],{},"实例分散","：StatefulSet 加 topologySpreadConstraints，避免单节点堆积 50+ 个实例。",[119,397,398,401],{},[169,399,400],{},"应用层配额","：\u002Fstatus 接口返回 diskUsedMi 和 overQuota 标志位，前端告警用户。",[10,403,404],{},"这些措施不能消除风险，但可以大幅降低故障概率和影响范围。",[406,407,408],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":41,"searchDepth":76,"depth":76,"links":410},[411,412,413,414,415,416,417],{"id":19,"depth":76,"text":20},{"id":104,"depth":76,"text":105},{"id":133,"depth":76,"text":134},{"id":193,"depth":76,"text":194},{"id":228,"depth":76,"text":228},{"id":312,"depth":76,"text":312},{"id":358,"depth":76,"text":358},"故障档案","2026-07-23","md",null,{},true,"\u002F2026-07-23-fa-018-subpath",{"title":5,"description":12},"FA-018","2026-07-23-FA-018-共享存储的subPath陷阱","K8s 共享 PVC + subPath 方案在实施中暴露的四个陷阱：权限不匹配、目录创建时机、节点级故障域、无 per-subPath 配额。",[430,431,432,433,434,435,253],"Kubernetes","存储","subPath","PVC","NFS","权限","4pJJyzbII4OOc9jJS74HUpUfyVc9CfxdE5ovso8ylSE",[438,705,1166],{"id":4,"title":5,"body":439,"column":418,"date":419,"description":12,"extension":420,"hero_image":421,"meta":702,"navigation":423,"path":424,"seo":703,"series_id":426,"severity":421,"stem":427,"summary":428,"tags":704,"__hash__":436},{"type":7,"value":440,"toc":693},[441,443,445,447,449,451,455,485,487,493,495,499,501,509,511,513,515,517,519,531,533,537,539,549,551,553,555,563,569,571,573,575,621,623,627,629,631,649,651,659,661,663,689,691],[10,442,12],{},[10,444,15],{},[17,446,20],{"id":19},[10,448,23],{},[10,450,26],{},[10,452,29,453,34],{},[31,454,33],{},[36,456,457],{"className":38,"code":39,"language":40,"meta":41,"style":41},[31,458,459,475],{"__ignoreMap":41},[45,460,461,463,465,467,469,471,473],{"class":47,"line":48},[45,462,52],{"class":51},[45,464,56],{"class":55},[45,466,60],{"class":59},[45,468,63],{"class":55},[45,470,67],{"class":66},[45,472,70],{"class":55},[45,474,73],{"class":59},[45,476,477,479,481,483],{"class":47,"line":76},[45,478,79],{"class":59},[45,480,82],{"class":55},[45,482,85],{"class":66},[45,484,88],{"class":55},[10,486,91],{},[10,488,94,489,98,491,101],{},[31,490,97],{},[31,492,97],{},[17,494,105],{"id":104},[10,496,108,497],{},[45,498,111],{},[10,500,114],{},[116,502,503,505,507],{},[119,504,121],{},[119,506,124],{},[119,508,127],{},[10,510,130],{},[17,512,134],{"id":133},[10,514,137],{},[10,516,140],{},[10,518,143],{},[116,520,521,525,527,529],{},[119,522,148,523,152],{},[31,524,151],{},[119,526,155],{},[119,528,158],{},[119,530,161],{},[10,532,164],{},[10,534,167,535,172],{},[169,536,171],{},[10,538,175],{},[116,540,541,545,547],{},[119,542,180,543,184],{},[31,544,183],{},[119,546,187],{},[119,548,190],{},[17,550,194],{"id":193},[10,552,197],{},[10,554,200],{},[116,556,557,559,561],{},[119,558,205],{},[119,560,208],{},[119,562,211],{},[10,564,214,565,218,567,222],{},[31,566,217],{},[31,568,221],{},[10,570,225],{},[17,572,228],{"id":228},[10,574,231],{},[233,576,577,589],{},[236,578,579],{},[239,580,581,583,585,587],{},[242,582,244],{},[242,584,247],{},[242,586,250],{},[242,588,253],{},[255,590,591,601,611],{},[239,592,593,595,597,599],{},[260,594,262],{},[260,596,265],{},[260,598,268],{},[260,600,271],{},[239,602,603,605,607,609],{},[260,604,276],{},[260,606,279],{},[260,608,282],{},[260,610,285],{},[239,612,613,615,617,619],{},[260,614,290],{},[260,616,293],{},[260,618,296],{},[260,620,299],{},[10,622,302],{},[10,624,305,625,309],{},[169,626,308],{},[17,628,312],{"id":312},[10,630,315],{},[116,632,633,637,641,645],{},[119,634,635,323],{},[169,636,322],{},[119,638,639,329],{},[169,640,328],{},[119,642,643,335],{},[169,644,334],{},[119,646,647,341],{},[169,648,340],{},[10,650,344],{},[116,652,653,655,657],{},[119,654,349],{},[119,656,352],{},[119,658,355],{},[17,660,358],{"id":358},[10,662,361],{},[363,664,665,671,677,681,685],{},[119,666,667,370,669,373],{},[169,668,369],{},[31,670,97],{},[119,672,673,379,675,383],{},[169,674,378],{},[31,676,382],{},[119,678,679,389],{},[169,680,388],{},[119,682,683,395],{},[169,684,394],{},[119,686,687,401],{},[169,688,400],{},[10,690,404],{},[406,692,408],{},{"title":41,"searchDepth":76,"depth":76,"links":694},[695,696,697,698,699,700,701],{"id":19,"depth":76,"text":20},{"id":104,"depth":76,"text":105},{"id":133,"depth":76,"text":134},{"id":193,"depth":76,"text":194},{"id":228,"depth":76,"text":228},{"id":312,"depth":76,"text":312},{"id":358,"depth":76,"text":358},{},{"title":5,"description":12},[430,431,432,433,434,435,253],{"id":706,"title":707,"body":708,"column":418,"date":1150,"description":1151,"extension":420,"hero_image":421,"meta":1152,"navigation":423,"path":1153,"seo":1154,"series_id":1155,"severity":421,"stem":1156,"summary":1157,"tags":1158,"__hash__":1165},"posts\u002F2026-07-11-FA-017-长任务异步化.md","三个超时机制，一起把长任务掐死了",{"type":7,"value":709,"toc":1142},[710,721,724,727,733,739,745,752,758,762,765,768,776,779,790,796,800,803,810,816,819,834,838,841,852,901,916,923,927,930,933,951,954,962,965,980,983,986,1133,1136,1139],[10,711,712,713,716,717,720],{},"数字人口播的「分析」功能从视频提取口播文稿、分镜脚本、结构卖点，需要下载视频、压缩、调 Gemini 视觉 API，耗时从几十秒到几分钟。最初同步实现：前端调 ",[31,714,715],{},"POST \u002Fapi\u002Fworkflow\u002Fdub\u002Fanalyze-parsed","，后端直接跑完再返回。这个方案暴露的问题不是单点，而是",[169,718,719],{},"三个独立的缺陷叠加","。",[10,722,723],{},"网关超时、CDN 空闲切断、客户端重试——这三个没有一个是\"bug\"，每个都是合理的系统设计。但组合在一起就是连锁灾难：客户端看不到结果（超时或连接断），认为失败了重试，后端却已经扣过费了；或者分析其实已跑完，但因为响应发不出去，重试又跑了一遍。退款时无法幂等判断，某些用户最终被多扣了好几倍。",[10,725,726],{},"具体来说，三个问题各自是什么：",[10,728,729,732],{},[169,730,731],{},"第一个问题是网关超时","。云平台的入口网关（API Gateway）有一个固定的读超时，通常设为几十秒。当后端分析耗时超过这个阈值，网关主动断连接，客户端收到 504 或连接重置，但后端的分析进程并不知道客户端已经走了，继续跑完整个流程。如果分析结果的退款逻辑放在 try-catch 的 finally 里，此时 HTTP 响应已发不出去，客户就看到\"分析失败\"，但款已经扣了。",[10,734,735,738],{},[169,736,737],{},"第二个问题是 CDN 空闲切断","。不少 CDN 和负载均衡层有这样的策略：如果一条 HTTP 连接在某个时间段内没有数据往返，就认为它已死而主动关闭。这与 FA-013 那次踩到的问题一样——长连接因为没有心跳数据而被 CDN 中间件切断，导致请求被突然中断。这个机制在分析请求上也会造成麻烦：如果分析用时较长，连接就可能被切，响应发不出去。",[10,740,741,744],{},[169,742,743],{},"第三个问题是客户端重试导致重复扣费","。当客户端没有收到响应（网关超时或 CDN 切断），一般会重试这个请求。问题在于这次重试是一条完全独立的请求，后端会把它当作新任务处理，再次扣费。而且如果第一次的分析实际上已经跑完了，就会出现\"分析跑了两遍、扣费扣了两遍、用户只看到了一个结果\"的情况。如果第一次分析中途被 CDN 切了，第二次重试重新开始，那扣费可能是三倍。而退款的时候，因为业务流程上没有幂等设计，很难追溯哪个 operationId 应该被退，哪个不应该。",[10,746,747,748,751],{},"这三个问题是",[169,749,750],{},"叠加的","，不是独立的。它们共同指向一个根本的架构问题。",[10,753,754,755,720],{},"根本原因一个：",[169,756,757],{},"不该让耗时任务挂在 HTTP 连接上",[17,759,761],{"id":760},"异步任务是必需的不是可选的","异步任务是必需的，不是可选的",[10,763,764],{},"解决方案很简单：把分析改成异步任务模式。",[10,766,767],{},"前端提交分析请求直接返回任务 ID（HTTP 耗时极短：校验参数、建数据库记录、预扣费）。后端后台 worker 跑分析重逻辑，完成后持久化结果。前端轮询查询状态，得到结果后停止。",[36,769,774],{"className":770,"code":772,"language":773},[771],"language-text","客户端              后端\n  |                 |\n  +--提交---------->|\n  |             创建任务，返回 ID\n  |\u003C--返回 ID-----+\n  |                 |（后台运行分析）\n  |                 |\n  +--轮询--------->|\n  |             查询状态\n  |\u003C--返回状态---+\n  |                 |\n  |（重复轮询）\n  +--轮询--------->|\n  |             查询状态\n  |\u003C--返回结果---+\n  |                 |\n","text",[31,775,772],{"__ignoreMap":41},[10,777,778],{},"好处显而易见：",[116,780,781,784,787],{},[119,782,783],{},"单次 HTTP 请求的耗时从分钟级降到秒级，不会触发网关或 CDN 的超时。",[119,785,786],{},"后端分析的运行生命周期与 HTTP 连接完全解耦。即使连接在中途被切，已经启动的分析会继续在后台跑，结果最终还是会被保存。",[119,788,789],{},"客户端重试不会产生新的分析任务。只要实现幂等判重，同一个请求在短时间内重复提交也只会产生一个任务。",[10,791,792,793,720],{},"但异步化不是万灵药，它把问题转移到了三个新的维度上：",[169,794,795],{},"幂等、持久化、失败语义",[17,797,799],{"id":798},"幂等同一请求只产生一个任务","幂等：同一请求只产生一个任务",[10,801,802],{},"假设客户端因为网络抖动，在短时间内连续发了两次\"开始分析\"请求，后端收到两条独立的 HTTP 请求。如果没有幂等设计，就会创建两个任务、扣两次费。",[10,804,805,806,809],{},"幂等的实现最直接的办法是在",[169,807,808],{},"提交阶段就判重","：为这个用户、这个资源设置一个\"只有一个进行中的分析任务\"的约束。素材中的设计是这样做的：",[36,811,814],{"className":812,"code":813,"language":773},[771],"if 该用户已有 status=running && kind=analyze_parsed 的任务:\n    return 409 Conflict（分析已在进行中）\n",[31,815,813],{"__ignoreMap":41},[10,817,818],{},"这样双击提交同一个视频，第一次返回 201 + taskId，第二次返回 409，客户端看到 409 就知道不要再建新任务，拿之前返回的 ID 去轮询。",[10,820,821,822,825,826,829,830,833],{},"另一个幂等层次是",[169,823,824],{},"计费操作的幂等","。预扣费的时候用一个全局唯一的 ",[31,827,828],{},"operationId","（比如 ",[31,831,832],{},"dub-analyze:{uuid}","），这个 ID 关联了这次预扣。如果扣费 API 被重复调用，因为 operationId 重复，系统只会扣一次。退款时也用同一个 operationId，保证退款与预扣能正确配对。",[17,835,837],{"id":836},"持久化进程重启后任务不丢","持久化：进程重启后任务不丢",[10,839,840],{},"后台分析 worker 可能因为发版、机器重启、容器被杀等原因在中途退出。如果任务的状态只保存在内存里，进程一死任务就丢了。",[10,842,843,844,847,848,851],{},"持久化的关键是",[169,845,846],{},"任务状态表","。素材中复用了既有的 ",[31,849,850],{},"SkyhumanTask"," 表，包含字段：",[116,853,854,860,866,871,877,883,889,895],{},[119,855,856,859],{},[31,857,858],{},"id",": 任务 ID",[119,861,862,865],{},[31,863,864],{},"status",": 进度（running \u002F completed \u002F failed）",[119,867,868,870],{},[31,869,828],{},": 幂等 ID",[119,872,873,876],{},[31,874,875],{},"chargedPoints",": 预扣费用",[119,878,879,882],{},[31,880,881],{},"resultPayload",": 分析结果的 JSON",[119,884,885,888],{},[31,886,887],{},"error",": 失败原因",[119,890,891,894],{},[31,892,893],{},"updatedAt",": 最后更新时间戳",[119,896,897,900],{},[31,898,899],{},"sourceObjectKey",": 源视频对象路径（用于幂等判重和重试）",[10,902,903,904,907,908,911,912,915],{},"任务创建时插一条 ",[31,905,906],{},"status=running"," 的记录。后台 worker 定期通过 heartbeat（每 30 秒做一次空 update，仅触碰 updatedAt）来证明自己还活着。分析完成时更新为 ",[31,909,910],{},"status=completed, resultPayload=...","；失败时更新为 ",[31,913,914],{},"status=failed, error=..."," 并触发退款。",[10,917,918,919,922],{},"这样即使 worker 进程突然死了，任务记录还在数据库里。新启起的 worker 可以扫描表里的 ",[31,920,921],{},"status=running && updatedAt 过期"," 的任务，判断它们已经没有 worker 在跑了，执行清理逻辑。",[17,924,926],{"id":925},"失败语义区分任务失败和查询失败","失败语义：区分\"任务失败\"和\"查询失败\"",[10,928,929],{},"异步设计引入了一个新的错误维度：客户端查询任务状态时得到的 404，可能是\"这个任务 ID 不存在\"（用户输错了），也可能是\"任务 ID 之前存在但已被清理\"（进程清理了过期数据）。这两种情况的含义很不一样。",[10,931,932],{},"更重要的是，前端轮询收到的错误需要区分是否应该重试：",[116,934,935,945],{},[119,936,937,940,941,944],{},[169,938,939],{},"任务本身失败","（",[31,942,943],{},"status=failed, error=\"Gemini API 返回错误\"","）：这种情况不该重试，因为重试也会失败。应该直接向用户展示失败信息，并根据 operationId 触发退款。",[119,946,947,950],{},[169,948,949],{},"查询接口失败","（500、网络超时）：这种情况应该重试查询，因为任务本身可能还在继续跑。",[10,952,953],{},"素材中的设计把这两类区分得很清楚：",[116,955,956,959],{},[119,957,958],{},"任务最终的状态（completed 或 failed）持久化到数据库，客户端查询会拿到明确的业务语义。",[119,960,961],{},"查询接口的 HTTP 错误（5xx）是技术层故障，客户端应该重试。",[10,963,964],{},"同时，后端的 reaper 机制（定期扫表清理超期任务）也遵循这个语义：",[116,966,967,973],{},[119,968,969,970,972],{},"如果一个 ",[31,971,906],{}," 的任务超过 90 秒没有 heartbeat 更新，说明 worker 已死，reaper 会强制置为 failed 并退款。",[119,974,975,976,979],{},"但这个强制置为 failed 不应该抛错，因为此时可能有其他进程也想更新这个任务。更新语句应该带条件：",[31,977,978],{},"UPDATE SkyhumanTask SET status=failed WHERE id=? AND status=running","，这样如果 reaper 和其他 worker 同时操作，只有一方会成功。",[17,981,982],{"id":982},"具体实现模式",[10,984,985],{},"数字人口播的分析异步化采用了这样的模式：",[363,987,988,1033,1080,1103],{},[119,989,990,993,994],{},[169,991,992],{},"提交阶段","（同步）：",[116,995,996,999,1002,1008,1014,1021,1027],{},[119,997,998],{},"校验用户和资源",[119,1000,1001],{},"判重：是否已有进行中的任务（409）",[119,1003,1004,1007],{},[31,1005,1006],{},"getObject"," 拉视频，检测时长（若≤0 返 400 不扣费）",[119,1009,1010,1013],{},[31,1011,1012],{},"chargeResource"," 预扣费用，获得 operationId",[119,1015,1016,1017,1020],{},"创建 ",[31,1018,1019],{},"SkyhumanTask{ status:running, operationId, sourceObjectKey }"," 记录",[119,1022,1023,1026],{},[31,1024,1025],{},"scheduleTask"," 丢到后台队列",[119,1028,1029,1030],{},"返回 ",[31,1031,1032],{},"{ taskId }",[119,1034,1035,1038,1039],{},[169,1036,1037],{},"后台运行阶段","：",[116,1040,1041,1044,1047,1054,1060,1070,1077],{},[119,1042,1043],{},"Worker 取出任务记录",[119,1045,1046],{},"开启 heartbeat 定时器（每 30s update 一次 updatedAt）",[119,1048,1049,1050,1053],{},"执行 ",[31,1051,1052],{},"analyzeDubVisionOnly","（下载→压缩→调用 Gemini→解析）",[119,1055,1056,1057],{},"成功：",[31,1058,1059],{},"update( status:completed, resultPayload )",[119,1061,1062,1063,1066,1067],{},"失败：",[31,1064,1065],{},"update( status:failed, error )"," + ",[31,1068,1069],{},"refundResource(operationId)",[119,1071,1072,1073,1076],{},"所有 update 都带条件 ",[31,1074,1075],{},"WHERE id=? AND status=running","（防被 reaper 覆盖）",[119,1078,1079],{},"Finally 清理 heartbeat 定时器",[119,1081,1082,1085,1086],{},[169,1083,1084],{},"查询阶段","（前端轮询）：",[116,1087,1088,1097,1100],{},[119,1089,1090,1093,1094],{},[31,1091,1092],{},"GET \u002Fapi\u002Fworkflow\u002Fdub\u002Ftasks\u002F:id"," 返回 ",[31,1095,1096],{},"{ status, resultPayload, error }",[119,1098,1099],{},"若 status=completed 或 failed，停止轮询",[119,1101,1102],{},"若查询接口返回 5xx，则后续重试",[119,1104,1105,1108,1109],{},[169,1106,1107],{},"清理阶段","（reaper）：",[116,1110,1111,1114,1121,1124,1130],{},[119,1112,1113],{},"后台定期扫表",[119,1115,1116,1117,1120],{},"对于 ",[31,1118,1119],{},"status=running && updatedAt > 90s"," 的任务，判定 worker 已死",[119,1122,1123],{},"若 providerTaskId（这里没有）存在，调用上游的 finalize API",[119,1125,1126,1127,1129],{},"若不存在，直接 ",[31,1128,1069],{}," + 置 failed",[119,1131,1132],{},"本设计无 providerTaskId，所以 reaper 自动走退款分支，无需改动 reaper 代码",[10,1134,1135],{},"这个模式的关键是三道防线：提交时判重（409）、后台心跳（定期 touch）、reaper 兜底（自动退款）。任何一个环节出问题，都有后续环节来补救。",[17,1137,1138],{"id":1138},"防线缺一不可",[10,1140,1141],{},"长耗时任务不能挂在 HTTP 连接上不只是超时问题，而是连接脆弱性与重复处理的共谋。异步化表面是\"返回 ID、后台跑\"这一步，真正的难点在三个维度的同时保证：幂等（不重复扣费）、持久化（不丢数据）、失败语义（不破坏重试逻辑）。缺一就会在某个场景暴露。",{"title":41,"searchDepth":76,"depth":76,"links":1143},[1144,1145,1146,1147,1148,1149],{"id":760,"depth":76,"text":761},{"id":798,"depth":76,"text":799},{"id":836,"depth":76,"text":837},{"id":925,"depth":76,"text":926},{"id":982,"depth":76,"text":982},{"id":1138,"depth":76,"text":1138},"2026-07-11","数字人口播的「分析」功能从视频提取口播文稿、分镜脚本、结构卖点，需要下载视频、压缩、调 Gemini 视觉 API，耗时从几十秒到几分钟。最初同步实现：前端调 POST \u002Fapi\u002Fworkflow\u002Fdub\u002Fanalyze-parsed，后端直接跑完再返回。这个方案暴露的问题不是单点，而是三个独立的缺陷叠加。",{},"\u002F2026-07-11-fa-017",{"title":707,"description":1151},"FA-017","2026-07-11-FA-017-长任务异步化","长耗时分析请求（几十秒到分钟级）用同步 HTTP 导致网关超时、CDN切断、客户端重试重复扣费。改异步任务需同时解决幂等、持久化、失败语义。",[1159,1160,1161,1162,1163,1164],"异步任务","HTTP超时","CDN","幂等","任务队列","微服务","ARFDvBMGX3tw-E3Rj_gw1RYEjrykplO6u1dxtdS7Uto",{"id":1167,"title":1168,"body":1169,"column":418,"date":2093,"description":2094,"extension":420,"hero_image":421,"meta":2095,"navigation":423,"path":2096,"seo":2097,"series_id":2098,"severity":421,"stem":2099,"summary":2100,"tags":2101,"__hash__":2107},"posts\u002F2026-06-17-FA-016-WS永久断开三条路径.md","日志里没有重连记录——它根本没在重连",{"type":7,"value":1170,"toc":2081},[1171,1182,1189,1196,1199,1202,1207,1214,1294,1300,1370,1381,1385,1388,1514,1525,1529,1535,1590,1593,1596,1599,1614,1617,1620,1623,1630,1888,1898,1901,1911,1924,1934,1937,1940,1947,1998,2001,2004,2007,2010,2013,2058,2075,2078],[10,1172,1173,1174,1177,1178,1181],{},"从 2026-05-11 开始，多台便携包客户反馈 WebSocket 断线后面板卡死。网关进程（",[31,1175,1176],{},"openclaw_gateway.exe","）明确在运行，",[31,1179,1180],{},"\u002Fhealth"," 返回 200，但面板显示\"已停止重连，请手动刷新\"——用户必须手动重启才能恢复。",[10,1183,1184,1185,1188],{},"关键线索在控制台：看不到任何 ",[31,1186,1187],{},"[ws] 计划重连"," 的日志。",[10,1190,1191,1192,1195],{},"通常断线后客户端会频繁尝试重连，日志里应该是满屏的重连记录。没有日志意味着问题不在重连失败，而在",[169,1193,1194],{},"压根没启动重连机制","。说明客户端进入了某个永久休眠状态。",[17,1197,1198],{"id":1198},"三条独立的永久断开路径",[10,1200,1201],{},"代码审查发现了三条完全不同的、独立的永久断开路径。任意一条命中，就会停止调度任何重连计时器。",[1203,1204,1206],"h3",{"id":1205},"路径-a凭据刷新异常","路径 A：凭据刷新异常",[10,1208,1209,1210,1213],{},"在 ",[31,1211,1212],{},"_scheduleReconnect"," 方法末尾，每次普通重连都会调用：",[36,1215,1219],{"className":1216,"code":1217,"language":1218,"meta":41,"style":41},"language-javascript shiki shiki-themes github-light github-dark","this._reconnectTimer = setTimeout(() => {\n  if (!this._intentionalClose) {\n    this._refreshCredentialsAndReconnect(0)\n  }\n}, delay)\n","javascript",[31,1220,1221,1245,1261,1282,1288],{"__ignoreMap":41},[45,1222,1223,1226,1229,1233,1236,1239,1242],{"class":47,"line":48},[45,1224,1225],{"class":59},"this",[45,1227,1228],{"class":66},"._reconnectTimer ",[45,1230,1232],{"class":1231},"szBVR","=",[45,1234,1235],{"class":51}," setTimeout",[45,1237,1238],{"class":66},"(() ",[45,1240,1241],{"class":1231},"=>",[45,1243,1244],{"class":66}," {\n",[45,1246,1247,1250,1253,1256,1258],{"class":47,"line":76},[45,1248,1249],{"class":1231},"  if",[45,1251,1252],{"class":66}," (",[45,1254,1255],{"class":1231},"!",[45,1257,1225],{"class":59},[45,1259,1260],{"class":66},"._intentionalClose) {\n",[45,1262,1264,1267,1270,1273,1276,1279],{"class":47,"line":1263},3,[45,1265,1266],{"class":59},"    this",[45,1268,1269],{"class":66},".",[45,1271,1272],{"class":51},"_refreshCredentialsAndReconnect",[45,1274,1275],{"class":66},"(",[45,1277,1278],{"class":59},"0",[45,1280,1281],{"class":66},")\n",[45,1283,1285],{"class":47,"line":1284},4,[45,1286,1287],{"class":66},"  }\n",[45,1289,1291],{"class":47,"line":1290},5,[45,1292,1293],{"class":66},"}, delay)\n",[10,1295,1296,1297,1299],{},"而 ",[31,1298,1272],{}," 是异步方法，catch 分支没有任何后续处理：",[36,1301,1303],{"className":1216,"code":1302,"language":1218,"meta":41,"style":41},"} catch (e) {\n  console.error('[ws] 刷新凭据失败:', e)\n  this._setConnected(false, 'error', `凭据刷新失败: ${e}`)\n}\n",[31,1304,1305,1316,1331,1365],{"__ignoreMap":41},[45,1306,1307,1310,1313],{"class":47,"line":48},[45,1308,1309],{"class":66},"} ",[45,1311,1312],{"class":1231},"catch",[45,1314,1315],{"class":66}," (e) {\n",[45,1317,1318,1321,1323,1325,1328],{"class":47,"line":76},[45,1319,1320],{"class":66},"  console.",[45,1322,887],{"class":51},[45,1324,1275],{"class":66},[45,1326,1327],{"class":55},"'[ws] 刷新凭据失败:'",[45,1329,1330],{"class":66},", e)\n",[45,1332,1333,1336,1338,1341,1343,1346,1349,1352,1354,1357,1360,1363],{"class":47,"line":1263},[45,1334,1335],{"class":59},"  this",[45,1337,1269],{"class":66},[45,1339,1340],{"class":51},"_setConnected",[45,1342,1275],{"class":66},[45,1344,1345],{"class":59},"false",[45,1347,1348],{"class":66},", ",[45,1350,1351],{"class":55},"'error'",[45,1353,1348],{"class":66},[45,1355,1356],{"class":55},"`凭据刷新失败: ${",[45,1358,1359],{"class":66},"e",[45,1361,1362],{"class":55},"}`",[45,1364,1281],{"class":66},[45,1366,1367],{"class":47,"line":1284},[45,1368,1369],{"class":66},"}\n",[10,1371,1372,1373,1376,1377,1380],{},"如果 ",[31,1374,1375],{},"api.readOpenclawConfig()"," 或 ",[31,1378,1379],{},"api.autoPairDevice()"," 抛错——磁盘 IO 抖、Rust 端处理器忙、Tauri IPC 队列阻塞——这次重连就永久终止。便携磁盘的 IO 抖动 + 配置文件读写争抢时最容易发生。",[1203,1382,1384],{"id":1383},"路径-b认证失败后的强制关闭","路径 B：认证失败后的强制关闭",[10,1386,1387],{},"当 WebSocket 收到 1008 unauthorized 响应时的处理逻辑：",[36,1389,1391],{"className":1216,"code":1390,"language":1218,"meta":41,"style":41},"if (this._authRetryCount \u003C 2) {\n  this._authRetryCount++\n  this._refreshCredentialsAndReconnect()\n  return\n}\nthis._setConnected(false, 'auth_failed', `认证失败: ${e.reason}。请检查 Gateway Token 配置。`)\nthis._intentionalClose = true   \u002F\u002F ← 永久关闭标记\nthis._flushPending()\nreturn\n",[31,1392,1393,1414,1424,1435,1440,1444,1479,1496,1508],{"__ignoreMap":41},[45,1394,1395,1398,1400,1402,1405,1408,1411],{"class":47,"line":48},[45,1396,1397],{"class":1231},"if",[45,1399,1252],{"class":66},[45,1401,1225],{"class":59},[45,1403,1404],{"class":66},"._authRetryCount ",[45,1406,1407],{"class":1231},"\u003C",[45,1409,1410],{"class":59}," 2",[45,1412,1413],{"class":66},") {\n",[45,1415,1416,1418,1421],{"class":47,"line":76},[45,1417,1335],{"class":59},[45,1419,1420],{"class":66},"._authRetryCount",[45,1422,1423],{"class":1231},"++\n",[45,1425,1426,1428,1430,1432],{"class":47,"line":1263},[45,1427,1335],{"class":59},[45,1429,1269],{"class":66},[45,1431,1272],{"class":51},[45,1433,1434],{"class":66},"()\n",[45,1436,1437],{"class":47,"line":1284},[45,1438,1439],{"class":1231},"  return\n",[45,1441,1442],{"class":47,"line":1290},[45,1443,1369],{"class":66},[45,1445,1447,1449,1451,1453,1455,1457,1459,1462,1464,1467,1469,1471,1474,1477],{"class":47,"line":1446},6,[45,1448,1225],{"class":59},[45,1450,1269],{"class":66},[45,1452,1340],{"class":51},[45,1454,1275],{"class":66},[45,1456,1345],{"class":59},[45,1458,1348],{"class":66},[45,1460,1461],{"class":55},"'auth_failed'",[45,1463,1348],{"class":66},[45,1465,1466],{"class":55},"`认证失败: ${",[45,1468,1359],{"class":66},[45,1470,1269],{"class":55},[45,1472,1473],{"class":66},"reason",[45,1475,1476],{"class":55},"}。请检查 Gateway Token 配置。`",[45,1478,1281],{"class":66},[45,1480,1482,1484,1487,1489,1492],{"class":47,"line":1481},7,[45,1483,1225],{"class":59},[45,1485,1486],{"class":66},"._intentionalClose ",[45,1488,1232],{"class":1231},[45,1490,1491],{"class":59}," true",[45,1493,1495],{"class":1494},"sJ8bj","   \u002F\u002F ← 永久关闭标记\n",[45,1497,1499,1501,1503,1506],{"class":47,"line":1498},8,[45,1500,1225],{"class":59},[45,1502,1269],{"class":66},[45,1504,1505],{"class":51},"_flushPending",[45,1507,1434],{"class":66},[45,1509,1511],{"class":47,"line":1510},9,[45,1512,1513],{"class":1231},"return\n",[10,1515,1516,1517,1520,1521,1524],{},"设置 ",[31,1518,1519],{},"_intentionalClose = true"," 之后，所有重连逻辑都被 ",[31,1522,1523],{},"if (!this._intentionalClose)"," 短路。即使只是 Gateway 重启窗口碰好出现 token 短暂不匹配，也会一次性把客户端打死，必须刷新页面才能恢复。",[1203,1526,1528],{"id":1527},"路径-c快速重连配额耗尽","路径 C：快速重连配额耗尽",[10,1530,1531,1532,1038],{},"定义了常数 ",[31,1533,1534],{},"MAX_RECONNECT_ATTEMPTS = 60",[36,1536,1538],{"className":1216,"code":1537,"language":1218,"meta":41,"style":41},"if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n  this._setConnected(false, 'error', `连接失败，已停止重连。请手动刷新页面重试。`)\n  return\n}\n",[31,1539,1540,1559,1582,1586],{"__ignoreMap":41},[45,1541,1542,1544,1546,1548,1551,1554,1557],{"class":47,"line":48},[45,1543,1397],{"class":1231},[45,1545,1252],{"class":66},[45,1547,1225],{"class":59},[45,1549,1550],{"class":66},"._reconnectAttempts ",[45,1552,1553],{"class":1231},">=",[45,1555,1556],{"class":59}," MAX_RECONNECT_ATTEMPTS",[45,1558,1413],{"class":66},[45,1560,1561,1563,1565,1567,1569,1571,1573,1575,1577,1580],{"class":47,"line":76},[45,1562,1335],{"class":59},[45,1564,1269],{"class":66},[45,1566,1340],{"class":51},[45,1568,1275],{"class":66},[45,1570,1345],{"class":59},[45,1572,1348],{"class":66},[45,1574,1351],{"class":55},[45,1576,1348],{"class":66},[45,1578,1579],{"class":55},"`连接失败，已停止重连。请手动刷新页面重试。`",[45,1581,1281],{"class":66},[45,1583,1584],{"class":47,"line":1263},[45,1585,1439],{"class":1231},[45,1587,1588],{"class":47,"line":1284},[45,1589,1369],{"class":66},[10,1591,1592],{},"客户机晚上挂机，Gateway 因为便携磁盘 GC 或 Windows 休眠争抢资源短暂掉线，客户端尝试 60 次仍未成功重连，就永久停摆。早上用户回来面板已成死链。",[17,1594,1595],{"id":1595},"为什么三条缺陷同时存在",[10,1597,1598],{},"这是逐步累加的历史债：",[116,1600,1601,1608,1611],{},[119,1602,1603,1604,1607],{},"路径 B 是在修复\"避免无限自动配对循环\"时添加的保护，但用错了对象——",[31,1605,1606],{},"_intentionalClose=true"," 本来是用户主动断开的语义，不该用在被动失败上",[119,1609,1610],{},"路径 C 是早期\"避免无穷重试\"的防护，但 60 次后完全放弃而不留任何复活路径是绝对错误",[119,1612,1613],{},"路径 A 是在\"凭据 reload\"改动时把所有重连都改走 refreshCredentials，没留意 catch 分支已经成了终态",[10,1615,1616],{},"三条各自独立、都能单独打死客户端，组合在一起命中率非常高。",[17,1618,1619],{"id":1619},"修复方案",[10,1621,1622],{},"核心思想是永不彻底放弃。任何\"快速重连配额耗尽\"的分支都转入慢轮询，留出窗口让用户改配置或等待 Gateway 恢复后能自动复连。",[10,1624,1625,1626,1629],{},"新增辅助方法 ",[31,1627,1628],{},"_schedulePoll(delayMs, kind)"," 作为慢轮询的触发器：",[36,1631,1633],{"className":1216,"code":1632,"language":1218,"meta":41,"style":41},"const AUTH_RETRY_LIMIT = 2\nconst SLOW_POLL_DELAY_AUTH = 60_000      \u002F\u002F 认证持续失败：1 分钟探一次\nconst SLOW_POLL_DELAY_GENERAL = 300_000  \u002F\u002F 一般持续失败：5 分钟探一次\n\n_schedulePoll(delayMs, kind) {\n  this._clearReconnectTimer()\n  this._reconnectAttempts = 0\n  if (kind === 'auth') this._authRetryCount = 0\n  this._reconnectState = 'scheduled'\n  this._pendingReconnect = true\n  this._reconnectTimer = setTimeout(() => {\n    this._reconnectTimer = null\n    if (this._intentionalClose) return\n    this._reconnectState = 'attempting'\n    if (kind === 'auth') {\n      this._refreshCredentialsAndReconnect(0)\n    } else {\n      this._doConnect()\n    }\n  }, delayMs)\n}\n",[31,1634,1635,1649,1664,1679,1684,1692,1703,1714,1738,1750,1763,1780,1792,1807,1819,1832,1848,1859,1871,1877,1883],{"__ignoreMap":41},[45,1636,1637,1640,1643,1646],{"class":47,"line":48},[45,1638,1639],{"class":1231},"const",[45,1641,1642],{"class":59}," AUTH_RETRY_LIMIT",[45,1644,1645],{"class":1231}," =",[45,1647,1648],{"class":59}," 2\n",[45,1650,1651,1653,1656,1658,1661],{"class":47,"line":76},[45,1652,1639],{"class":1231},[45,1654,1655],{"class":59}," SLOW_POLL_DELAY_AUTH",[45,1657,1645],{"class":1231},[45,1659,1660],{"class":59}," 60_000",[45,1662,1663],{"class":1494},"      \u002F\u002F 认证持续失败：1 分钟探一次\n",[45,1665,1666,1668,1671,1673,1676],{"class":47,"line":1263},[45,1667,1639],{"class":1231},[45,1669,1670],{"class":59}," SLOW_POLL_DELAY_GENERAL",[45,1672,1645],{"class":1231},[45,1674,1675],{"class":59}," 300_000",[45,1677,1678],{"class":1494},"  \u002F\u002F 一般持续失败：5 分钟探一次\n",[45,1680,1681],{"class":47,"line":1284},[45,1682,1683],{"emptyLinePlaceholder":423},"\n",[45,1685,1686,1689],{"class":47,"line":1290},[45,1687,1688],{"class":51},"_schedulePoll",[45,1690,1691],{"class":66},"(delayMs, kind) {\n",[45,1693,1694,1696,1698,1701],{"class":47,"line":1446},[45,1695,1335],{"class":59},[45,1697,1269],{"class":66},[45,1699,1700],{"class":51},"_clearReconnectTimer",[45,1702,1434],{"class":66},[45,1704,1705,1707,1709,1711],{"class":47,"line":1481},[45,1706,1335],{"class":59},[45,1708,1550],{"class":66},[45,1710,1232],{"class":1231},[45,1712,1713],{"class":59}," 0\n",[45,1715,1716,1718,1721,1724,1727,1730,1732,1734,1736],{"class":47,"line":1498},[45,1717,1249],{"class":1231},[45,1719,1720],{"class":66}," (kind ",[45,1722,1723],{"class":1231},"===",[45,1725,1726],{"class":55}," 'auth'",[45,1728,1729],{"class":66},") ",[45,1731,1225],{"class":59},[45,1733,1404],{"class":66},[45,1735,1232],{"class":1231},[45,1737,1713],{"class":59},[45,1739,1740,1742,1745,1747],{"class":47,"line":1510},[45,1741,1335],{"class":59},[45,1743,1744],{"class":66},"._reconnectState ",[45,1746,1232],{"class":1231},[45,1748,1749],{"class":55}," 'scheduled'\n",[45,1751,1753,1755,1758,1760],{"class":47,"line":1752},10,[45,1754,1335],{"class":59},[45,1756,1757],{"class":66},"._pendingReconnect ",[45,1759,1232],{"class":1231},[45,1761,1762],{"class":59}," true\n",[45,1764,1766,1768,1770,1772,1774,1776,1778],{"class":47,"line":1765},11,[45,1767,1335],{"class":59},[45,1769,1228],{"class":66},[45,1771,1232],{"class":1231},[45,1773,1235],{"class":51},[45,1775,1238],{"class":66},[45,1777,1241],{"class":1231},[45,1779,1244],{"class":66},[45,1781,1783,1785,1787,1789],{"class":47,"line":1782},12,[45,1784,1266],{"class":59},[45,1786,1228],{"class":66},[45,1788,1232],{"class":1231},[45,1790,1791],{"class":59}," null\n",[45,1793,1795,1798,1800,1802,1805],{"class":47,"line":1794},13,[45,1796,1797],{"class":1231},"    if",[45,1799,1252],{"class":66},[45,1801,1225],{"class":59},[45,1803,1804],{"class":66},"._intentionalClose) ",[45,1806,1513],{"class":1231},[45,1808,1810,1812,1814,1816],{"class":47,"line":1809},14,[45,1811,1266],{"class":59},[45,1813,1744],{"class":66},[45,1815,1232],{"class":1231},[45,1817,1818],{"class":55}," 'attempting'\n",[45,1820,1822,1824,1826,1828,1830],{"class":47,"line":1821},15,[45,1823,1797],{"class":1231},[45,1825,1720],{"class":66},[45,1827,1723],{"class":1231},[45,1829,1726],{"class":55},[45,1831,1413],{"class":66},[45,1833,1835,1838,1840,1842,1844,1846],{"class":47,"line":1834},16,[45,1836,1837],{"class":59},"      this",[45,1839,1269],{"class":66},[45,1841,1272],{"class":51},[45,1843,1275],{"class":66},[45,1845,1278],{"class":59},[45,1847,1281],{"class":66},[45,1849,1851,1854,1857],{"class":47,"line":1850},17,[45,1852,1853],{"class":66},"    } ",[45,1855,1856],{"class":1231},"else",[45,1858,1244],{"class":66},[45,1860,1862,1864,1866,1869],{"class":47,"line":1861},18,[45,1863,1837],{"class":59},[45,1865,1269],{"class":66},[45,1867,1868],{"class":51},"_doConnect",[45,1870,1434],{"class":66},[45,1872,1874],{"class":47,"line":1873},19,[45,1875,1876],{"class":66},"    }\n",[45,1878,1880],{"class":47,"line":1879},20,[45,1881,1882],{"class":66},"  }, delayMs)\n",[45,1884,1886],{"class":47,"line":1885},21,[45,1887,1369],{"class":66},[10,1889,1890,1891,1893,1894,1897],{},"慢轮询命中后失败会重新进入 ",[31,1892,1212],{}," 快速重连周期（因为 ",[31,1895,1896],{},"_reconnectAttempts"," 重置为 0），相当于\"快速 60 次 → 慢一次 → 快速 60 次 → 慢一次\"的循环，永不放弃。",[10,1899,1900],{},"三条修复并行：",[10,1902,1903,1906,1907,1910],{},[169,1904,1905],{},"Fix A","：普通重连直接走 ",[31,1908,1909],{},"_doConnect()","，凭据刷新隔离到认证失败分支，避免配置读取错误牵连普通重连。",[10,1912,1913,1916,1917,1920,1921,1923],{},[169,1914,1915],{},"Fix B","：认证失败耗尽后转入 ",[31,1918,1919],{},"_schedulePoll('auth')","，不再设置 ",[31,1922,1606],{},"，给认证恢复留出 60 秒的探测窗口。",[10,1925,1926,1929,1930,1933],{},[169,1927,1928],{},"Fix C","：重连次数超过 MAX_RECONNECT_ATTEMPTS 后转入 ",[31,1931,1932],{},"_schedulePoll('general')","，设置 UI 状态为\"连接持续失败，300 秒后重试\"而不是终态。",[10,1935,1936],{},"慢轮询失败后重新进入快速重连周期，形成\"快速 60 次 → 慢一次 → 快速 60 次\"的循环，永不放弃。",[17,1938,1939],{"id":1939},"防回归验证",[10,1941,1942,1943,1946],{},"新增 ",[31,1944,1945],{},"wakou-full\u002Fsrc\u002Flib\u002Fws-client.slow-poll.test.js","，覆盖 7 个 case：",[116,1948,1949,1957,1969,1972,1981,1988,1993],{},[119,1950,1951,1952,1954,1955],{},"Fix A：普通重连命中 ",[31,1953,1868],{},"，不调用 ",[31,1956,1272],{},[119,1958,1959,1960,1963,1964,1966,1967],{},"Fix C：MAX_RECONNECT_ATTEMPTS 后状态是 ",[31,1961,1962],{},"reconnecting","（非终态 ",[31,1965,887],{},"），5 分钟后触发 ",[31,1968,1868],{},[119,1970,1971],{},"Fix C：第二轮慢轮询失败后还能再调度快速重连",[119,1973,1974,1975,1977,1978],{},"Fix B：",[31,1976,1919],{}," 等待 60 秒调用 ",[31,1979,1980],{},"_refreshCredentialsAndReconnect(0)",[119,1982,1974,1983,1985,1986],{},[31,1984,1932],{}," 等待后调用 ",[31,1987,1868],{},[119,1989,1990,1992],{},[31,1991,1606],{}," 时慢轮询应跳过",[119,1994,1995,1997],{},[31,1996,1272],{}," 抛错路径调度慢轮询",[10,1999,2000],{},"测试全部通过（7\u002F7）。",[17,2002,2003],{"id":2003},"外部因素的叠加",[10,2005,2006],{},"这个时期同步发生了另一个外部问题：CDN 对 WebSocket 空闲连接会在 4-9 分钟后强制切断。客户端收到连接中断后根据指数退避策略重连，如果恰好命中上述三条路径之一，就进入永久断开状态。这解释了为什么故障特别在长时间挂机后高发——空闲足够长，CDN 必定切断，而后续重连很容易落入某条缺陷路径。两个问题各自独立，但组合效果是\"挂机过夜必死\"。",[17,2008,2009],{"id":2009},"排查验证",[10,2011,2012],{},"故障修复后，可以用以下方式验证重连状态：",[36,2014,2016],{"className":1216,"code":2015,"language":1218,"meta":41,"style":41},"__clawpanelWsClient.getConnectionInfo()\n\u002F\u002F {\n\u002F\u002F   connected: false,\n\u002F\u002F   reconnectState: 'scheduled' | 'attempting',\n\u002F\u002F   reconnectAttempts: 0~60,\n\u002F\u002F   ...\n\u002F\u002F }\n",[31,2017,2018,2028,2033,2038,2043,2048,2053],{"__ignoreMap":41},[45,2019,2020,2023,2026],{"class":47,"line":48},[45,2021,2022],{"class":66},"__clawpanelWsClient.",[45,2024,2025],{"class":51},"getConnectionInfo",[45,2027,1434],{"class":66},[45,2029,2030],{"class":47,"line":76},[45,2031,2032],{"class":1494},"\u002F\u002F {\n",[45,2034,2035],{"class":47,"line":1263},[45,2036,2037],{"class":1494},"\u002F\u002F   connected: false,\n",[45,2039,2040],{"class":47,"line":1284},[45,2041,2042],{"class":1494},"\u002F\u002F   reconnectState: 'scheduled' | 'attempting',\n",[45,2044,2045],{"class":47,"line":1290},[45,2046,2047],{"class":1494},"\u002F\u002F   reconnectAttempts: 0~60,\n",[45,2049,2050],{"class":47,"line":1446},[45,2051,2052],{"class":1494},"\u002F\u002F   ...\n",[45,2054,2055],{"class":47,"line":1481},[45,2056,2057],{"class":1494},"\u002F\u002F }\n",[10,2059,2060,2063,2064,2067,2068,2063,2071,2074],{},[31,2061,2062],{},"reconnectState='scheduled'"," 且 ",[31,2065,2066],{},"reconnectAttempts=0"," 表示在慢轮询窗口正常工作；",[31,2069,2070],{},"reconnectState='idle'",[31,2072,2073],{},"connected=false"," 是老的永久断开路径。",[10,2076,2077],{},"重连机制里避免静默终态是最基本的要求。任何异常路径都必须有可见的状态提示和后续操作入口，否则用户端看到的就是死机。这次故障的根本教训是，不能让客户端在任何情况下进入\"不可恢复\"的状态而没有任何提示。慢轮询的引入给了所有失败情景一个\"最后的机会\"，即使前面的快速重连机制彻底耗尽了，用户等待足够长的时间后系统仍有自动恢复的可能。",[406,2079,2080],{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":41,"searchDepth":76,"depth":76,"links":2082},[2083,2088,2089,2090,2091,2092],{"id":1198,"depth":76,"text":1198,"children":2084},[2085,2086,2087],{"id":1205,"depth":1263,"text":1206},{"id":1383,"depth":1263,"text":1384},{"id":1527,"depth":1263,"text":1528},{"id":1595,"depth":76,"text":1595},{"id":1619,"depth":76,"text":1619},{"id":1939,"depth":76,"text":1939},{"id":2003,"depth":76,"text":2003},{"id":2009,"depth":76,"text":2009},"2026-06-17","从 2026-05-11 开始，多台便携包客户反馈 WebSocket 断线后面板卡死。网关进程（openclaw_gateway.exe）明确在运行，\u002Fhealth 返回 200，但面板显示\"已停止重连，请手动刷新\"——用户必须手动重启才能恢复。",{},"\u002F2026-06-17-fa-016-ws",{"title":1168,"description":2094},"FA-016","2026-06-17-FA-016-WS永久断开三条路径","客户端重连逻辑中三处独立的\"静默终止\"缺陷导致 WS 永久断开，任何一条路径命中就不再调度重连计时器。",[2102,2103,2104,2105,2106],"WebSocket","Node.js","客户端重连机制","异常处理","便携包","qaISKpAwjYnhiSdxEMYYnJtJ-A8QaOq-djOEJ0W9-bM",1785406912030]