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