[{"data":1,"prerenderedAt":3031},["ShallowReactive",2],{"\u002F2026-05-22-fa-012-gwbridge":3,"\u002F2026-05-22-fa-012-gwbridge-rel":1281},{"id":4,"title":5,"body":6,"column":1263,"date":1264,"description":12,"extension":1265,"hero_image":1266,"meta":1267,"navigation":1268,"path":1269,"seo":1270,"series_id":1271,"severity":1266,"stem":1272,"summary":1273,"tags":1274,"__hash__":1280},"posts\u002F2026-05-22-FA-012-gwbridge容量上限.md","一个默认配置，决定了我能有多少用户",{"type":7,"value":8,"toc":1255},"minimark",[9,13,17,20,23,26,29,32,35,38,41,47,55,65,68,149,152,174,177,180,185,192,244,247,250,253,283,286,291,294,319,322,327,330,335,338,341,406,409,412,417,420,423,426,462,465,468,496,499,502,509,514,517,520,526,578,581,586,593,596,615,618,621,626,629,634,637,689,696,707,714,717,804,810,815,821,875,878,881,886,889,892,895,900,903,906,911,914,920,923,930,935,941,944,951,956,959,1003,1006,1009,1027,1037,1043,1046,1051,1054,1068,1075,1080,1083,1088,1091,1094,1098,1104,1113,1129,1135,1138,1141,1167,1170,1190,1193,1196,1201,1216,1221,1224,1227,1251],[10,11,12],"p",{},"我以为这是十分钟的活：一行 apt 改动，给 runtime 镜像加 ffmpeg，打包、灰度、完。结果花了三小时多。不是因为代码复杂，而是在灰度的那一刻，整个基础设施的隐性容量上限暴露了——Docker Swarm 的 gwbridge 只有 253 个 IP，现在已经用满了。",[14,15,16],"h2",{"id":16},"现象",[10,18,19],{},"早上 7:30，用户报 AI 容器里没有 ffmpeg，无法剪视频。表面上这是一行 Dockerfile 改动——apt 列表里漏了 ffmpeg。",[10,21,22],{},"一小时内排查完毕：容器确实没装这个工具。改 Dockerfile、打包新镜像（1.0.18），准备灰度。",[10,24,25],{},"然后，在给一个用户灰度升级时，事情出了岔子。",[10,27,28],{},"Swarm 调度了一个新的 task 去 pull 新镜像，但 gwbridge（Docker overlay 网络中负责容器出站的虚拟网桥）拿不到 IP。Task 失败了。灰度用户被卡在 0\u002F1 状态。自动回滚也失败了。",[10,30,31],{},"同时看 prod 集群的 service 状态，有 10 个长期处于 cycling 启动失败、重试、又失败的状态——这些现象其实已经存在很久，但因为看起来像是\"偶发起不来\"，一直被当作瞬时故障忽视了。现在它们集中暴露了。",[10,33,34],{},"很快发现真问题：生产环境的 gwbridge 是 \u002F24 网段，253 个可用 IP 已经占满。当前有 251 个用户容器 + ingress network 的 1 个 endpoint + 其他系统组件 = 已用 253\u002F253。任何新容器启动时需要分配 endpoint，但池子空了——没有可用 IP，task 调度失败，开始 cycling。",[10,36,37],{},"那 10-13 个看起来\"偶发失败\"的 service？它们就是一直在试图找到那不存在的第 254 个 IP。",[14,39,40],{"id":40},"排查过程",[10,42,43],{},[44,45,46],"strong",{},"8:10 — 首先检查 service 状态",[10,48,49,50,54],{},"连接到生产服务器，运行 ",[51,52,53],"code",{},"docker service ls","，发现当前 service 的复制状态：",[56,57,62],"pre",{"className":58,"code":60,"language":61},[59],"language-text","ID          NAME              MODE        REPLICAS    IMAGE\n...\n251 个 RUNNING 状态的 service，每个 replicas=1\u002F1\n10 个 RUNNING 状态但 replicas=0\u002F1，反复 cycling\n","text",[51,63,60],{"__ignoreMap":64},"",[10,66,67],{},"所有用户的容器中有 251 个正常运行，另外 10 个卡在了反复重试的状态。再深入看 gwbridge 的配置：",[56,69,73],{"className":70,"code":71,"language":72,"meta":64,"style":64},"language-bash shiki shiki-themes github-light github-dark","$ docker network inspect gwbridge\n[{\n  \"Name\": \"docker_gwbridge\",\n  \"Subnet\": \"172.30.1.0\u002F24\",\n  \"Gateway\": \"172.30.1.1\"\n}]\n","bash",[51,74,75,97,104,119,132,143],{"__ignoreMap":64},[76,77,80,84,88,91,94],"span",{"class":78,"line":79},"line",1,[76,81,83],{"class":82},"sScJk","$",[76,85,87],{"class":86},"sZZnC"," docker",[76,89,90],{"class":86}," network",[76,92,93],{"class":86}," inspect",[76,95,96],{"class":86}," gwbridge\n",[76,98,100],{"class":78,"line":99},2,[76,101,103],{"class":102},"sVt8B","[{\n",[76,105,107,110,113,116],{"class":78,"line":106},3,[76,108,109],{"class":86},"  \"Name\"",[76,111,112],{"class":102},": ",[76,114,115],{"class":86},"\"docker_gwbridge\"",[76,117,118],{"class":102},",\n",[76,120,122,125,127,130],{"class":78,"line":121},4,[76,123,124],{"class":86},"  \"Subnet\"",[76,126,112],{"class":102},[76,128,129],{"class":86},"\"172.30.1.0\u002F24\"",[76,131,118],{"class":102},[76,133,135,138,140],{"class":78,"line":134},5,[76,136,137],{"class":86},"  \"Gateway\"",[76,139,112],{"class":102},[76,141,142],{"class":86},"\"172.30.1.1\"\n",[76,144,146],{"class":78,"line":145},6,[76,147,148],{"class":102},"}]\n",[10,150,151],{},"\u002F24 网段分配给了 gwbridge。接下来检查当前分配的 IP 究竟用到了哪里：",[56,153,155],{"className":70,"code":154,"language":72,"meta":64,"style":64},"$ docker network inspect gwbridge --verbose\n",[51,156,157],{"__ignoreMap":64},[76,158,159,161,163,165,167,170],{"class":78,"line":79},[76,160,83],{"class":82},[76,162,87],{"class":86},[76,164,90],{"class":86},[76,166,93],{"class":86},[76,168,169],{"class":86}," gwbridge",[76,171,173],{"class":172},"sj4cs"," --verbose\n",[10,175,176],{},"一遍遍历下来，251 个用户容器占了 endpoint，加上 ingress 及其他系统组件的占位，gwbridge 内 253 个可用 IP 已经无余量。\u002F24 网段理论上是 254 个 IP（.0 到 .255），但除去网络地址 .0 和广播地址 .255，可用的就是 253 个（.1 网关也在其中）。",[10,178,179],{},"已用 253\u002F253。没有余量。这就是为什么新容器无法获取 IP，灰度失败了。",[10,181,182],{},[44,183,184],{},"9:00 — 假设 1：改 daemon.json 配置",[10,186,187,188,191],{},"想过扩大 gwbridge 的网段。改 ",[51,189,190],{},"\u002Fetc\u002Fdocker\u002Fdaemon.json","：",[56,193,197],{"className":194,"code":195,"language":196,"meta":64,"style":64},"language-json shiki shiki-themes github-light github-dark","{\n  \"default-address-pools\": [{\n    \"base\": \"172.31.0.0\u002F16\",\n    \"size\": 22\n  }]\n}\n","json",[51,198,199,204,212,224,234,239],{"__ignoreMap":64},[76,200,201],{"class":78,"line":79},[76,202,203],{"class":102},"{\n",[76,205,206,209],{"class":78,"line":99},[76,207,208],{"class":172},"  \"default-address-pools\"",[76,210,211],{"class":102},": [{\n",[76,213,214,217,219,222],{"class":78,"line":106},[76,215,216],{"class":172},"    \"base\"",[76,218,112],{"class":102},[76,220,221],{"class":86},"\"172.31.0.0\u002F16\"",[76,223,118],{"class":102},[76,225,226,229,231],{"class":78,"line":121},[76,227,228],{"class":172},"    \"size\"",[76,230,112],{"class":102},[76,232,233],{"class":172},"22\n",[76,235,236],{"class":78,"line":134},[76,237,238],{"class":102},"  }]\n",[76,240,241],{"class":78,"line":145},[76,242,243],{"class":102},"}\n",[10,245,246],{},"这样，下次创建网络时，Swarm 应该用 \u002F22（1022 个 IP）而不是 \u002F24（253 个）。",[10,248,249],{},"重启 docker daemon，让配置生效。等待 60 秒，让集群自愈。",[10,251,252],{},"再看 gwbridge：",[56,254,256],{"className":70,"code":255,"language":72,"meta":64,"style":64},"$ docker info | grep \"Default Address Pools:\" -A 1\n",[51,257,258],{"__ignoreMap":64},[76,259,260,262,264,267,271,274,277,280],{"class":78,"line":79},[76,261,83],{"class":82},[76,263,87],{"class":86},[76,265,266],{"class":86}," info",[76,268,270],{"class":269},"szBVR"," |",[76,272,273],{"class":82}," grep",[76,275,276],{"class":86}," \"Default Address Pools:\"",[76,278,279],{"class":172}," -A",[76,281,282],{"class":172}," 1\n",[10,284,285],{},"没有输出。空。配置没读进去。",[10,287,288],{},[44,289,290],{},"9:15 — 尝试各种做法",[10,292,293],{},"想快速解决，试过几个办法：",[295,296,297,305,308],"ol",{},[298,299,300,301,304],"li",{},"删除 ",[51,302,303],{},"\u002Fvar\u002Flib\u002Fdocker\u002Fnetwork\u002Ffiles\u002Flocal-kv.db","（Docker 网络配置持久化库），强制重建。——不行。",[298,306,307],{},"确认 daemon.json 语法无误，重新加载 daemon 配置。——不行。",[298,309,310,311,314,315,318],{},"查阅 Docker 文档。发现一句关键的话：",[51,312,313],{},"default-address-pools"," 只在 ",[51,316,317],{},"swarm init"," 时消费一次。",[10,320,321],{},"意思是，这个配置项的生效时机是 Docker Swarm 初始化那一刻——在那时，Docker 会根据这个池子创建初始网络。但对于已经存在的网络，改这个配置不会有任何效果。gwbridge 是 5 天前 swarm init 时创建的。现在改 daemon.json 已经太晚。配置修改完全无法影响到已有网络的设置。",[10,323,324],{},[44,325,326],{},"9:30 — 改回配置，准备另一条路",[10,328,329],{},"把 daemon.json 恢复到原样，重启 docker。集群自愈回到\"252 healthy + 10 cycling\"状态。",[10,331,332],{},[44,333,334],{},"9:45 — Staging 验证",[10,336,337],{},"有个 staging 环境（同样的 Docker Swarm 架构，但用户数少得多）。用它做 dry-run。",[10,339,340],{},"方案：手动删除 gwbridge，再用新 subnet 重建。",[56,342,344],{"className":70,"code":343,"language":72,"meta":64,"style":64},"docker network rm docker_gwbridge\ndocker network create \\\n  --driver bridge \\\n  --subnet 172.31.0.0\u002F20 \\\n  --gateway 172.31.0.1 \\\n  docker_gwbridge\n",[51,345,346,359,371,381,391,401],{"__ignoreMap":64},[76,347,348,351,353,356],{"class":78,"line":79},[76,349,350],{"class":82},"docker",[76,352,90],{"class":86},[76,354,355],{"class":86}," rm",[76,357,358],{"class":86}," docker_gwbridge\n",[76,360,361,363,365,368],{"class":78,"line":99},[76,362,350],{"class":82},[76,364,90],{"class":86},[76,366,367],{"class":86}," create",[76,369,370],{"class":172}," \\\n",[76,372,373,376,379],{"class":78,"line":106},[76,374,375],{"class":172},"  --driver",[76,377,378],{"class":86}," bridge",[76,380,370],{"class":172},[76,382,383,386,389],{"class":78,"line":121},[76,384,385],{"class":172},"  --subnet",[76,387,388],{"class":86}," 172.31.0.0\u002F20",[76,390,370],{"class":172},[76,392,393,396,399],{"class":78,"line":134},[76,394,395],{"class":172},"  --gateway",[76,397,398],{"class":172}," 172.31.0.1",[76,400,370],{"class":172},[76,402,403],{"class":78,"line":145},[76,404,405],{"class":86},"  docker_gwbridge\n",[10,407,408],{},"Staging 上，105 秒内完成整个过程，所有 service 自动重新调度，容器全部拿回 IP，状态恢复。",[10,410,411],{},"确认这个方案可行。",[10,413,414],{},[44,415,416],{},"9:50 — Canary 试错",[10,418,419],{},"但真正动手前，想在 prod 上做个\"单用户 canary\"——选一个 idle 用户，试试 scale 0（停容器，释放 endpoint）然后 scale 1（重启容器，重新分配 endpoint）。这样可以在生产环境验证删除重建方案是否安全。",[10,421,422],{},"选了用户 B。",[10,424,425],{},"先 scale 0：",[56,427,429],{"className":70,"code":428,"language":72,"meta":64,"style":64},"docker service scale svc-\u003C用户B>=0\n",[51,430,431],{"__ignoreMap":64},[76,432,433,435,438,441,444,447,450,453,456,459],{"class":78,"line":79},[76,434,350],{"class":82},[76,436,437],{"class":86}," service",[76,439,440],{"class":86}," scale",[76,442,443],{"class":86}," svc-",[76,445,446],{"class":269},"\u003C",[76,448,449],{"class":86},"用户",[76,451,452],{"class":102},"B",[76,454,455],{"class":269},">",[76,457,458],{"class":86},"=",[76,460,461],{"class":172},"0\n",[10,463,464],{},"成功。容器停了，gwbridge 上的 endpoint 释放了。一个 IP 应该回归可用。",[10,466,467],{},"再 scale 1：",[56,469,471],{"className":70,"code":470,"language":72,"meta":64,"style":64},"docker service scale svc-\u003C用户B>=1\n",[51,472,473],{"__ignoreMap":64},[76,474,475,477,479,481,483,485,487,489,491,493],{"class":78,"line":79},[76,476,350],{"class":82},[76,478,437],{"class":86},[76,480,440],{"class":86},[76,482,443],{"class":86},[76,484,446],{"class":269},[76,486,449],{"class":86},[76,488,452],{"class":102},[76,490,455],{"class":269},[76,492,458],{"class":86},[76,494,495],{"class":172},"1\n",[10,497,498],{},"预期：gwbridge 应该有 1 个空 IP 现在可用，container 会拿到它。",[10,500,501],{},"实际：task 又启动失败了。为什么？",[10,503,504,505,508],{},"看日志发现：刚释放的那个 IP（比如 172.30.1.100），立刻被其他 cycling 的 task 抢走了。Swarm 内部有失联的 service 在不停地尝试获取 endpoint，它们的重试机制比 ",[51,506,507],{},"scale 1"," 的新 task 更激进。这种负反馈导致用户 B 的容器仍然拿不到 IP。",[10,510,511],{},[44,512,513],{},"9:55 — 关键假设验证",[10,515,516],{},"这个失败给了一个启发：能否先把那 13 个 cycling service 全部 scale 0，让它们停止抢占 IP？",[10,518,519],{},"这样就有 13 个 IP 可用，足够 gwbridge 扩容时临时周转。",[10,521,522,523,191],{},"给 13 个失联 service 全部执行 ",[51,524,525],{},"scale 0",[56,527,529],{"className":70,"code":528,"language":72,"meta":64,"style":64},"docker service ls --filter \"label=health=fail\" --format \"{{.Name}}\" | \\\n  xargs -I {} docker service scale {}=0\n",[51,530,531,556],{"__ignoreMap":64},[76,532,533,535,537,540,543,546,549,552,554],{"class":78,"line":79},[76,534,350],{"class":82},[76,536,437],{"class":86},[76,538,539],{"class":86}," ls",[76,541,542],{"class":172}," --filter",[76,544,545],{"class":86}," \"label=health=fail\"",[76,547,548],{"class":172}," --format",[76,550,551],{"class":86}," \"{{.Name}}\"",[76,553,270],{"class":269},[76,555,370],{"class":172},[76,557,558,561,564,567,569,571,573,576],{"class":78,"line":99},[76,559,560],{"class":82},"  xargs",[76,562,563],{"class":172}," -I",[76,565,566],{"class":86}," {}",[76,568,87],{"class":86},[76,570,437],{"class":86},[76,572,440],{"class":86},[76,574,575],{"class":86}," {}=",[76,577,461],{"class":172},[10,579,580],{},"都成功了。集群稳定，回到\"252 healthy + 0 failing\"（那 13 个现在是 0\u002F0）。",[10,582,583],{},[44,584,585],{},"10:10 — Staging 性能优化验证",[10,587,588,589,592],{},"刚意识到，扩容过程中，swarm 会试图 pull 新镜像。因为网络不好，pull 可能花 60+ 秒。能否通过修改 ",[51,590,591],{},"\u002Fetc\u002Fhosts"," 把 docker.io 黑洞掉，让 Swarm 直接 fallback 到本地镜像？",[10,594,595],{},"在 staging 上测试：在容器启动前加一行 hosts 黑洞：",[56,597,599],{"className":70,"code":598,"language":72,"meta":64,"style":64},"echo \"127.0.0.1 docker.io\" >> \u002Fetc\u002Fhosts\n",[51,600,601],{"__ignoreMap":64},[76,602,603,606,609,612],{"class":78,"line":79},[76,604,605],{"class":172},"echo",[76,607,608],{"class":86}," \"127.0.0.1 docker.io\"",[76,610,611],{"class":269}," >>",[76,613,614],{"class":86}," \u002Fetc\u002Fhosts\n",[10,616,617],{},"效果：scale 0 → scale 1 的过程从 63 秒降到 1 秒。",[10,619,620],{},"确认这个优化可行。会用到生产环境。",[10,622,623],{},[44,624,625],{},"10:25 — 完整备份",[10,627,628],{},"在真正动手删 gwbridge 前，备份所有 265 个 service 的 spec JSON（2.1MB）。以防万一 raft 数据库损坏，这是唯一的重建 service 的办法。",[10,630,631],{},[44,632,633],{},"10:42 — 真正扩容，第一次失败",[10,635,636],{},"开始执行删除并重建流程。先删掉旧 gwbridge，再用新网段重建：",[56,638,639],{"className":70,"code":343,"language":72,"meta":64,"style":64},[51,640,641,651,661,669,677,685],{"__ignoreMap":64},[76,642,643,645,647,649],{"class":78,"line":79},[76,644,350],{"class":82},[76,646,90],{"class":86},[76,648,355],{"class":86},[76,650,358],{"class":86},[76,652,653,655,657,659],{"class":78,"line":99},[76,654,350],{"class":82},[76,656,90],{"class":86},[76,658,367],{"class":86},[76,660,370],{"class":172},[76,662,663,665,667],{"class":78,"line":106},[76,664,375],{"class":172},[76,666,378],{"class":86},[76,668,370],{"class":172},[76,670,671,673,675],{"class":78,"line":121},[76,672,385],{"class":172},[76,674,388],{"class":86},[76,676,370],{"class":172},[76,678,679,681,683],{"class":78,"line":134},[76,680,395],{"class":172},[76,682,398],{"class":172},[76,684,370],{"class":172},[76,686,687],{"class":78,"line":145},[76,688,405],{"class":86},[10,690,691,692,695],{},"第二条命令报错：",[51,693,694],{},"\"Error response from daemon: Pool overlaps with other one on this address space\"","。",[10,697,698,699,702,703,706],{},"原因很讽刺：在 9:15 分的探索期间，改了 daemon.json 为 ",[51,700,701],{},"172.31.0.0\u002F16 size 22","，然后重启了 docker。启动时，docker daemon 会重建系统默认的 bridge 网络。这次它从那个 \u002F16 池子里切了一个 \u002F22 出来——恰好是 ",[51,704,705],{},"172.31.0.0\u002F22","。这块子网现在被 bridge 网络占用了。",[10,708,709,710,713],{},"我想给 gwbridge 分配的 ",[51,711,712],{},"172.31.0.0\u002F20"," 直接包含了这个 \u002F22，两个网络的地址空间产生了重叠，所以冲突。必须换一个不冲突的网段。",[10,715,716],{},"临时决策：换一个网段。探测几个候选 subnet 看哪个能创建：",[56,718,720],{"className":70,"code":719,"language":72,"meta":64,"style":64},"for SUBNET in 172.31.0.0\u002F22 10.30.0.0\u002F20 192.168.32.0\u002F20; do\n  docker network create --subnet \"$SUBNET\" _test 2>&1 | head -1\n  docker network rm _test 2>\u002Fdev\u002Fnull\ndone\n",[51,721,722,748,783,799],{"__ignoreMap":64},[76,723,724,727,730,733,736,739,742,745],{"class":78,"line":79},[76,725,726],{"class":269},"for",[76,728,729],{"class":102}," SUBNET ",[76,731,732],{"class":269},"in",[76,734,735],{"class":86}," 172.31.0.0\u002F22",[76,737,738],{"class":86}," 10.30.0.0\u002F20",[76,740,741],{"class":86}," 192.168.32.0\u002F20",[76,743,744],{"class":102},"; ",[76,746,747],{"class":269},"do\n",[76,749,750,753,755,757,760,763,766,769,772,775,777,780],{"class":78,"line":99},[76,751,752],{"class":82},"  docker",[76,754,90],{"class":86},[76,756,367],{"class":86},[76,758,759],{"class":172}," --subnet",[76,761,762],{"class":86}," \"",[76,764,765],{"class":102},"$SUBNET",[76,767,768],{"class":86},"\"",[76,770,771],{"class":86}," _test",[76,773,774],{"class":269}," 2>&1",[76,776,270],{"class":269},[76,778,779],{"class":82}," head",[76,781,782],{"class":172}," -1\n",[76,784,785,787,789,791,793,796],{"class":78,"line":106},[76,786,752],{"class":82},[76,788,90],{"class":86},[76,790,355],{"class":86},[76,792,771],{"class":86},[76,794,795],{"class":269}," 2>",[76,797,798],{"class":86},"\u002Fdev\u002Fnull\n",[76,800,801],{"class":78,"line":121},[76,802,803],{"class":269},"done\n",[10,805,806,809],{},[51,807,808],{},"10.30.0.0\u002F20"," 可用。改用这个。",[10,811,812],{},[44,813,814],{},"10:45 — 恢复并重建",[10,816,817,818,820],{},"删 gwbridge，用 ",[51,819,808],{}," 重建：",[56,822,824],{"className":70,"code":823,"language":72,"meta":64,"style":64},"docker network rm docker_gwbridge\ndocker network create \\\n  --driver bridge \\\n  --subnet 10.30.0.0\u002F20 \\\n  --gateway 10.30.0.1 \\\n  docker_gwbridge\n",[51,825,826,836,846,854,862,871],{"__ignoreMap":64},[76,827,828,830,832,834],{"class":78,"line":79},[76,829,350],{"class":82},[76,831,90],{"class":86},[76,833,355],{"class":86},[76,835,358],{"class":86},[76,837,838,840,842,844],{"class":78,"line":99},[76,839,350],{"class":82},[76,841,90],{"class":86},[76,843,367],{"class":86},[76,845,370],{"class":172},[76,847,848,850,852],{"class":78,"line":106},[76,849,375],{"class":172},[76,851,378],{"class":86},[76,853,370],{"class":172},[76,855,856,858,860],{"class":78,"line":121},[76,857,385],{"class":172},[76,859,738],{"class":86},[76,861,370],{"class":172},[76,863,864,866,869],{"class":78,"line":134},[76,865,395],{"class":172},[76,867,868],{"class":172}," 10.30.0.1",[76,870,370],{"class":172},[76,872,873],{"class":78,"line":145},[76,874,405],{"class":86},[10,876,877],{},"成功。",[10,879,880],{},"Swarm 立刻开始自动重新调度那 13 个被 scale 0 的 service。在接下来的 1-2 分钟内，所有 270 个 service 都拿回了 IP。gwbridge 当前用量从 0 涨回 267\u002F4094。",[10,882,883],{},[44,884,885],{},"10:55 — 孤儿清理",[10,887,888],{},"有 1 个 service 卡住了，一直 0\u002F1。",[10,890,891],{},"检查发现，旧的 docker-proxy 进程还在占着 host port 18183。container 已经不存在了，但进程没清。端口被占，新容器绑定不了。",[10,893,894],{},"手动 kill 这个 PID，释放端口。Swarm 重试，成功。",[10,896,897],{},[44,898,899],{},"11:00 — 数据库对账",[10,901,902],{},"DB 里有 13 条 Instance 记录状态仍是 PROVISIONING，实际容器已经在 RUNNING。写个快速脚本，把它们更新到 RUNNING。",[10,904,905],{},"同时删了 1 条孤儿 Instance 记录（某用户容器在异常重启时丢了）。",[10,907,908],{},[44,909,910],{},"11:05 — 第一次 ffmpeg 灰度，失败",[10,912,913],{},"现在网络扩容完毕，可以开始灰度 ffmpeg 了。",[10,915,916,917,695],{},"用 digest 形式指定镜像：",[51,918,919],{},"cpa\u002Fopenclaw-runtime:1.0.18@sha256:fcebbc...",[10,921,922],{},"想法是 digest 是 immutable 的，Swarm 可以直接用，不必重新 pull。",[10,924,925,926,929],{},"实际：Swarm 看到 ",[51,927,928],{},"@sha256:..."," 这种格式后，认为这是\"远程仓库 immutable 引用\"，强行去 docker.io pull。Pull 失败（网络不稳定），没有 fallback 到本地已有的 image（本地有 1.0.18 tag，但 digest 不同）。Task fatal error。Rollback。",[10,931,932],{},[44,933,934],{},"11:08 — 第二次灰度，成功",[10,936,937,938,695],{},"改用 tag-only 形式：",[51,939,940],{},"cpa\u002Fopenclaw-runtime:1.0.18",[10,942,943],{},"Swarm 走 pull 流程，失败后自动 fallback 本地同 tag 的 image。成功。",[10,945,946,947,950],{},"容器启动，",[51,948,949],{},"ffmpeg -version"," 输出 5.1.9。实际剪了一个 1 秒黑屏的 mp4 验证。",[10,952,953],{},[44,954,955],{},"11:18 — v4 全量发布",[10,957,958],{},"网络问题解决后，准备一键发布所有用户的容器升级。提交全部 270 个 service 的 update（1.0.17 → 1.0.18）。用 xargs 并发提交：",[56,960,962],{"className":70,"code":961,"language":72,"meta":64,"style":64},"cat service-list.txt | xargs -P 10 -I {} docker service update --image cpa\u002Fopenclaw-runtime:1.0.18 {}\n",[51,963,964],{"__ignoreMap":64},[76,965,966,969,972,974,977,980,983,985,987,989,991,994,997,1000],{"class":78,"line":79},[76,967,968],{"class":82},"cat",[76,970,971],{"class":86}," service-list.txt",[76,973,270],{"class":269},[76,975,976],{"class":82}," xargs",[76,978,979],{"class":172}," -P",[76,981,982],{"class":172}," 10",[76,984,563],{"class":172},[76,986,566],{"class":86},[76,988,87],{"class":86},[76,990,437],{"class":86},[76,992,993],{"class":86}," update",[76,995,996],{"class":172}," --image",[76,998,999],{"class":86}," cpa\u002Fopenclaw-runtime:1.0.18",[76,1001,1002],{"class":86}," {}\n",[10,1004,1005],{},"大约 1 秒完成 270 个 update 提交。",[10,1007,1008],{},"然后脚本进入 Phase 4（收敛判断）。检查所有 service 是否都运行了新镜像：",[56,1010,1012],{"className":70,"code":1011,"language":72,"meta":64,"style":64},"docker service ls --format \"{{.Image}}\"\n",[51,1013,1014],{"__ignoreMap":64},[76,1015,1016,1018,1020,1022,1024],{"class":78,"line":79},[76,1017,350],{"class":82},[76,1019,437],{"class":86},[76,1021,539],{"class":86},[76,1023,548],{"class":172},[76,1025,1026],{"class":86}," \"{{.Image}}\"\n",[10,1028,1029,1030,1033,1034,1036],{},"1 秒钟后看到全部输出都变成了 ",[51,1031,1032],{},"1.0.18","，脚本误判完成，把之前加的 ",[51,1035,591],{}," 黑洞过早还原。",[10,1038,1039,1040,1042],{},"问题来了：Swarm rolling update 是异步的。",[51,1041,53],{}," 看到的是 spec image（声明），不是 task 实际跑的 image（事实）。Spec 在你 update 那一秒就变了，但容器真正切换需要时间——它要先 stop 旧 task，再 pull（或 fallback）新镜像，再 start 新 task。这个过程通常需要数十秒。",[10,1044,1045],{},"结果，脚本还原了 hosts 黑洞，但大批容器正在切换过程中，需要 pull 镜像。此时网络开始响应慢。30 秒的 pull 超时开始积累，导致部分容器切换失败。",[10,1047,1048],{},[44,1049,1050],{},"11:21 — 紧急救援",[10,1052,1053],{},"检测到 hosts 还原过早，立刻加回黑洞：",[56,1055,1056],{"className":70,"code":598,"language":72,"meta":64,"style":64},[51,1057,1058],{"__ignoreMap":64},[76,1059,1060,1062,1064,1066],{"class":78,"line":79},[76,1061,605],{"class":172},[76,1063,608],{"class":86},[76,1065,611],{"class":269},[76,1067,614],{"class":86},[10,1069,1070,1071,1074],{},"等真实收敛。看 ",[51,1072,1073],{},"docker ps --filter \"name=svc-\" --format \"{{.Image}}\"","（task 层的实际镜像），不是 service ls 的 spec。",[10,1076,1077],{},[44,1078,1079],{},"11:25 — 真实收敛",[10,1081,1082],{},"270\u002F270 容器全部跑上 1.0.18。",[10,1084,1085],{},[44,1086,1087],{},"11:26 — 最终验证",[10,1089,1090],{},"抽 3 个用户容器实测 ffmpeg，panel\u002Fcanvas health check = 200，gwbridge 用量 270\u002F4094 (~6.6%)。",[10,1092,1093],{},"全部完成。",[14,1095,1097],{"id":1096},"根因三层问题叠加","根因：三层问题叠加",[10,1099,1100,1103],{},[44,1101,1102],{},"表面","：容器没装 ffmpeg。改 Dockerfile 就行。",[10,1105,1106,1109,1110,1112],{},[44,1107,1108],{},"深层 1 — 架构容量瓶颈","：系统设计是\"1 用户 = 1 容器\"，所以用户数上限 = 可用 IP 数。gwbridge 是 5 天前 ",[51,1111,317],{}," 时以 docker 默认值 \u002F24 创建的，253 个 IP。现在 251 个用户 + ingress 等系统组件已占满。",[10,1114,1115,1118,1119,1122,1123,1125,1126,1128],{},[44,1116,1117],{},"深层 2 — 配置陷阱","：某人改了 ",[51,1120,1121],{},"daemon.json"," 的 ",[51,1124,313],{},"，本想扩容。但这个配置只在 ",[51,1127,317],{}," 那一刻被读取一次。已有网络（gwbridge）不会因为修改配置而改变。这是个文档没突出强调的时机陷阱。",[10,1130,1131,1134],{},[44,1132,1133],{},"深层 3 — 无告警机制","：基础设施容量（IP 池、CPU、内存）应该主动巡检。这里 gwbridge 沉默地跑到 100% 满，用户侧只看到\"个别容器偶发起不来\"，很难追溯到网络容量。",[14,1136,1137],{"id":1137},"修复选择与执行",[10,1139,1140],{},"为什么选\"手动删除重建 gwbridge\"而不是其他：",[295,1142,1143,1149,1155,1161],{},[298,1144,1145,1148],{},[44,1146,1147],{},"改 daemon.json 已验证无效","：现有网络不会因配置变化而改变",[298,1150,1151,1154],{},[44,1152,1153],{},"重启 docker 风险太高","：所有 270 个容器停机 10+ 分钟",[298,1156,1157,1160],{},[44,1158,1159],{},"逐个迁移","：工作量巨大，容易出错",[298,1162,1163,1166],{},[44,1164,1165],{},"删除重建","：只涉及网络对象层面，不影响容器生命周期。Swarm 自动重调度，风险可控",[10,1168,1169],{},"验证阶梯：",[295,1171,1172,1178,1184],{},[298,1173,1174,1177],{},[44,1175,1176],{},"Staging dry-run","（105 秒完成）：确认 service 自动重调度、endpoint 重新分配、容器正常启动",[298,1179,1180,1183],{},[44,1181,1182],{},"单用户 canary","（发现\"释放 IP 被抢\"的问题）：改成先 scale 0 那 13 个 cycling service，再做扩容",[298,1185,1186,1189],{},[44,1187,1188],{},"全量执行","（5 分钟）：删 gwbridge，用 10.30.0.0\u002F20 重建，自动调度完成",[10,1191,1192],{},"结果：253 → 4094 IP（16 倍扩容），13 个失联用户恢复，270 个 service 全部正常。",[14,1194,1195],{"id":1195},"影响与防回归",[10,1197,1198,191],{},[44,1199,1200],{},"停机影响",[1202,1203,1204,1207,1210,1213],"ul",{},[298,1205,1206],{},"gwbridge 重建期间 5-10 分钟全集群不可用",[298,1208,1209],{},"灰度期间部分用户约 25 分钟间歇掉线",[298,1211,1212],{},"全量发布期间大批 task 切换，约 7 分钟影响",[298,1214,1215],{},"数据零丢失",[10,1217,1218,191],{},[44,1219,1220],{},"防回归措施",[10,1222,1223],{},"目前无已实施方案。Action Items 中有 P1 的\"监控告警：gwbridge IP 池 > 70%\"，尚未部署。",[14,1225,1226],{"id":1226},"教训",[295,1228,1229,1235,1245],{},[298,1230,1231,1234],{},[44,1232,1233],{},"基础设施容量要主动巡检，不要等爆表","。隐性容量上限通常以\"偶发故障\"的形式暴露，此时已接近 100%。应定期扫描 IP 池用量，设 70% 和 90% 的告警。",[298,1236,1237,695,1240,314,1242,1244],{},[44,1238,1239],{},"配置生效时机必须验证，不要假设",[51,1241,313],{},[51,1243,317],{}," 消费一次。类似的时机陷阱在分布式系统很常见（初始化 vs 运行时 vs 重启）。每项基础设施配置要写脚本验证一次：改配置 → 观察系统反映（docker info \u002F ps \u002F 日志），不要假设。",[298,1246,1247,1250],{},[44,1248,1249],{},"验证阶梯很重要","。Staging dry-run 测试可行性且暴露意外行为（这次的\"释放 IP 被抢\"）。单用户 canary 在生产真实复现，成本低。全量发布时信心最足。",[1252,1253,1254],"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 .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);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":64,"searchDepth":99,"depth":99,"links":1256},[1257,1258,1259,1260,1261,1262],{"id":16,"depth":99,"text":16},{"id":40,"depth":99,"text":40},{"id":1096,"depth":99,"text":1097},{"id":1137,"depth":99,"text":1137},{"id":1195,"depth":99,"text":1195},{"id":1226,"depth":99,"text":1226},"故障档案","2026-05-22","md",null,{},true,"\u002F2026-05-22-fa-012-gwbridge",{"title":5,"description":12},"FA-012","2026-05-22-FA-012-gwbridge容量上限","一行 apt 改动引出的 16 倍网络扩容：docker daemon.json 的配置陷阱如何偷偷限制了业务增长。",[1275,1276,1277,1278,1279],"Docker Swarm","网络容量","基础设施","隐性容量上限","配置陷阱","VVmDdWNROPXksGoBzLZgxfDwrGQwJ3EaEOK5I1GncQE",[1282,1435,2338],{"id":1283,"title":1284,"body":1285,"column":1263,"date":1421,"description":1289,"extension":1265,"hero_image":1266,"meta":1422,"navigation":1268,"path":1423,"seo":1424,"series_id":1266,"severity":1266,"stem":1425,"summary":1426,"tags":1427,"__hash__":1434},"posts\u002F2026-09-10-登录失效不能只显示空白页.md","登录失效不能只显示空白页",{"type":7,"value":1286,"toc":1414},[1287,1290,1293,1296,1300,1303,1306,1309,1321,1325,1332,1347,1350,1353,1357,1360,1363,1366,1369,1373,1376,1387,1390,1394,1397,1400,1411],[10,1288,1289],{},"用户看到的是空白、卡住或一直加载，系统里却可能已经有一个明确的信号：接口返回了 401。",[10,1291,1292],{},"最近一次修复针对的就是这种错位。登录令牌过期后，前端仍只检查本地存储里有没有令牌字符串，不检查令牌是否还有效。页面因此保持“已登录”的外观，但余额停在同步中，会话列表没有内容，用户也收不到提示。",[10,1294,1295],{},"这不是一个单独页面的 Bug，而是登录状态判断与接口真实状态脱节。",[14,1297,1299],{"id":1298},"现象页面还在业务已经失效","现象：页面还在，业务已经失效",[10,1301,1302],{},"问题出现时，用户仍然可以看到应用外壳。真正需要数据的请求陆续返回 401，页面组件各自处理失败结果，有的停在 loading，有的显示空列表，有的没有任何反馈。",[10,1304,1305],{},"这种状态比直接跳转登录页更难排查，因为页面没有崩溃，浏览器也没有明显报错。用户的描述通常是“突然不能用了”。",[10,1307,1308],{},"根因有两个：",[295,1310,1311,1318],{},[298,1312,1313,1314,1317],{},"本地 ",[51,1315,1316],{},"yc_token"," 只作为“字符串是否存在”的标记；",[298,1319,1320],{},"应用内的请求分散在几十个文件，没有统一的 401 出口。",[14,1322,1324],{"id":1323},"修复在请求边界识别失效","修复：在请求边界识别失效",[10,1326,1327,1328,1331],{},"这次修改没有要求逐个页面补判断，而是在应用入口包一层 ",[51,1329,1330],{},"window.fetch","，把判定条件收紧到三点：",[1202,1333,1334,1341,1344],{},[298,1335,1336,1337,1340],{},"请求是同源 ",[51,1338,1339],{},"\u002Fapi\u002F"," 路径；",[298,1342,1343],{},"请求不在登录、注册和票据登录等合法返回 401 的白名单里；",[298,1345,1346],{},"本地确实存在登录令牌。",[10,1348,1349],{},"满足条件后，才把 401 解释为登录失效，并触发统一提示。外部签名 URL 不会被误判，流式响应也不读取 body，只观察 HTTP status，避免影响聊天和视频编导流程。",[10,1351,1352],{},"这个边界很重要。全局拦截器如果只看状态码，容易把登录接口本身的“凭据错误”当成“会话过期”；如果不区分同源请求，也可能误伤对象存储或其他外部服务。",[14,1354,1356],{"id":1355},"交互先保住用户正在做的事","交互：先保住用户正在做的事",[10,1358,1359],{},"修复没有直接清空登录态，而是分两层提示。",[10,1361,1362],{},"第一层是弹窗，告诉用户会话已失效，并提供重新登录入口。用户可以选择稍后处理，先复制还没有保存的内容。",[10,1364,1365],{},"第二层是顶部常驻提示条。用户关掉弹窗后，页面仍保留可见的恢复入口，避免回到“没有提示的死状态”。",[10,1367,1368],{},"多标签页也是一个边界：如果另一个标签页已经完成续期，当前页面重新检查后应接管新的有效令牌，而不是把它删掉。否则用户在一个标签页登录，另一个标签页却把新令牌清除，问题会从单页失效变成跨标签页互相干扰。",[14,1370,1372],{"id":1371},"测试验证状态转换而不是只测组件渲染","测试：验证状态转换，而不是只测组件渲染",[10,1374,1375],{},"这次变更补了三类测试：",[1202,1377,1378,1381,1384],{},[298,1379,1380],{},"失效令牌触发弹窗和常驻提示；",[298,1382,1383],{},"登录、注册等白名单接口的 401 不触发全局失效；",[298,1385,1386],{},"多标签页已有新令牌时，当前页面能复用有效状态。",[10,1388,1389],{},"测试重点不是“按钮是否出现”，而是确认请求状态、登录状态和用户反馈之间的转换。只有把这三者放在一起验证，才能避免页面看似正常、用户却无法继续工作的情况。",[14,1391,1393],{"id":1392},"结论鉴权错误也是产品状态","结论：鉴权错误也是产品状态",[10,1395,1396],{},"401 不是只留给开发者看的网络状态。对用户来说，它意味着“当前工作无法继续”，必须有明确的恢复路径。",[10,1398,1399],{},"前端鉴权设计至少要回答三个问题：",[295,1401,1402,1405,1408],{},[298,1403,1404],{},"哪些 401 表示凭据错误，哪些表示会话过期；",[298,1406,1407],{},"失效时用户正在编辑的内容如何保留；",[298,1409,1410],{},"多标签页和重试发生时，哪个令牌拥有更高的有效性。",[10,1412,1413],{},"把这三个问题写进代码和测试，登录失效就不再是一张空白页，而是一个可理解、可恢复的产品状态。",{"title":64,"searchDepth":99,"depth":99,"links":1415},[1416,1417,1418,1419,1420],{"id":1298,"depth":99,"text":1299},{"id":1323,"depth":99,"text":1324},{"id":1355,"depth":99,"text":1356},{"id":1371,"depth":99,"text":1372},{"id":1392,"depth":99,"text":1393},"2026-09-10",{},"\u002F2026-09-10",{"title":1284,"description":1289},"2026-09-10-登录失效不能只显示空白页","一次登录令牌过期问题的修复：从分散的接口错误，收口到同源 API 的统一失效提示，同时保留未保存内容和多标签页续期场景。",[1428,1429,1430,1431,1432,1433],"前端","鉴权",401,"fetch","用户体验","测试","mDBl0HrrKMlN2mL_SZGdBwS6YpvL96QS8WWVfThP2c4",{"id":1436,"title":1437,"body":1438,"column":1263,"date":2323,"description":1442,"extension":1265,"hero_image":1266,"meta":2324,"navigation":1268,"path":2325,"seo":2326,"series_id":2327,"severity":1266,"stem":2328,"summary":2329,"tags":2330,"__hash__":2337},"posts\u002F2026-08-31-FA-024-同一批活儿扣了两遍钱.md","同一批活儿，扣了两遍钱",{"type":7,"value":1439,"toc":2315},[1440,1443,1454,1457,1461,1464,1536,1543,1550,1553,1556,1614,1621,1624,1718,1725,1729,1732,1739,1817,1831,1838,1841,1844,1864,1867,1871,1874,1877,1880,1883,1898,1905,1909,1912,1915,1976,1987,1990,2064,2067,2132,2135,2142,2145,2172,2175,2179,2182,2185,2223,2234,2237,2248,2251,2257,2261,2264,2279,2282,2285,2291,2305,2312],[10,1441,1442],{},"用户点「批量生视频」，10 张已经出好的分镜图被整套重跑，再扣一遍钱。",[10,1444,1445,1446,1449,1450,1453],{},"生产数据核实过：两次运行的 ",[51,1447,1448],{},"image.generate"," 都是 ",[51,1451,1452],{},"charged","。不是显示问题，是真的扣了两次。",[10,1455,1456],{},"修的过程里翻出两处独立成因，以及一条让它们同时隐身的原因。",[14,1458,1460],{"id":1459},"一祖先闭包不该当目标传","一、祖先闭包不该当目标传",[10,1462,1463],{},"画布上有一个「运行整组」的入口。前端传目标节点给后端：",[56,1465,1469],{"className":1466,"code":1467,"language":1468,"meta":64,"style":64},"language-tsx shiki shiki-themes github-light github-dark","onRunGroup={\n  onRunTargets && runState && !['estimating', 'submitted', 'running'].includes(runState)\n    ? (groupId) => {\n        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);\n        onRunTargets(targetNodes);\n      }\n    : undefined\n}\n","tsx",[51,1470,1471,1480,1502,1507,1512,1517,1522,1531],{"__ignoreMap":64},[76,1472,1473,1476,1478],{"class":78,"line":79},[76,1474,1475],{"class":102},"onRunGroup",[76,1477,458],{"class":269},[76,1479,203],{"class":102},[76,1481,1482,1485,1488,1491,1494,1496,1499],{"class":78,"line":99},[76,1483,1484],{"class":102},"  onRunTargets && runState && ![",[76,1486,1487],{"class":86},"'estimating'",[76,1489,1490],{"class":102},", ",[76,1492,1493],{"class":86},"'submitted'",[76,1495,1490],{"class":102},[76,1497,1498],{"class":86},"'running'",[76,1500,1501],{"class":102},"].includes(runState)\n",[76,1503,1504],{"class":78,"line":106},[76,1505,1506],{"class":102},"    ? (groupId) => {\n",[76,1508,1509],{"class":78,"line":121},[76,1510,1511],{"class":102},"        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);\n",[76,1513,1514],{"class":78,"line":134},[76,1515,1516],{"class":102},"        onRunTargets(targetNodes);\n",[76,1518,1519],{"class":78,"line":145},[76,1520,1521],{"class":102},"      }\n",[76,1523,1525,1528],{"class":78,"line":1524},7,[76,1526,1527],{"class":102},"    : ",[76,1529,1530],{"class":172},"undefined\n",[76,1532,1534],{"class":78,"line":1533},8,[76,1535,243],{"class":102},[10,1537,1538,1539,1542],{},"它把组内节点连同",[44,1540,1541],{},"祖先闭包","一起传了过去。",[10,1544,1545,1546,1549],{},"后端的复用逻辑是这样的：",[51,1547,1548],{},"reuseCandidates = runSet − targetSet","。已经在目标集合里的节点不会被复用，要重新执行。祖先一旦也算目标，复用候选就成了空集——本来该被复用的上游，全部重跑。",[10,1551,1552],{},"而那 10 张分镜图正是祖先。",[10,1554,1555],{},"修法是把这一步交回给后端：",[56,1557,1559],{"className":1466,"code":1558,"language":1468,"meta":64,"style":64},"? () => {\n    \u002F\u002F 只传组内节点当目标，**绝不能把祖先闭包当目标传**：\n    \u002F\u002F 后端 reuseCandidates = runSet − targetSet，祖先若也算目标，\n    \u002F\u002F 复用候选就成了空集，已出图的上游会被整套重跑并再次扣费\n    \u002F\u002F （线上真实事故：点「批量生视频」把 10 张分镜图重扣了一遍）。\n    \u002F\u002F 祖先闭包由后端 pruneFlowForTargets 自己算。\n    onRunTargets(group.nodeIds);\n  }\n",[51,1560,1561,1575,1581,1586,1591,1596,1601,1609],{"__ignoreMap":64},[76,1562,1563,1566,1569,1572],{"class":78,"line":79},[76,1564,1565],{"class":269},"?",[76,1567,1568],{"class":102}," () ",[76,1570,1571],{"class":269},"=>",[76,1573,1574],{"class":102}," {\n",[76,1576,1577],{"class":78,"line":99},[76,1578,1580],{"class":1579},"sJ8bj","    \u002F\u002F 只传组内节点当目标，**绝不能把祖先闭包当目标传**：\n",[76,1582,1583],{"class":78,"line":106},[76,1584,1585],{"class":1579},"    \u002F\u002F 后端 reuseCandidates = runSet − targetSet，祖先若也算目标，\n",[76,1587,1588],{"class":78,"line":121},[76,1589,1590],{"class":1579},"    \u002F\u002F 复用候选就成了空集，已出图的上游会被整套重跑并再次扣费\n",[76,1592,1593],{"class":78,"line":134},[76,1594,1595],{"class":1579},"    \u002F\u002F （线上真实事故：点「批量生视频」把 10 张分镜图重扣了一遍）。\n",[76,1597,1598],{"class":78,"line":145},[76,1599,1600],{"class":1579},"    \u002F\u002F 祖先闭包由后端 pruneFlowForTargets 自己算。\n",[76,1602,1603,1606],{"class":78,"line":1524},[76,1604,1605],{"class":82},"    onRunTargets",[76,1607,1608],{"class":102},"(group.nodeIds);\n",[76,1610,1611],{"class":78,"line":1533},[76,1612,1613],{"class":102},"  }\n",[10,1615,1616,1617,1620],{},"祖先闭包本来就由后端的 ",[51,1618,1619],{},"pruneFlowForTargets"," 算。前端多算一遍，算的不只是重复劳动，而是一个语义不同的集合：后端要的是「用户想跑的」，前端给的是「跑这个需要的」。",[10,1622,1623],{},"补的守卫测试很直白——组件层的回调只能接收一个组 ID：",[56,1625,1627],{"className":1466,"code":1626,"language":1468,"meta":64,"style":64},"it(\"运行整组时只把组内节点当目标，不能传祖先闭包（线上重复扣费事故）\", () => {\n  fireEvent.click(screen.getByRole(\"button\", { name: \"整组执行\" }));\n  expect(onRunGroup).toHaveBeenCalledWith(mockGroup.id);\n  expect(onRunGroup.mock.calls[0]).toHaveLength(1);\n});\n",[51,1628,1629,1647,1675,1689,1713],{"__ignoreMap":64},[76,1630,1631,1634,1637,1640,1643,1645],{"class":78,"line":79},[76,1632,1633],{"class":82},"it",[76,1635,1636],{"class":102},"(",[76,1638,1639],{"class":86},"\"运行整组时只把组内节点当目标，不能传祖先闭包（线上重复扣费事故）\"",[76,1641,1642],{"class":102},", () ",[76,1644,1571],{"class":269},[76,1646,1574],{"class":102},[76,1648,1649,1652,1655,1658,1661,1663,1666,1669,1672],{"class":78,"line":99},[76,1650,1651],{"class":102},"  fireEvent.",[76,1653,1654],{"class":82},"click",[76,1656,1657],{"class":102},"(screen.",[76,1659,1660],{"class":82},"getByRole",[76,1662,1636],{"class":102},[76,1664,1665],{"class":86},"\"button\"",[76,1667,1668],{"class":102},", { name: ",[76,1670,1671],{"class":86},"\"整组执行\"",[76,1673,1674],{"class":102}," }));\n",[76,1676,1677,1680,1683,1686],{"class":78,"line":106},[76,1678,1679],{"class":82},"  expect",[76,1681,1682],{"class":102},"(onRunGroup).",[76,1684,1685],{"class":82},"toHaveBeenCalledWith",[76,1687,1688],{"class":102},"(mockGroup.id);\n",[76,1690,1691,1693,1696,1699,1702,1705,1707,1710],{"class":78,"line":121},[76,1692,1679],{"class":82},[76,1694,1695],{"class":102},"(onRunGroup.mock.calls[",[76,1697,1698],{"class":172},"0",[76,1700,1701],{"class":102},"]).",[76,1703,1704],{"class":82},"toHaveLength",[76,1706,1636],{"class":102},[76,1708,1709],{"class":172},"1",[76,1711,1712],{"class":102},");\n",[76,1714,1715],{"class":78,"line":134},[76,1716,1717],{"class":102},"});\n",[10,1719,1720,1721,1724],{},"同一处 ",[51,1722,1723],{},"ancestorClosure"," 在运行面板里保留着，那里是用它显示「运行 N 个（复用 M 个）」的，不参与提交。",[14,1726,1728],{"id":1727},"二落库的行对不上","二、落库的行对不上",[10,1730,1731],{},"第二处成因更早，也更深。",[10,1733,1734,1735,1738],{},"画布运行会把每个节点展开成若干行写入 ",[51,1736,1737],{},"CanvasFlowNodeRun","。批量框要求一个节点跑 N 份，所以展开后的行需要一个复合标识：",[56,1740,1744],{"className":1741,"code":1742,"language":1743,"meta":64,"style":64},"language-ts shiki shiki-themes github-light github-dark","const nodeRuns = expandedNodes.map((node) => ({\n  id: generateNodeRunId(),\n  runId,\n  nodeId: node.nodeId,   \u002F\u002F 展开后的复合 id\n  batchIndex: node.batchIndex,\n  \u002F\u002F ...\n}))\n","ts",[51,1745,1746,1778,1789,1794,1802,1807,1812],{"__ignoreMap":64},[76,1747,1748,1751,1754,1757,1760,1763,1766,1770,1773,1775],{"class":78,"line":79},[76,1749,1750],{"class":269},"const",[76,1752,1753],{"class":172}," nodeRuns",[76,1755,1756],{"class":269}," =",[76,1758,1759],{"class":102}," expandedNodes.",[76,1761,1762],{"class":82},"map",[76,1764,1765],{"class":102},"((",[76,1767,1769],{"class":1768},"s4XuR","node",[76,1771,1772],{"class":102},") ",[76,1774,1571],{"class":269},[76,1776,1777],{"class":102}," ({\n",[76,1779,1780,1783,1786],{"class":78,"line":99},[76,1781,1782],{"class":102},"  id: ",[76,1784,1785],{"class":82},"generateNodeRunId",[76,1787,1788],{"class":102},"(),\n",[76,1790,1791],{"class":78,"line":106},[76,1792,1793],{"class":102},"  runId,\n",[76,1795,1796,1799],{"class":78,"line":121},[76,1797,1798],{"class":102},"  nodeId: node.nodeId,   ",[76,1800,1801],{"class":1579},"\u002F\u002F 展开后的复合 id\n",[76,1803,1804],{"class":78,"line":134},[76,1805,1806],{"class":102},"  batchIndex: node.batchIndex,\n",[76,1808,1809],{"class":78,"line":145},[76,1810,1811],{"class":1579},"  \u002F\u002F ...\n",[76,1813,1814],{"class":78,"line":1524},[76,1815,1816],{"class":102},"}))\n",[10,1818,1819,1820,1823,1824,1827,1828,695],{},"问题在这张表的唯一约束是 ",[51,1821,1822],{},"@@unique([runId, nodeId, batchIndex])","，而 ",[51,1825,1826],{},"nodeId"," 落的是",[44,1829,1830],{},"展开后的复合 id",[10,1832,1833,1834,1837],{},"于是 executor 那一侧全线对不上。它按 ",[51,1835,1836],{},"nodeId + batchIndex"," 反查行、统计每份的项数、构建调度状态——落库的键和它查的键不是一套。",[10,1839,1840],{},"调度器找不到已派发的记录，于是重复派发同一个批量单元。周期的量级是 100 毫秒。",[10,1842,1843],{},"修法是把复合 id 留在内存里，落库只落原始 ID：",[56,1845,1847],{"className":1741,"code":1846,"language":1743,"meta":64,"style":64},"\u002F\u002F 行必须以「原始 nodeId + batchIndex」落库（对齐 @@unique([runId, nodeId, batchIndex])）。\n\u002F\u002F 展开后的复合 id 只活在调度器内存里：落了复合 id，executor 的\n\u002F\u002F itemCountsFromRows\u002FbuildSchedulerState 就全都对不上行。\n",[51,1848,1849,1854,1859],{"__ignoreMap":64},[76,1850,1851],{"class":78,"line":79},[76,1852,1853],{"class":1579},"\u002F\u002F 行必须以「原始 nodeId + batchIndex」落库（对齐 @@unique([runId, nodeId, batchIndex])）。\n",[76,1855,1856],{"class":78,"line":99},[76,1857,1858],{"class":1579},"\u002F\u002F 展开后的复合 id 只活在调度器内存里：落了复合 id，executor 的\n",[76,1860,1861],{"class":78,"line":106},[76,1862,1863],{"class":1579},"\u002F\u002F itemCountsFromRows\u002FbuildSchedulerState 就全都对不上行。\n",[10,1865,1866],{},"配套改了 executor 三处状态写库的定位方式，并在 ready 循环里加了一层 in-flight 防重派兜底，防止同类问题再犯。",[14,1868,1870],{"id":1869},"三为什么单测全绿","三、为什么单测全绿",[10,1872,1873],{},"这一处的成因能藏住，是因为测试的写法。",[10,1875,1876],{},"批量链路的每个环节都有自己的单测：创建函数有自己的用例，executor 也有。两组用例各自手写 fixture——创建函数的用例断言它写出了正确的行，executor 的用例喂给它一组正确的行，断言它正确调度。两组都绿。",[10,1878,1879],{},"错的正是「创建函数写出的行」和「executor 期望的行」之间的那个接口。",[10,1881,1882],{},"修的时候补了一个贯通测试环境，理由写在文件头：",[56,1884,1886],{"className":1741,"code":1885,"language":1743,"meta":64,"style":64},"存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费，\n中间不允许手写 fixture（批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的）。\n",[51,1887,1888,1893],{"__ignoreMap":64},[76,1889,1890],{"class":78,"line":79},[76,1891,1892],{"class":102},"存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费，\n",[76,1894,1895],{"class":78,"line":99},[76,1896,1897],{"class":102},"中间不允许手写 fixture（批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的）。\n",[10,1899,1900,1901,1904],{},"新的契约测试从创建一路跑到执行，中间不插桩。断言的是端到端的账：",[51,1902,1903],{},"itemCount=3"," 时，成员 3 行、每份恰好执行一次。",[14,1906,1908],{"id":1907},"四项数从哪来","四、项数从哪来",[10,1910,1911],{},"修完上面两处，还有一个数对不上：预估和实扣。",[10,1913,1914],{},"批量框跑几份，原先是从上游推断的：",[56,1916,1918],{"className":1741,"code":1917,"language":1743,"meta":64,"style":64},"if (upstreamNode.nodeDefId === 'material.input') {\n  const count = (upstreamNode.params as any)?.count ?? 1;\n  \u002F\u002F ...\n}\n",[51,1919,1920,1937,1968,1972],{"__ignoreMap":64},[76,1921,1922,1925,1928,1931,1934],{"class":78,"line":79},[76,1923,1924],{"class":269},"if",[76,1926,1927],{"class":102}," (upstreamNode.nodeDefId ",[76,1929,1930],{"class":269},"===",[76,1932,1933],{"class":86}," 'material.input'",[76,1935,1936],{"class":102},") {\n",[76,1938,1939,1942,1945,1947,1950,1953,1956,1959,1962,1965],{"class":78,"line":99},[76,1940,1941],{"class":269},"  const",[76,1943,1944],{"class":172}," count",[76,1946,1756],{"class":269},[76,1948,1949],{"class":102}," (upstreamNode.params ",[76,1951,1952],{"class":269},"as",[76,1954,1955],{"class":172}," any",[76,1957,1958],{"class":102},")?.count ",[76,1960,1961],{"class":269},"??",[76,1963,1964],{"class":172}," 1",[76,1966,1967],{"class":102},";\n",[76,1969,1970],{"class":78,"line":106},[76,1971,1811],{"class":1579},[76,1973,1974],{"class":78,"line":121},[76,1975,243],{"class":102},[10,1977,1978,1979,1982,1983,1986],{},"注册表里从来没有 ",[51,1980,1981],{},"material.input"," 这个节点——真名是 ",[51,1984,1985],{},"asset.input","。这个分支从未命中过，项数永远回落 1。",[10,1988,1989],{},"改成框上显式配置：",[56,1991,1993],{"className":1741,"code":1992,"language":1743,"meta":64,"style":64},"\u002F**\n * 框内子图跑几份（1~50，缺省 1）。配在框上、由用户手动设置——\n * 「按上游 list 项数自动展开」的推断从未走通过（框架节点没注册），\n * 手动份数是当前唯一的项数来源。\n *\u002F\nitemCount: z.number().int().min(1).max(50).optional(),\n",[51,1994,1995,2000,2005,2010,2015,2020],{"__ignoreMap":64},[76,1996,1997],{"class":78,"line":79},[76,1998,1999],{"class":1579},"\u002F**\n",[76,2001,2002],{"class":78,"line":99},[76,2003,2004],{"class":1579}," * 框内子图跑几份（1~50，缺省 1）。配在框上、由用户手动设置——\n",[76,2006,2007],{"class":78,"line":106},[76,2008,2009],{"class":1579}," * 「按上游 list 项数自动展开」的推断从未走通过（框架节点没注册），\n",[76,2011,2012],{"class":78,"line":121},[76,2013,2014],{"class":1579}," * 手动份数是当前唯一的项数来源。\n",[76,2016,2017],{"class":78,"line":134},[76,2018,2019],{"class":1579}," *\u002F\n",[76,2021,2022,2025,2028,2031,2034,2037,2039,2042,2044,2046,2049,2052,2054,2057,2059,2062],{"class":78,"line":145},[76,2023,2024],{"class":82},"itemCount",[76,2026,2027],{"class":102},": z.",[76,2029,2030],{"class":82},"number",[76,2032,2033],{"class":102},"().",[76,2035,2036],{"class":82},"int",[76,2038,2033],{"class":102},[76,2040,2041],{"class":82},"min",[76,2043,1636],{"class":102},[76,2045,1709],{"class":172},[76,2047,2048],{"class":102},").",[76,2050,2051],{"class":82},"max",[76,2053,1636],{"class":102},[76,2055,2056],{"class":172},"50",[76,2058,2048],{"class":102},[76,2060,2061],{"class":82},"optional",[76,2063,1788],{"class":102},[10,2065,2066],{},"同时把预估也切到同一个来源：",[56,2068,2070],{"className":1741,"code":2069,"language":1743,"meta":64,"style":64},"\u002F**\n * 批量倍数：与执行侧同源——run-create 落行的份数就是框上的 itemCount，\n * 预估必须用同一个数，否则「预计 1 份、实扣 3 份」。\n *\u002F\nexport function batchMultipliersFromFlow(flow: CanvasFlow): Record\u003Cstring, number> {\n",[51,2071,2072,2076,2081,2086,2090],{"__ignoreMap":64},[76,2073,2074],{"class":78,"line":79},[76,2075,1999],{"class":1579},[76,2077,2078],{"class":78,"line":99},[76,2079,2080],{"class":1579}," * 批量倍数：与执行侧同源——run-create 落行的份数就是框上的 itemCount，\n",[76,2082,2083],{"class":78,"line":106},[76,2084,2085],{"class":1579}," * 预估必须用同一个数，否则「预计 1 份、实扣 3 份」。\n",[76,2087,2088],{"class":78,"line":121},[76,2089,2019],{"class":1579},[76,2091,2092,2095,2098,2101,2103,2106,2109,2112,2115,2117,2120,2122,2125,2127,2129],{"class":78,"line":134},[76,2093,2094],{"class":269},"export",[76,2096,2097],{"class":269}," function",[76,2099,2100],{"class":82}," batchMultipliersFromFlow",[76,2102,1636],{"class":102},[76,2104,2105],{"class":1768},"flow",[76,2107,2108],{"class":269},":",[76,2110,2111],{"class":82}," CanvasFlow",[76,2113,2114],{"class":102},")",[76,2116,2108],{"class":269},[76,2118,2119],{"class":82}," Record",[76,2121,446],{"class":102},[76,2123,2124],{"class":172},"string",[76,2126,1490],{"class":102},[76,2128,2030],{"class":172},[76,2130,2131],{"class":102},"> {\n",[10,2133,2134],{},"创建、预估、重试三个路由统一用这个函数。",[10,2136,2137,2138,2141],{},"同一轮里还修了预估的一个老问题：视频节点的预估用的是后台的一口价资源键 ",[51,2139,2140],{},"canvas_video_generate","，而执行侧用的是「模型 + 分辨率 + 是否带上游视频」算出的键，按秒计价。两套键，两套价——预估和实扣自然对不上。预估改用执行同款键。",[10,2143,2144],{},"以及一处显眼的占位：",[56,2146,2148],{"className":1741,"code":2147,"language":1743,"meta":64,"style":64},"\u002F\u002F 改前\nconst totalEstimatedCost = 0; \u002F\u002F TODO: Pass in actual estimate\n",[51,2149,2150,2155],{"__ignoreMap":64},[76,2151,2152],{"class":78,"line":79},[76,2153,2154],{"class":1579},"\u002F\u002F 改前\n",[76,2156,2157,2159,2162,2164,2167,2169],{"class":78,"line":99},[76,2158,1750],{"class":269},[76,2160,2161],{"class":172}," totalEstimatedCost",[76,2163,1756],{"class":269},[76,2165,2166],{"class":172}," 0",[76,2168,744],{"class":102},[76,2170,2171],{"class":1579},"\u002F\u002F TODO: Pass in actual estimate\n",[10,2173,2174],{},"运行记录里的预估成本一直是 0。",[14,2176,2178],{"id":2177},"五续跑为什么不重复扣费","五、续跑为什么不重复扣费",[10,2180,2181],{},"同一轮加了单节点重试。既然重复扣费是这个月的主线，这条功能的实现方式值得记下来。",[10,2183,2184],{},"失败运行的续跑不是「重新跑一遍」：",[56,2186,2188],{"className":1741,"code":2187,"language":1743,"meta":64,"style":64},"\u002F**\n * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n *\u002F\n",[51,2189,2190,2194,2199,2204,2209,2214,2219],{"__ignoreMap":64},[76,2191,2192],{"class":78,"line":79},[76,2193,1999],{"class":1579},[76,2195,2196],{"class":78,"line":99},[76,2197,2198],{"class":1579}," * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n",[76,2200,2201],{"class":78,"line":106},[76,2202,2203],{"class":1579}," * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n",[76,2205,2206],{"class":78,"line":121},[76,2207,2208],{"class":1579}," * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n",[76,2210,2211],{"class":78,"line":134},[76,2212,2213],{"class":1579}," * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n",[76,2215,2216],{"class":78,"line":145},[76,2217,2218],{"class":1579}," * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n",[76,2220,2221],{"class":78,"line":1524},[76,2222,2019],{"class":1579},[10,2224,2225,2226,2229,2230,2233],{},"关键在于复用判定落在",[44,2227,2228],{},"行状态","上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带上原来的 ",[51,2231,2232],{},"billingRef","，调度器看到它就不再执行。",[10,2235,2236],{},"预估也跟着只算子集，余额检查同理。",[10,2238,2239,2240,2243,2244,2247],{},"同一次改动里还补了一个幂等入口——按 ",[51,2241,2242],{},"userId + clientRequestId"," 查重，重复提交返回已有的运行 ID。归属校验用带 ",[51,2245,2246],{},"userId"," 的查询。",[10,2249,2250],{},"界面文案也改了，从「重试」改成「重试失败节点」，带一句说明：",[2252,2253,2254],"blockquote",{},[10,2255,2256],{},"成功节点的产物直接沿用，只有失败的节点会重新执行并计费。",[14,2258,2260],{"id":2259},"六重复扣费这道题","六、重复扣费这道题",[10,2262,2263],{},"两处成因的形式完全不同：",[1202,2265,2266,2273],{},[298,2267,2268,2269,2272],{},"一处是",[44,2270,2271],{},"前端算了一个它不该算的集合","。祖先闭包的计算本身没错，错在把它当成了「目标」。",[298,2274,2268,2275,2278],{},[44,2276,2277],{},"落库的键和查库的键不是一套","。两边各自都有定义，各自都有测试，只是没人对着比过一次。",[10,2280,2281],{},"两条链路的共同点是：扣费发生的时刻离「用户点了什么」很远。用户在界面上点的是一个按钮，扣费发生在调度器认为某个节点未被执行的那一刻。中间隔着目标集合的计算、行的展开、行的落库、调度状态的重建。",[10,2283,2284],{},"链路上每一段都「合理」，叠起来就是收两次钱。",[10,2286,2287,2288,191],{},"防守这类问题的办法不是加校验，是",[44,2289,2290],{},"把口径收成一份",[1202,2292,2293,2296,2299,2302],{},[298,2294,2295],{},"目标集合由后端算，前端只传用户选了什么。",[298,2297,2298],{},"批量份数由框上配置，预估与执行读同一个函数。",[298,2300,2301],{},"复用与否由行状态决定，续跑沿用这套判定，不加例外。",[298,2303,2304],{},"贯通测试不允许手写 fixture——两侧各自造数据，就只能测出两侧各自的正确性。",[10,2306,2307,2308,2311],{},"最后那条是这次最实在的收获。一组测试全绿但系统是错的，通常不是因为用例写得不好，而是因为",[44,2309,2310],{},"用例的输入各自构造","。第三方造的数据一定和另一方一致，因为它们都来自同一个人的同一份理解；真实的接口不一致，恰恰是因为两边由不同的人、在不同的时间实现。",[1252,2313,2314],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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 .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":64,"searchDepth":99,"depth":99,"links":2316},[2317,2318,2319,2320,2321,2322],{"id":1459,"depth":99,"text":1460},{"id":1727,"depth":99,"text":1728},{"id":1869,"depth":99,"text":1870},{"id":1907,"depth":99,"text":1908},{"id":2177,"depth":99,"text":2178},{"id":2259,"depth":99,"text":2260},"2026-08-31",{},"\u002F2026-08-31-fa-024",{"title":1437,"description":1442},"FA-024","2026-08-31-FA-024-同一批活儿扣了两遍钱","点「批量生视频」把 10 张已经出好的分镜图整套重跑并再次扣费。两处独立成因：前端把祖先闭包当目标传给后端，后端落库的行 ID 与唯一约束不一致导致重复派发。",[2331,2332,2333,2334,2335,2336],"画布","批量执行","幂等","重复扣费","契约测试","计费","ajZtLrM5ge42AeND6nbuv6CH5k69juZtT88VhFe_hNc",{"id":2339,"title":2340,"body":2341,"column":1263,"date":3017,"description":2345,"extension":1265,"hero_image":1266,"meta":3018,"navigation":1268,"path":3019,"seo":3020,"series_id":3021,"severity":1266,"stem":3022,"summary":3023,"tags":3024,"__hash__":3030},"posts\u002F2026-08-28-FA-023-图是好的链接死了.md","图是好的，链接死了",{"type":7,"value":2342,"toc":3010},[2343,2346,2349,2352,2356,2359,2365,2368,2371,2377,2380,2387,2390,2443,2446,2449,2453,2456,2459,2528,2531,2537,2584,2587,2591,2594,2597,2603,2606,2706,2713,2716,2795,2806,2812,2816,2819,2825,2828,2833,2836,2908,2914,2921,2924,2928,2934,2945,2948,3004,3007],[10,2344,2345],{},"画布开着十几分钟之后，上面的素材图集体变成「图片加载失败」。",[10,2347,2348],{},"刷新能好，但好十几分钟，然后再裂。用户以为是网络问题，我们一开始也这么以为——直到发现刷新只是换来另一条同样会死的链接。",[10,2350,2351],{},"三处独立的问题叠在一起。",[14,2353,2355],{"id":2354},"一每请求重建一次签名客户端","一、每请求重建一次签名客户端",[10,2357,2358],{},"起因是压测。素材库场景下测图片端点的吞吐：",[56,2360,2363],{"className":2361,"code":2362,"language":61},[59],"素材库场景压测（100 用户 × 24 张图）实测 \u002Fapi\u002Fmedia 卡在约 1400 req\u002Fs，\n同等并发下 \u002Fhealth 有 4100 req\u002Fs。\n",[51,2364,2362],{"__ignoreMap":64},[10,2366,2367],{},"差了三倍。定位到签名逻辑：每次请求都新建一个 S3 客户端。",[10,2369,2370],{},"对比两种写法：",[56,2372,2375],{"className":2373,"code":2374,"language":61},[59],"每请求新建 client + 签名  1.223ms\u002F次 → 818 次\u002F秒\u002F核\n复用 client 只签名        0.385ms\u002F次 → 2598 次\u002F秒\u002F核\n",[51,2376,2374],{"__ignoreMap":64},[10,2378,2379],{},"818 × 2 个 api 副本约等于 1636，与实测的 1400 吻合。",[10,2381,2382,2383,2386],{},"差的那部分不在构造函数上。构造函数本身只要 0.069ms，剩下的一毫秒多花在",[44,2384,2385],{},"首次签名","：新客户端要重新解析中间件栈、区域、凭证链，复用之后这些被记住。",[10,2388,2389],{},"改法是缓存，但缓存键不是「无脑单例」：",[56,2391,2393],{"className":1741,"code":2392,"language":1743,"meta":64,"style":64},"签名客户端按配置缓存。素材库一屏 24 张图，每张都重建 client 时实测 1.223ms\u002F次，\n复用后 0.385ms\u002F次——快 3.2 倍。\n\n按配置做键而不是裸单例：配置换了必须重建，否则会拿旧域名的 client 继续签，\n生产改配置不生效，测试之间也会互相污染。\n",[51,2394,2395,2412,2428,2433,2438],{"__ignoreMap":64},[76,2396,2397,2400,2403,2406,2409],{"class":78,"line":79},[76,2398,2399],{"class":102},"签名客户端按配置缓存。素材库一屏 ",[76,2401,2402],{"class":172},"24",[76,2404,2405],{"class":102}," 张图，每张都重建 client 时实测 1.223ms",[76,2407,2408],{"class":269},"\u002F",[76,2410,2411],{"class":102},"次，\n",[76,2413,2414,2417,2419,2422,2425],{"class":78,"line":99},[76,2415,2416],{"class":102},"复用后 0.385ms",[76,2418,2408],{"class":269},[76,2420,2421],{"class":102},"次——快 ",[76,2423,2424],{"class":172},"3.2",[76,2426,2427],{"class":102}," 倍。\n",[76,2429,2430],{"class":78,"line":106},[76,2431,2432],{"emptyLinePlaceholder":1268},"\n",[76,2434,2435],{"class":78,"line":121},[76,2436,2437],{"class":102},"按配置做键而不是裸单例：配置换了必须重建，否则会拿旧域名的 client 继续签，\n",[76,2439,2440],{"class":78,"line":134},[76,2441,2442],{"class":102},"生产改配置不生效，测试之间也会互相污染。\n",[10,2444,2445],{},"键包含公开域名、端点、区域、桶名、路径风格、访问凭证。反向验证过：注入一个裸单例，三条测试转红。",[10,2447,2448],{},"这一处只解决吞吐，不解决裂图。",[14,2450,2452],{"id":2451},"二前端把签名-url-当永久地址存","二、前端把签名 URL 当永久地址存",[10,2454,2455],{},"裂图的直接原因在前端。",[10,2457,2458],{},"签名 URL 的有效期是 15 分钟（900 秒）。后端每次都现签，这个行为是对的。错的是前端把签好的 URL 存进了缓存，且没有有效期：",[56,2460,2462],{"className":1741,"code":2461,"language":1743,"meta":64,"style":64},"\u002F**\n * 签名 URL 的缓存寿命。\n *\n * 服务端签的是 900 秒，这里只敢存一半多一点——差值是留给「拿到 URL 到图片\n * 真正加载完」这段时间的。\n * 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**：\n * 后端每次都现签是对的，栽的是前端把签好的 URL 当永久地址存了。\n *\u002F\nconst URL_CACHE_TTL_MS = 8 * 60 * 1000;\n",[51,2463,2464,2468,2473,2478,2483,2488,2493,2498,2502],{"__ignoreMap":64},[76,2465,2466],{"class":78,"line":79},[76,2467,1999],{"class":1579},[76,2469,2470],{"class":78,"line":99},[76,2471,2472],{"class":1579}," * 签名 URL 的缓存寿命。\n",[76,2474,2475],{"class":78,"line":106},[76,2476,2477],{"class":1579}," *\n",[76,2479,2480],{"class":78,"line":121},[76,2481,2482],{"class":1579}," * 服务端签的是 900 秒，这里只敢存一半多一点——差值是留给「拿到 URL 到图片\n",[76,2484,2485],{"class":78,"line":134},[76,2486,2487],{"class":1579}," * 真正加载完」这段时间的。\n",[76,2489,2490],{"class":78,"line":145},[76,2491,2492],{"class":1579}," * 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**：\n",[76,2494,2495],{"class":78,"line":1524},[76,2496,2497],{"class":1579}," * 后端每次都现签是对的，栽的是前端把签好的 URL 当永久地址存了。\n",[76,2499,2500],{"class":78,"line":1533},[76,2501,2019],{"class":1579},[76,2503,2505,2507,2510,2512,2515,2518,2521,2523,2526],{"class":78,"line":2504},9,[76,2506,1750],{"class":269},[76,2508,2509],{"class":172}," URL_CACHE_TTL_MS",[76,2511,1756],{"class":269},[76,2513,2514],{"class":172}," 8",[76,2516,2517],{"class":269}," *",[76,2519,2520],{"class":172}," 60",[76,2522,2517],{"class":269},[76,2524,2525],{"class":172}," 1000",[76,2527,1967],{"class":102},[10,2529,2530],{},"8 分钟：服务端签 15 分钟，前端只存一半多，剩下的留给加载。",[10,2532,2533,2534,191],{},"光靠 TTL 还不够。页面可能在后台挂着很久，计时器不准；而且用户的操作序列无法预判。所以补第二个机制——",[44,2535,2536],{},"把裂图当作过期信号",[56,2538,2540],{"className":1741,"code":2539,"language":1743,"meta":64,"style":64},"图片加载失败时调用：作废该 URL 的缓存并重新现签一次。\n签名 URL 会过期，光靠 TTL 猜不准（页面可能在后台挂很久）。裂图本身\n就是最可靠的过期信号——收到它就重签一次，让节点自己好起来。\n同一个 URL 只重试一次，避免真·坏图把请求打成死循环。\n",[51,2541,2542,2553,2569,2574],{"__ignoreMap":64},[76,2543,2544,2547,2550],{"class":78,"line":79},[76,2545,2546],{"class":102},"图片加载失败时调用：作废该 ",[76,2548,2549],{"class":172},"URL",[76,2551,2552],{"class":102}," 的缓存并重新现签一次。\n",[76,2554,2555,2558,2560,2563,2566],{"class":78,"line":99},[76,2556,2557],{"class":102},"签名 ",[76,2559,2549],{"class":172},[76,2561,2562],{"class":102}," 会过期，光靠 ",[76,2564,2565],{"class":172},"TTL",[76,2567,2568],{"class":102}," 猜不准（页面可能在后台挂很久）。裂图本身\n",[76,2570,2571],{"class":78,"line":106},[76,2572,2573],{"class":102},"就是最可靠的过期信号——收到它就重签一次，让节点自己好起来。\n",[76,2575,2576,2579,2581],{"class":78,"line":121},[76,2577,2578],{"class":102},"同一个 ",[76,2580,2549],{"class":172},[76,2582,2583],{"class":102}," 只重试一次，避免真·坏图把请求打成死循环。\n",[10,2585,2586],{},"一个 URL 只重试一次。真的坏图不能把请求打成死循环——测试里连续上报 5 次加载失败，断言请求数不超过 2。",[14,2588,2590],{"id":2589},"三面向浏览器的链路签了-15-分钟的直链","三、面向浏览器的链路，签了 15 分钟的直链",[10,2592,2593],{},"做到这里，症状没了。但同一个坑之前已经踩过一次，这一次是第三次。",[10,2595,2596],{},"前两次都在画布上：",[56,2598,2601],{"className":2599,"code":2600,"language":61},[59],"线上报障：画布上素材图集体「图片加载失败」。根因是这个端点返回了\nresolveAssetUrl 签的 15 分钟 S3 直链，而不是 \u002Fapi\u002Fmedia 稳定路径。\n只断言「有 url 字段」的用例抓不到这种错，必须钉住 URL 的形态。\n",[51,2602,2600],{"__ignoreMap":64},[10,2604,2605],{},"另一次在画布运行产物：",[56,2607,2609],{"className":1741,"code":2608,"language":1743,"meta":64,"style":64},"-    \u002F\u002F 读时现签，复用素材那边同一个签名实现\n-    signObjectUrl: (objectKey: string) => createPrivateObjectReadUrl(objectKey),\n+    \u002F\u002F 必须是 stableAssetUrl（\u002Fapi\u002Fmedia 稳定路径、7 天有效、走后端代理），\n+    \u002F\u002F 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是\n+    \u002F\u002F 直接交给浏览器渲染 \u003Cimg> 的，画布开十几分钟产物图就集体裂（线上报障过）。\n+    \u002F\u002F 「读时现签」只解决了「不存旧 URL」，没解决「签出来的只活 15 分钟」。\n+    signObjectUrl: (objectKey: string) => stableAssetUrl(objectKey, ''),\n",[51,2610,2611,2619,2647,2655,2662,2669,2676],{"__ignoreMap":64},[76,2612,2613,2616],{"class":78,"line":79},[76,2614,2615],{"class":269},"-",[76,2617,2618],{"class":1579},"    \u002F\u002F 读时现签，复用素材那边同一个签名实现\n",[76,2620,2621,2623,2626,2629,2632,2634,2637,2639,2641,2644],{"class":78,"line":99},[76,2622,2615],{"class":269},[76,2624,2625],{"class":82},"    signObjectUrl",[76,2627,2628],{"class":102},": (",[76,2630,2631],{"class":1768},"objectKey",[76,2633,2108],{"class":269},[76,2635,2636],{"class":172}," string",[76,2638,1772],{"class":102},[76,2640,1571],{"class":269},[76,2642,2643],{"class":82}," createPrivateObjectReadUrl",[76,2645,2646],{"class":102},"(objectKey),\n",[76,2648,2649,2652],{"class":78,"line":106},[76,2650,2651],{"class":269},"+",[76,2653,2654],{"class":1579},"    \u002F\u002F 必须是 stableAssetUrl（\u002Fapi\u002Fmedia 稳定路径、7 天有效、走后端代理），\n",[76,2656,2657,2659],{"class":78,"line":121},[76,2658,2651],{"class":269},[76,2660,2661],{"class":1579},"    \u002F\u002F 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是\n",[76,2663,2664,2666],{"class":78,"line":134},[76,2665,2651],{"class":269},[76,2667,2668],{"class":1579},"    \u002F\u002F 直接交给浏览器渲染 \u003Cimg> 的，画布开十几分钟产物图就集体裂（线上报障过）。\n",[76,2670,2671,2673],{"class":78,"line":145},[76,2672,2651],{"class":269},[76,2674,2675],{"class":1579},"    \u002F\u002F 「读时现签」只解决了「不存旧 URL」，没解决「签出来的只活 15 分钟」。\n",[76,2677,2678,2680,2682,2684,2686,2688,2690,2692,2694,2697,2700,2703],{"class":78,"line":1524},[76,2679,2651],{"class":269},[76,2681,2625],{"class":82},[76,2683,2628],{"class":102},[76,2685,2631],{"class":1768},[76,2687,2108],{"class":269},[76,2689,2636],{"class":172},[76,2691,1772],{"class":102},[76,2693,1571],{"class":269},[76,2695,2696],{"class":82}," stableAssetUrl",[76,2698,2699],{"class":102},"(objectKey, ",[76,2701,2702],{"class":86},"''",[76,2704,2705],{"class":102},"),\n",[10,2707,2708,2709,2712],{},"最后那句是这次的实质收获。「读时现签」听起来已经解决了过期问题——每次读都是新的。但签出来的东西只活 15 分钟，而浏览器渲染 ",[51,2710,2711],{},"\u003Cimg>"," 的 URL 会被页面持有到下一次刷新。两个时间尺度不匹配。",[10,2714,2715],{},"真正的修法是给「吐给浏览器」这条路单独一档地址，不再用签名直链，改走后端代理的稳定路径：",[56,2717,2719],{"className":1741,"code":2718,"language":1743,"meta":64,"style":64},"\u002F\u002F 稳定媒体 URL：给前端 \u003Cimg>\u002F\u003Cvideo> 用的持久地址，替代 15 分钟就过期的 S3 签名 URL——\n\u002F\u002F 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 \u002Fapi\u002Fmedia 验签后 302 到读时现签的 S3 URL。\n\u002F\u002F HMAC 即凭证（与 slides raw 下载、local-business-promo blob 同范式，域分隔前缀防跨用），\n\u002F\u002F 不依赖登录态：img 标签发不出 Authorization 头。\n\u002F\u002F\n\u002F\u002F TTL 7 天：覆盖任何真实的页面停留场景。\n\u002F\u002F exp 对齐到天窗口：同一对象同一天内产出字节级相同的 URL，浏览器缓存可命中；\n\u002F\u002F 任意时刻拿到的 URL 剩余有效期至少 6 天，不存在「刚拿到就过期」。\nexport const MEDIA_URL_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n",[51,2720,2721,2726,2731,2736,2741,2746,2751,2756,2761],{"__ignoreMap":64},[76,2722,2723],{"class":78,"line":79},[76,2724,2725],{"class":1579},"\u002F\u002F 稳定媒体 URL：给前端 \u003Cimg>\u002F\u003Cvideo> 用的持久地址，替代 15 分钟就过期的 S3 签名 URL——\n",[76,2727,2728],{"class":78,"line":99},[76,2729,2730],{"class":1579},"\u002F\u002F 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 \u002Fapi\u002Fmedia 验签后 302 到读时现签的 S3 URL。\n",[76,2732,2733],{"class":78,"line":106},[76,2734,2735],{"class":1579},"\u002F\u002F HMAC 即凭证（与 slides raw 下载、local-business-promo blob 同范式，域分隔前缀防跨用），\n",[76,2737,2738],{"class":78,"line":121},[76,2739,2740],{"class":1579},"\u002F\u002F 不依赖登录态：img 标签发不出 Authorization 头。\n",[76,2742,2743],{"class":78,"line":134},[76,2744,2745],{"class":1579},"\u002F\u002F\n",[76,2747,2748],{"class":78,"line":145},[76,2749,2750],{"class":1579},"\u002F\u002F TTL 7 天：覆盖任何真实的页面停留场景。\n",[76,2752,2753],{"class":78,"line":1524},[76,2754,2755],{"class":1579},"\u002F\u002F exp 对齐到天窗口：同一对象同一天内产出字节级相同的 URL，浏览器缓存可命中；\n",[76,2757,2758],{"class":78,"line":1533},[76,2759,2760],{"class":1579},"\u002F\u002F 任意时刻拿到的 URL 剩余有效期至少 6 天，不存在「刚拿到就过期」。\n",[76,2762,2763,2765,2768,2771,2773,2776,2778,2781,2783,2785,2787,2789,2791,2793],{"class":78,"line":2504},[76,2764,2094],{"class":269},[76,2766,2767],{"class":269}," const",[76,2769,2770],{"class":172}," MEDIA_URL_TTL_MS",[76,2772,1756],{"class":269},[76,2774,2775],{"class":172}," 7",[76,2777,2517],{"class":269},[76,2779,2780],{"class":172}," 24",[76,2782,2517],{"class":269},[76,2784,2520],{"class":172},[76,2786,2517],{"class":269},[76,2788,2520],{"class":172},[76,2790,2517],{"class":269},[76,2792,2525],{"class":172},[76,2794,1967],{"class":102},[10,2796,2797,2798,2801,2802,2805],{},"路径形态是 ",[51,2799,2800],{},"\u002Fapi\u002Fmedia?key=…&exp=…&sig=…","。签名用 HMAC，不依赖登录态——",[51,2803,2804],{},"img"," 标签发不出 Authorization 头。",[10,2807,2808,2811],{},[51,2809,2810],{},"exp"," 对齐到天窗口这一条值得单独说：同一对象同一天内产出的 URL 字节级相同，浏览器的缓存能命中；而因为窗口按天滚动，任何时刻拿到的 URL 剩余有效期至少 6 天。",[14,2813,2815],{"id":2814},"四把规矩变成测试","四、把规矩变成测试",[10,2817,2818],{},"三档地址的分工写进了代码注释，放在存储工具的末尾：",[56,2820,2823],{"className":2821,"code":2822,"language":61},[59],"吐给浏览器（渲染 \u003Cimg>\u002F\u003Cvideo>）→ stableAssetUrl：返回 \u002Fapi\u002Fmedia 稳定路径，\n7 天有效，根治「页面长驻后签名过期裂图」\n交给上游拉取 → resolveUpstreamAssetUrl：6 小时\n本进程内立刻用 → resolveAssetUrl：15 分钟\n",[51,2824,2822],{"__ignoreMap":64},[10,2826,2827],{},"注释拦不住。这事已经栽过两次，两次的症状一模一样：",[2252,2829,2830],{},[10,2831,2832],{},"两次的症状一模一样：图刚打开好好的，画布放十几分钟就集体「图片加载失败」，刷新只是换来另一条同样 15 分钟后会死的链接，排查时极容易误判成前端缓存问题。",[10,2834,2835],{},"所以加了一条源码级断言。它不是接口测试——它读源码字符串，剥掉注释之后断言不该出现的函数名：",[56,2837,2839],{"className":1741,"code":2838,"language":1743,"meta":64,"style":64},"\u002F** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 *\u002F\nconst SHORT_LIVED_SIGNERS = [\"createPrivateObjectReadUrl\", \"resolveAssetUrl\"];\n\n\u002F** 这些文件产出的 URL 会被前端直接塞进 \u003Cimg>\u002F\u003Cvideo> *\u002F\nconst BROWSER_FACING_FILES = [\n  \"canvas-flow\u002Frun-routes.ts\",\n  \"canvas-adapter\u002Fmaterial-url-route.ts\",\n];\n",[51,2840,2841,2846,2869,2873,2878,2890,2897,2904],{"__ignoreMap":64},[76,2842,2843],{"class":78,"line":79},[76,2844,2845],{"class":1579},"\u002F** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 *\u002F\n",[76,2847,2848,2850,2853,2855,2858,2861,2863,2866],{"class":78,"line":99},[76,2849,1750],{"class":269},[76,2851,2852],{"class":172}," SHORT_LIVED_SIGNERS",[76,2854,1756],{"class":269},[76,2856,2857],{"class":102}," [",[76,2859,2860],{"class":86},"\"createPrivateObjectReadUrl\"",[76,2862,1490],{"class":102},[76,2864,2865],{"class":86},"\"resolveAssetUrl\"",[76,2867,2868],{"class":102},"];\n",[76,2870,2871],{"class":78,"line":106},[76,2872,2432],{"emptyLinePlaceholder":1268},[76,2874,2875],{"class":78,"line":121},[76,2876,2877],{"class":1579},"\u002F** 这些文件产出的 URL 会被前端直接塞进 \u003Cimg>\u002F\u003Cvideo> *\u002F\n",[76,2879,2880,2882,2885,2887],{"class":78,"line":134},[76,2881,1750],{"class":269},[76,2883,2884],{"class":172}," BROWSER_FACING_FILES",[76,2886,1756],{"class":269},[76,2888,2889],{"class":102}," [\n",[76,2891,2892,2895],{"class":78,"line":145},[76,2893,2894],{"class":86},"  \"canvas-flow\u002Frun-routes.ts\"",[76,2896,118],{"class":102},[76,2898,2899,2902],{"class":78,"line":1524},[76,2900,2901],{"class":86},"  \"canvas-adapter\u002Fmaterial-url-route.ts\"",[76,2903,118],{"class":102},[76,2905,2906],{"class":78,"line":1533},[76,2907,2868],{"class":102},[10,2909,2910,2911,695],{},"再加一条断言钉住 URL 的形态：产出的地址里不能出现 ",[51,2912,2913],{},"X-Amz-Signature",[10,2915,2916,2917,2920],{},"这是这次修法里唯一带点非常规的做法：",[44,2918,2919],{},"用测试来守一条架构约定","。普通的测试守行为，这条守的是「哪个函数可以在哪里被调用」。",[10,2922,2923],{},"之所以需要它，是因为这类错误的三个特征叠在一起：编译期查不出（函数签名都一样）、单测查不出（旧用例只断言「有 url 字段」）、运行时查不出（图在头十几分钟是好的）。三个环节都不拦，只能靠一条读源码的断言。",[14,2925,2927],{"id":2926},"五时间尺度","五、时间尺度",[10,2929,2930,2931,695],{},"回头看，这三处其实是同一个问题在三个层面的表现：",[44,2932,2933],{},"链接的有效期和它的使用方式不匹配",[1202,2935,2936,2939,2942],{},[298,2937,2938],{},"签名客户端：复用的时间尺度是「进程生命周期」，实现给的是「单次请求」。",[298,2940,2941],{},"前端缓存：URL 被持有的时间尺度是「页面停留时长」，缓存给的是「无限」。",[298,2943,2944],{},"直链：浏览器的取用方式是「随时重新加载」，签名给的是 15 分钟。",[10,2946,2947],{},"修完之后的取值是有依据的，不是拍的：",[2949,2950,2951,2967],"table",{},[2952,2953,2954],"thead",{},[2955,2956,2957,2961,2964],"tr",{},[2958,2959,2960],"th",{},"用途",[2958,2962,2963],{},"有效期",[2958,2965,2966],{},"依据",[2968,2969,2970,2982,2993],"tbody",{},[2955,2971,2972,2976,2979],{},[2973,2974,2975],"td",{},"本进程立刻取用",[2973,2977,2978],{},"15 分钟",[2973,2980,2981],{},"签完就 fetch，够用且泄露窗口小",[2955,2983,2984,2987,2990],{},[2973,2985,2986],{},"交给上游排队拉取",[2973,2988,2989],{},"6 小时",[2973,2991,2992],{},"实测排队 10~25 分钟才轮到上游下载",[2955,2994,2995,2998,3001],{},[2973,2996,2997],{},"浏览器渲染",[2973,2999,3000],{},"7 天（按天对齐）",[2973,3002,3003],{},"覆盖任意页面停留，且同一天 URL 相同可命中缓存",[10,3005,3006],{},"最后一条还有一层：它不走签名直链，走后端代理。这换来两个好处——不受对象存储域名可达性影响，也不受签名有效期约束。代价是流量过后端，但素材图不走这条路的代价更大。",[1252,3008,3009],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":64,"searchDepth":99,"depth":99,"links":3011},[3012,3013,3014,3015,3016],{"id":2354,"depth":99,"text":2355},{"id":2451,"depth":99,"text":2452},{"id":2589,"depth":99,"text":2590},{"id":2814,"depth":99,"text":2815},{"id":2926,"depth":99,"text":2927},"2026-08-28",{},"\u002F2026-08-28-fa-023",{"title":2340,"description":2345},"FA-023","2026-08-28-FA-023-图是好的链接死了","画布上的素材图集体裂开，刷新只换来另一条同样会死的链接。三处独立问题：签名客户端每请求重建、前端把签名 URL 当永久地址存、面向浏览器的链路签了 15 分钟的直链。",[3025,3026,3027,3028,2335,3029],"S3","签名URL","缓存","性能","对象存储","LNHg4QB5lztHWhSI-OvrOM4XrnhLpHHPMlNgjTOxMA4",1789212572912]