[{"data":1,"prerenderedAt":1431},["ShallowReactive",2],{"\u002F2026-06-03-574124g":3,"\u002F2026-06-03-574124g-rel":488},{"id":4,"title":5,"body":6,"column":471,"date":472,"description":12,"extension":473,"hero_image":474,"meta":475,"navigation":476,"path":477,"seo":478,"series_id":474,"severity":474,"stem":479,"summary":480,"tags":481,"__hash__":487},"posts\u002F2026-06-03-574用户124G全量迁移.md","搬 574 个用户之前，先拿两个人试",{"type":7,"value":8,"toc":456},"minimark",[9,13,16,20,23,26,29,32,37,40,43,86,89,92,95,122,125,128,183,186,194,198,201,204,207,210,213,216,220,223,226,239,246,249,253,256,259,266,269,273,276,279,286,289,293,296,299,302,351,358,362,365,368,371,374,378,381,384,387,390,393,396,399,402,405,412,419,422,428,431,434,437,440,443,446,449,452],[10,11,12],"p",{},"6 月初完成了一次规模迁移：把老 Docker Swarm 平台的全部用户搬到新 Kubernetes 集群。574 个用户、124G 数据量、573 个中转站密钥需要转换。数据完整保留，管理组件在新系统全新部署。",[10,14,15],{},"这不是\"先设计再执行\"的故事。而是在两个覆盖不同数据形态的真实用户身上走通全流程，每踩到一个坑就固化成 runbook 里的一条，再展开到全量。最终沉淀下来七个必须遵守的关键点。",[17,18,19],"h2",{"id":19},"为什么选择循序渐进而非一步到位",[10,21,22],{},"迁移之前我做了一个完整的架构设计：怎么打包数据、用什么协议传输、新系统怎么导入。理论上没有漏洞。",[10,24,25],{},"但在动手准备脚本时，我决定先拿两个真实用户验证。不是因为不放心设计，而是因为数据迁移这类操作，细节决定成败。方案在脑子里再完美，一旦接触现实数据就会暴露出盲点。",[10,27,28],{},"我选了一个 volume 型用户和一个 bind 型用户。两种挂载方式代表了数据存储的完全不同逻辑。如果两个都能通，全量迁移的风险就能显著降低。",[17,30,31],{"id":31},"七个从测试用户踩出来的关键点",[33,34,36],"h3",{"id":35},"_1-数据双源bind-和-volume-走不同的导出路径","1. 数据双源：bind 和 volume 走不同的导出路径",[10,38,39],{},"容器数据有两种存储方式。bind 挂载直接把主机目录映射进容器，volume 是由容器引擎管理的抽象存储。两者在容器里看起来一样，但取数据的方式完全不同。",[10,41,42],{},"bind 型用户的数据在主机上就是普通目录。我最初想直接用 tar 打包，脚本很简单：",[44,45,50],"pre",{"className":46,"code":47,"language":48,"meta":49,"style":49},"language-bash shiki shiki-themes github-light github-dark","tar czf user.tgz \u003Cuser-data-dir>\u002F\n","bash","",[51,52,53],"code",{"__ignoreMap":49},[54,55,58,62,66,69,73,76,80,83],"span",{"class":56,"line":57},"line",1,[54,59,61],{"class":60},"sScJk","tar",[54,63,65],{"class":64},"sZZnC"," czf",[54,67,68],{"class":64}," user.tgz",[54,70,72],{"class":71},"szBVR"," \u003C",[54,74,75],{"class":64},"user-data-di",[54,77,79],{"class":78},"sVt8B","r",[54,81,82],{"class":71},">",[54,84,85],{"class":64},"\u002F\n",[10,87,88],{},"结果一个 50GB 的目录，打出来的包里只有 13 个文件。其他数千个文件都消失了。",[10,90,91],{},"排查过程：先检查源目录确实有完整的文件。再看 tar 命令的权限——发现文件的所有者是 UID 10001（容器内的非 root 用户）。当 tar 以普通用户身份运行时，对某些文件没有读权限。容器内的应用可以读，是因为它就是 UID 10001 的进程。",[10,93,94],{},"解决方案就一个：用 sudo 运行 tar。",[44,96,98],{"className":46,"code":97,"language":48,"meta":49,"style":49},"sudo tar czf user.tgz \u003Cuser-data-dir>\u002F\n",[51,99,100],{"__ignoreMap":49},[54,101,102,105,108,110,112,114,116,118,120],{"class":56,"line":57},[54,103,104],{"class":60},"sudo",[54,106,107],{"class":64}," tar",[54,109,65],{"class":64},[54,111,68],{"class":64},[54,113,72],{"class":71},[54,115,75],{"class":64},[54,117,79],{"class":78},[54,119,82],{"class":71},[54,121,85],{"class":64},[10,123,124],{},"这次拿到了完整的数据。但 sudo 意味着脚本需要配置 sudoers，或者在迁移期间给执行用户 sudo 权限。这是一个权限模型的变化。",[10,126,127],{},"volume 型用户的情况不同。volume 数据不在主机上直接可见，而是由容器引擎管理。要取出数据必须进到容器内部。我的做法是启动一个 alpine 容器，挂载目标 volume，然后在容器内运行 tar 打包。",[44,129,131],{"className":46,"code":130,"language":48,"meta":49,"style":49},"docker run --rm -v \u003Cvolume-name>:\u002Fdata alpine tar czf - \u002Fdata | tee user.tgz\n",[51,132,133],{"__ignoreMap":49},[54,134,135,138,141,145,148,150,153,156,158,161,164,166,168,171,174,177,180],{"class":56,"line":57},[54,136,137],{"class":60},"docker",[54,139,140],{"class":64}," run",[54,142,144],{"class":143},"sj4cs"," --rm",[54,146,147],{"class":143}," -v",[54,149,72],{"class":71},[54,151,152],{"class":64},"volume-nam",[54,154,155],{"class":78},"e",[54,157,82],{"class":71},[54,159,160],{"class":64},":\u002Fdata",[54,162,163],{"class":64}," alpine",[54,165,107],{"class":64},[54,167,65],{"class":64},[54,169,170],{"class":64}," -",[54,172,173],{"class":64}," \u002Fdata",[54,175,176],{"class":71}," |",[54,178,179],{"class":60}," tee",[54,181,182],{"class":64}," user.tgz\n",[10,184,185],{},"容器内的进程对 volume 有完整的读权限，不存在属主问题。打出来的包是完整的。",[10,187,188,189,193],{},"这两条路的存在意味着迁移脚本必须",[190,191,192],"strong",{},"先判断用户数据的挂载类型","。我在数据库里加了一个字段记录每个用户是 bind 还是 volume，导出脚本根据这个字段选择对应的打包方式。",[33,195,197],{"id":196},"_2-设备表如果不迁桌面端会完全失效","2. 设备表如果不迁，桌面端会完全失效",[10,199,200],{},"中间有一个 table 我差点漏掉。老系统数据库里有一张 ConnectorDevice 表，记录的是每个用户绑定的设备信息。一共 658 个设备记录跨 551 个用户。",[10,202,203],{},"初版迁移计划里没有这张表。理由是\"设备信息不是核心用户数据，新系统支持重新绑定\"。",[10,205,206],{},"验证第一个 volume 型用户时一切正常——账号能登，数据在那。第二个 bind 型用户登上去以后，试着从桌面端的工具栏打开一个功能，结果无响应。",[10,208,209],{},"日志显示工具调用失败。错误消息指向设备 token 查询。追进去才发现，工具栏里的每个按钮都通过设备标识符来路由调用，设备表里没有这个用户，路由直接返回 404。",[10,211,212],{},"这不是\"用户可以重新绑定\"就解决的问题。用户从桌面端发起的操作链路已经依赖于设备标识，设备表是必需的。漏掉它等于功能瘫痪。",[10,214,215],{},"所以 ConnectorDevice 表成了迁移的强制条件。新系统的数据库导入流程里，要同时导入这张表，并保持 userId 的一致性。",[33,217,219],{"id":218},"_3-中转站账号不变但密文必须转换","3. 中转站账号不变，但密文必须转换",[10,221,222],{},"两个平台都接入同一个 sub2api 中转站。中转站管理着用户的各种第三方工具授权。在老系统里，这些授权用老平台的 SEALED_BOX 密钥加密存储。搬到新系统，需要用新平台的 SEALED_BOX 密钥重新加密。",[10,224,225],{},"为什么要转换？SEALED_BOX 是一种公钥加密方案，每个平台都有自己的密钥对。老平台的私钥无法解密新平台加的密文，所以迁移过程必须：",[227,228,229,233,236],"ol",{},[230,231,232],"li",{},"用老平台的私钥解密",[230,234,235],{},"取出明文",[230,237,238],{},"用新平台的公钥重新加密",[10,240,241,242,245],{},"我写了一个转换脚本，用 libsodium 的 ",[51,243,244],{},"crypto_box_seal"," 接口来验证整个往返过程。解出来的 keyLen=67、pwLen=32，用新平台的私钥解密验证通过。",[10,247,248],{},"这个转换必须在导出端完成。新系统导入时拿到的是已经用新密钥加密的密文，导入脚本直接写进数据库，中转站调用也会成功。",[33,250,252],{"id":251},"_4-userid-保持不变clusterid-需要改","4. userId 保持不变，clusterId 需要改",[10,254,255],{},"用户的全局标识是 userId。在老系统里它是用户的数据库主键，在新系统也是。所有的关联记录——设备表、配额表、中转站账号——都通过 userId 串联。",[10,257,258],{},"userId 必须 1:1 迁移，不能改。",[10,260,261,262,265],{},"clusterId 是集群标识。老系统里 clusterId 反映的是容器运行在哪个 Swarm 集群。新系统采用 Kubernetes，集群标识体系不同。我统一把新系统的 clusterId 改成 ",[51,263,264],{},"cce-1","。这个值对应新 K8s 集群的内部标识。",[10,267,268],{},"新系统的容器启动脚本会读这个字段，根据它去连接对应的控制平面。改错 clusterId 等于把容器指向了错误的集群。",[33,270,272],{"id":271},"_5-用私有桶中转数据不走公开-cdn","5. 用私有桶中转数据，不走公开 CDN",[10,274,275],{},"打包好的数据文件动辄几百 MB 到几 GB。老系统的网络和新系统的网络不在同一个 VPC 里，直接 scp 传输会占用宝贵的跨域带宽。",[10,277,278],{},"我用云对象存储的私有桶作为中间仓库。老系统打好包以后上传到私有桶，新构建机再从私有桶下载。两端都是到公有云厂商的接入点，利用云厂商内部的高速专线。",[10,280,281,282,285],{},"关键是这个桶必须是",[190,283,284],{},"私有的，不挂公开 CDN","。用户数据严禁走任何公开网络。每次上传和下载都用签名的 URL 来授权，传完数据立刻删除对象，不留痕迹。",[10,287,288],{},"我验证过 md5：一个 13.7MB 的包从老机上传、新机下载，字节级一致。解包出来的 4778 个文件和 openclaw.json 的校验和都对。",[33,290,292],{"id":291},"_6-sfs-挂载会掉检查后自动重新-mount","6. SFS 挂载会掉，检查后自动重新 mount",[10,294,295],{},"新系统把共享存储（SFS）挂到一台 build 机上。这台机器负责接收下载的数据、解包、导入数据库。",[10,297,298],{},"问题是每次 build 机重启，SFS 的挂载点就掉了。虽然自动挂载配置写在 fstab 里，但在容器平台的场景下不总是可靠。如果导入脚本在挂载掉的时刻运行，直接写会失败。",[10,300,301],{},"解决方案是每个导入脚本的开头加一个检查逻辑：",[44,303,305],{"className":46,"code":304,"language":48,"meta":49,"style":49},"if ! mountpoint \u002Fmnt\u002Fsfs > \u002Fdev\u002Fnull 2>&1; then\n  mount -a\nfi\n",[51,306,307,336,345],{"__ignoreMap":49},[54,308,309,312,315,318,321,324,327,330,333],{"class":56,"line":57},[54,310,311],{"class":71},"if",[54,313,314],{"class":71}," !",[54,316,317],{"class":60}," mountpoint",[54,319,320],{"class":64}," \u002Fmnt\u002Fsfs",[54,322,323],{"class":71}," >",[54,325,326],{"class":64}," \u002Fdev\u002Fnull",[54,328,329],{"class":71}," 2>&1",[54,331,332],{"class":78},"; ",[54,334,335],{"class":71},"then\n",[54,337,339,342],{"class":56,"line":338},2,[54,340,341],{"class":60},"  mount",[54,343,344],{"class":143}," -a\n",[54,346,348],{"class":56,"line":347},3,[54,349,350],{"class":71},"fi\n",[10,352,353,354,357],{},"脚本先看一遍挂载点是否存活，不活就执行 ",[51,355,356],{},"mount -a"," 重新挂载。这样即使 build 机在迁移过程中重启了，脚本也能自动恢复。",[33,359,361],{"id":360},"_7-迁移完成后桌面端-connector-必须重启","7. 迁移完成后桌面端 Connector 必须重启",[10,363,364],{},"新系统的控制平面地址和老系统不同。桌面端的 Connector 进程启动时会连接到指定的控制平面，握手成功后建立长连接。",[10,366,367],{},"如果 Connector 进程还连着老系统，即使用户账号已经迁到新系统，Connector 也无法获取到新系统的指令。反过来说，如果 Connector 没有重启，它就不知道用户迁到了新系统。",[10,369,370],{},"这就是为什么迁移完成后必须通知用户重启桌面端 Connector。重启时 Connector 会重新寻址、重新握手、连接到新的控制平面。之后一切恢复正常。",[10,372,373],{},"如果用户没有重启，会卡在\"正在尝试恢复连接\"的状态。新的工具指令下不来，老的控制平面也有超时断开了，这个过程会很难受。",[17,375,377],{"id":376},"从两个用户到-574-个用户","从两个用户到 574 个用户",[10,379,380],{},"两个测试用户的迁移用了半天。第一个用户（volume 型，约 329MB，7728 个文件）：导出、上传、下载、解包、导入、验证，全程顺利。中间没有意外，密钥转换、设备表、clusterId 都对上了。",[10,382,383],{},"第二个用户（bind 型，约 75MB，4778 个文件）：打包时踩了那个属主的坑——sudo 加上以后就通了。从这里开始意识到，bind 和 volume 的处理逻辑必须分开。",[10,385,386],{},"两个用户都通过以后，我把脚本参数化，改成了单用户批处理的形式。老系统的导出脚本接收 userId，自动判断挂载类型、选择打包方式、执行密钥转换。新系统的导入脚本接收 userId，自动从私有桶拉数据、解包、导入数据库。",[10,388,389],{},"全量执行时用批量循环调用这两个脚本。老系统可以较高并发上传（打包是 CPU 密集但量小），新系统的 build 机 CPU 是瓶颈，所以每批控制在 5-10 个用户。失败的用户记录下来，单独重试。脚本设计成幂等的，重跑同一个用户不会产生重复导入或覆盖。",[10,391,392],{},"整个过程没有\"先完整设计再验证\"的阶段。而是在两个真实用户身上把七个关键点全部踩了一遍，每个坑的解法都通过验证，然后把脚本一般化。这样到全量执行时，风险已经可控。",[10,394,395],{},"574 个用户的迁移在一个停机窗口内完成。中间有两个用户因为网络超时失败，重跑以后成功。最后逐个抽样验证，账号能登、数据完整、设备表存在、sub2api 调用成功。",[10,397,398],{},"密钥转换的幂等性证明了它的必要性：如果中间有用户的导入被中断，下一次重跑时新的加密过程会用最新的密钥重算，结果还是对的。这避免了\"某个用户的密文版本是混杂的\"这种中间态。",[17,400,401],{"id":401},"迁移执行中的可见性与回滚设计",[10,403,404],{},"全量迁移前，我准备了详细的进度跟踪和失败处理机制。每个用户的迁移过程记录到日志里：已导出、已上传、已下载、已解包、已导入。如果某个环节失败，日志会精确指出是哪一步、为什么失败。",[10,406,407,408,411],{},"失败不等于灾难，因为整个过程设计得是",[190,409,410],{},"幂等的","。重跑同一个用户的导出和导入脚本不会产生重复的数据库行。如果导入中途被打断，下一次导入会看到已有的用户记录，跳过创建步骤，只更新增量数据（比如新一轮的密钥转换）。",[10,413,414,415,418],{},"老系统的数据在整个迁移期间处于",[190,416,417],{},"只读状态","。没有删除任何源数据，只是读取、转换、上传。一旦新系统稳定运行，老系统作为完整的回滚备份而继续保活。这是保险的做法——如果新系统在某个时刻崩溃或数据破损，我可以立刻切回老系统，损失只是中间几个小时的新增数据。",[10,420,421],{},"两个测试用户通过后，迁移在一个停机窗口内展开。574 个用户分批处理，老系统的上传和新系统的导入并行进行。中间有两个用户因为网络超时导致下载失败，重新跑了一遍就成功了。这证实了脚本的幂等性和容错能力。",[10,423,424,425,427],{},"迁移完成后，我逐个抽样验证。登录账号，检查数据目录是否完整，跑 md5 校验和（与打包时的 hash 对比），用新系统的接口调用 sub2api 看密钥是否正确转换。设备表的记录数和新系统现存的设备数应该吻合。clusterId 在所有用户上是一致的 ",[51,426,264],{},"。",[10,429,430],{},"这些检查都通过以后，才通知用户切换到新系统，并提醒重启桌面端 Connector。",[17,432,433],{"id":433},"为什么数据迁移总是出其不意",[10,435,436],{},"事后看，七个关键点里有五个是在测试阶段才浮出来的。最初的设计文档对其中三个（bind 属主问题、设备表漏掉、SFS 挂载掉线）完全没有预见。",[10,438,439],{},"这不是设计不够仔细，而是这类问题的特性：它们涉及多个系统的交界面。bind 挂载的属主问题不会出现在任何单一组件的文档里，而是 Linux 文件系统权限、Docker 挂载、tar 命令的组合特性。设备表漏掉是因为新系统的功能链路和老系统不同，在纸面上看不出来。SFS 的挂载掉线需要真实的硬件重启来复现。",[10,441,442],{},"如果先花两周完整设计，再花两周编码实现，再花一周全量执行，那么遇到这些问题时已经是 go-live 前几个小时，后果会很严重。",[10,444,445],{},"改成\"两个用户验证 → 踩坑固化 → 全量执行\"的流程，则是在可控的范围内把风险提前释放。两个用户的数据量不大（总共不到 500MB），失败了重来也快。从踩坑到修复脚本，整个周期不超过一天。新系统的导入逻辑也是在这个过程中逐步完善的——先是最基础的\"把数据解开、写进数据库\"，然后加上\"密钥转换\"，再加上\"设备表导入\"，最后加上\"幂等检查和错误恢复\"。",[10,447,448],{},"每一层都是在真实数据验证下加上去的，不是基于假设。",[10,450,451],{},"新系统稳定运行两周以后，我才把老 Swarm 系统下线。期间没有发现任何数据不一致或功能失效。574 个用户和它们的 124G 数据完整迁移到了新平台。",[453,454,455],"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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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}",{"title":49,"searchDepth":338,"depth":338,"links":457},[458,459,468,469,470],{"id":19,"depth":338,"text":19},{"id":31,"depth":338,"text":31,"children":460},[461,462,463,464,465,466,467],{"id":35,"depth":347,"text":36},{"id":196,"depth":347,"text":197},{"id":218,"depth":347,"text":219},{"id":251,"depth":347,"text":252},{"id":271,"depth":347,"text":272},{"id":291,"depth":347,"text":292},{"id":360,"depth":347,"text":361},{"id":376,"depth":338,"text":377},{"id":401,"depth":338,"text":401},{"id":433,"depth":338,"text":433},"Agent 平台","2026-06-03","md",null,{},true,"\u002F2026-06-03-574124g",{"title":5,"description":12},"2026-06-03-574用户124G全量迁移","老 Swarm 平台向新 K8s 集群迁移 574 用户及其 124G 用户数据，以测试用户踩出的七个关键点固化成执行 runbook。",[482,483,484,485,486],"Kubernetes","Docker Swarm","数据迁移","生产实战","运维","dlB_dynUu1DA8l32l1REN8u3JzSU2dAQIPWDJ6MkAjY",[489,807,1105],{"id":490,"title":491,"body":492,"column":471,"date":795,"description":496,"extension":473,"hero_image":474,"meta":796,"navigation":476,"path":797,"seo":798,"series_id":474,"severity":474,"stem":799,"summary":800,"tags":801,"__hash__":806},"posts\u002F2026-07-26-峰值1987一个人维护AI平台的边界.md","一个人，能维护到多大规模？",{"type":7,"value":493,"toc":780},[494,497,500,503,506,539,542,545,549,552,559,574,577,584,588,591,594,597,604,611,615,618,625,629,632,635,645,656,659,663,666,669,676,680,683,686,689,693,696,699,725,728,731,734,740,754,760,774],[10,495,496],{},"从 2026-05-22 的 270 个用户到 07 下旬的峰值 1987 日活，这个平台的增长不是线性的。中间隔着四次明确的容量撞墙，每一次都留下了可复查的根因记录。这篇文章讲的是，什么条件下一个人能维护一个到达四位数日活规模的多租户平台。",[17,498,499],{"id":499},"成长曲线与撞墙的四个节点",[10,501,502],{},"架构的设计前提是\"1 用户 = 1 容器\"。这个决策确定了，用户数与容器数就是同一个口径。没有\"日活 X 万、同时在线 Y 万\"这种双指标的混淆。",[10,504,505],{},"实际的规模序列是这样的：",[507,508,509,515,521,527,533],"ul",{},[230,510,511,514],{},[190,512,513],{},"270 个 service","（05-22）：刚完成 ffmpeg 升级后的测量",[230,516,517,520],{},[190,518,519],{},"372 RUNNING","（05-24）：滚动升级前的容器计数",[230,522,523,526],{},[190,524,525],{},"379","（05-25）：五个 P0 patch 部署后稳定",[230,528,529,532],{},[190,530,531],{},"574 用户","（06-03）：迁移到 K8s 时的基线，539 个 RUNNING",[230,534,535,538],{},[190,536,537],{},"1987","（07 下旬）：最近测得的峰值",[10,540,541],{},"跨度是两个月，增速从 Swarm 期间的两周内 270→379（40% 增长）、到迁 K8s 后约八周 574→1987（约 3.5 倍）。这个加速度不是\"计划好的伸缩\"，而是每一次解决了瓶颈后，下一个瓶颈暴露出来。",[17,543,544],{"id":544},"四堵撞过的墙",[33,546,548],{"id":547},"第一堵容器网络-ip-池05-22fa-012","第一堵：容器网络 IP 池（05-22，FA-012）",[10,550,551],{},"用户报告\"AI 容器里没 ffmpeg\"。这本来是个 Dockerfile 一行 apt 的事。但打算上线这个修改时，意外发现 gwbridge（Docker Swarm 默认的容器网络）IP 地址段是 \u002F24，总共 253 个 IP，已经被 270 个用户的容器占满。13 个用户容器长期处于启动失败的循环中。",[10,553,554,555,558],{},"排查逻辑是这样的：一行 Dockerfile 改动不至于触发灰度风险，但灰度一个用户时，Swarm 需要给新容器分配 gwbridge 的 IP。如果池子满了，IP 分配失败，新容器启动失败，再加上老容器还没完全释放（endpoint 还在占位），就陷入了\"申请 → 失败 → retry\"的循环。这种症状看起来随机，用户看到的是\"我的容器启不来\"，系统看到的是\"又一个容器 cycling\"。直到看 ",[51,556,557],{},"docker info"," 的输出，才发现 gwbridge 已经 100% 满用。",[10,560,561,562,565,566,573],{},"根本原因在于一个不易察觉的配置陷阱：Docker Daemon 的配置文件里改了 ",[51,563,564],{},"default-address-pools","，但这个配置",[190,567,568,569,572],{},"只在 ",[51,570,571],{},"swarm init"," 那一刻消费一次","。已经创建的网络不会动。之前改过这个配置的人可能不知道这个行为，改了等于没改。",[10,575,576],{},"修复不能简单地改配置重启。我试过在 staging 环境用 dry-run 验证，写脚本探测不冲突的子网段，然后在 production 的 canary 验证阶段发现\"释放的 IP 立刻被其它 cycling 任务抢走\"这样的负反馈。所以流程变成：先 scale 0 所有失联的服务（停止它们 cycling，释放的位置不会被抢），然后手动删除旧的 gwbridge、用新 subnet 重建。最终把容量从 253 扩到了 4094，增长了 16 倍。13 个失联用户全部恢复。",[10,578,579,580,583],{},"这次的启示是",[190,581,582],{},"配置陷阱往往比代码 bug 更隐蔽","。因为配置改了看不出效果，维护者会觉得没改上去、会反复尝试，但每次尝试的假设都错了。",[33,585,587],{"id":586},"第二堵单容器资源限制与内核参数05-25fa-013","第二堵：单容器资源限制与内核参数（05-25，FA-013）",[10,589,590],{},"五天后，部署了五个 P0 patch：B1 容器内存 burst factor 调整（硬限制改为内存 × 4）、B2 mcp register 的假失败救活逻辑、B3 ulimit nofile 扩大到 65536、B4 mcp cooldown 清理、B6 provision 接口幂等性。这一次的滚动升级从 05-25 凌晨 0:41 一直跑到 07:34，实际耗时 6 小时 42 分钟，原本预估只要 3 小时。",[10,592,593],{},"但更关键的数据是这个：升级前，平台里有一个重灾户容器一天被 OOM kill 了 247 次。这不是偶发故障——是每一天都在重复。升级完成一小时后，这个数字变成了 0。",[10,595,596],{},"这次的根因是九个缺陷的叠加。B1 的根因是硬限制设得太低（原来 93 个用户只有 500MB 硬限，远低于实际需要），B2 是 mcp register exit code 的错误判断（非零 exit 自动当做失败，但有时是因为配置覆盖了），B3 是文件描述符不足导致新连接打开失败。单个缺陷可能不致命，但聚在一起就是 OOM 风暴。更严重的是，一个用户的 OOM 不只影响那个用户——每次 OOM 都会触发内核的 swap 操作，拖累整机的 I\u002FO，让 cpa-api 主进程的事件循环卡顿 18 秒。前端看到的是全站变慢，实际根因可能是某个用户容器在反复 OOM。",[10,598,599,600,603],{},"部署前做了充分的 staging 验证和 production canary，分阶段升级了重灾户、然后全量升级、最后等完全收敛。期间遇到的问题是单容器的 ",[51,601,602],{},"docker service update"," 实际耗时约 60 秒（含 Swarm scheduler 延迟），并发调度起来比预期慢 2 倍。",[10,605,606,607,610],{},"这次的启示是：",[190,608,609],{},"当多个独立缺陷同时叠加时，表象看起来是单一故障（OOM 风暴），但根因分散在配置、参数、逻辑判断的不同层","。修复必须从全景取证开始，先理清每个环节的缺陷，再按优先级有序修复。一次部署才能彻底收敛，局部修复反而会留下隐患。",[33,612,614],{"id":613},"第三堵编排层与自愈能力06-03迁-k8s","第三堵：编排层与自愈能力（06-03，迁 K8s）",[10,616,617],{},"到 06-03，用户已经涨到 574 个。继续打补丁的成本已经高于\"一次性迁移编排平台\"。Swarm 的问题不只是容量——还有调度自愈的缺失。任何故障都依赖人工介入，一个人无法 24 小时在线。决策很简单：从 Docker Swarm 迁到 Kubernetes。",[10,619,620,621,624],{},"这次迁移的复杂性不在于技术实现本身（用 COS 中转数据、转换 sub2api 密钥、把 124G 数据分批导入），而在于",[190,622,623],{},"理解新平台引入的新故障域","。K8s 有更细的控制粒度和自动调度，但同时也暴露了之前 Swarm 隐藏的问题。比如共享存储（NFS\u002FSFS）的客户端卡死，在 Swarm 时期可能因为容器分散在不同节点而被掩盖；但在 K8s 这样的细粒度编排下，如果一个节点的 NFS 客户端出问题，节点上的所有 pod 都会受影响。所以迁移不是终点，而是暴露新问题的起点。",[33,626,628],{"id":627},"第四堵节点级存储与可观测性06-05fa-015","第四堵：节点级存储与可观测性（06-05，FA-015）",[10,630,631],{},"迁移两天后，某个节点上的 NFS 客户端在某个时刻 hang 住了。Kubernetes 不知道发生了什么，kubelet 仍然报告节点 Ready（因为 kubelet 本身没卡），但这个节点上 52 个实例的网关全部 DOWN 或 HUNG。这 52 个实例对应的是本该分散部署的用户容器，因为某种原因堆在了同一个节点上。",[10,633,634],{},"故障的症状可以分成几类。单个实例网关的 DOWN（容器无法启动、schema 非法）、HUNG（反复重启导致 adapter 504）、节点级的卡死（整节点上的容器创建\u002F删除都阻塞）、数据库与实际状态割裂（DB 里是 ERROR、但 pod 健康了）。每种症状对应不同的根因，需要不同的检测和自愈手段。",[10,636,637,638,641,642,427],{},"最严重的是，",[190,639,640],{},"这个故障没有任何告警","。平台的监控指标全部正常，绿灯一片。直到用户反馈\"连不上面板\"，才被发现。这已经是故障发生几小时以后的事。故障本身是可逆的——节点冻死就人工 cordon、删除卡住的 pod、让其它节点重新调度。但",[190,643,644],{},"不可见的故障比故障本身更致命",[10,646,647,648,651,652,655],{},"这次事故直接导出了四层兜底的设计：L0 从配置和资源限制层面降低故障触发（比如 NFS 改 ",[51,649,650],{},"soft"," 参数而不是 ",[51,653,654],{},"hard","，这样网络抖动时容器报错退出而不是整个节点冻）；L1 加检测让故障可见（每 60 秒检测一遍\"节点上是否有 pod 卡 ContainerCreating、网关探测失败率多少\"）；L2 对低风险故障自动自愈（网关单次 DOWN 就重建 pod、DB 对账失败就修复状态）；L3 对高风险动作告警优先（节点冻死先推送告警、等人工确认再 drain）。",[17,657,658],{"id":658},"三件真正决定可行性的事",[33,660,662],{"id":661},"_1-文档即基础设施","1. 文档即基础设施",[10,664,665],{},"这个平台现在有 200+ 份设计文档与 80 份故障档案。这些不是为了\"好看\"存在的。",[10,667,668],{},"一个人无法对整个系统保持完整的心智模型。200+ 文档是唯一能让一个人记住系统全貌的方式。每当遇到新故障，能快速检索以前遇过的类似问题。每当需要做架构决策，能回溯当初为什么这样设计、放弃过哪些选项。",[10,670,671,672,675],{},"同时，",[190,673,674],{},"这些文档也是 AI 能有效介入的前提","。我可以把这些故障档案喂给模型，让它帮助排查新问题、验证修复方案、甚至生成监控规则。但前提是要把故障记录得清楚。空洞的\"修好了\"没有任何价值。",[33,677,679],{"id":678},"_2-故障必须被归档","2. 故障必须被归档",[10,681,682],{},"同一个症状反复出现，每次修复可能只治了表象的一个侧面，真正的收敛需要理解全部的根因。这只有在每一次都写清档案的情况下才可能。",[10,684,685],{},"如果没有归档制度，第二次遇到类似症状时，维护者根本不知道第一次修复做过什么、为什么还没有彻底解决。\"又来了\"和\"这个问题还有遗留\"的反应完全不同。前者是被动应对、逐次救火，后者是主动追踪、系统解决。",[10,687,688],{},"实际上，平台里有不少故障走过了四到五次的修复周期。每一次修复时，回看之前的档案，能快速理清\"这一层已经改过，那一层还没触及\"。这种\"有案可查、有据可循\"的状态，把修复从赌博变成了可重复的流程——每一次问题复发时，不是从零开始排查，而是从已知的检查点继续。",[33,690,692],{"id":691},"_3-自愈优先于告警告警优先于人工","3. 自愈优先于告警，告警优先于人工",[10,694,695],{},"在 FA-015 之前，平台大量依赖人工值守。任何问题都需要运维看到日志、理解现象、手动操作。一个人无法 24 小时在线。",[10,697,698],{},"FA-015 的教训直接导出了四层兜底的设计：",[507,700,701,707,713,719],{},[230,702,703,706],{},[190,704,705],{},"L0 预防","：从配置和资源限制的层面降低故障触发的概率。",[230,708,709,712],{},[190,710,711],{},"L1 检测","：让不可见的故障变成可见——节点卡死、实例网关异常、数据库与实际状态割裂，全部要有独立的检测逻辑。",[230,714,715,718],{},[190,716,717],{},"L2 自愈","：对于低风险的故障（单实例网关重启、DB 状态对账），直接自动修复。高风险的动作（节点 drain）先告警、等人工确认。",[230,720,721,724],{},[190,722,723],{},"L3 告警","：自愈失败时、检测到新的异常时，推送给人。",[10,726,727],{},"这样的设计下，一个人维护平台的上限大幅抬高。不是因为个人能力变强了，而是系统能自动处理大多数故障，只把人类的决策能力用在最关键的地方。",[17,729,730],{"id":730},"诚实的边界在哪",[10,732,733],{},"但这个设计也有明显的天花板：",[10,735,736,739],{},[190,737,738],{},"真正撑不住的","（需要六小时以上连续操作、需要跨时区响应、需要多人交叉验证）：",[507,741,742,745,748,751],{},[230,743,744],{},"数据库或存储层的重大故障。恢复涉及数据一致性检查，无法完全自动化。",[230,746,747],{},"涉及业务逻辑的错误。修复需要理解用户意图，不只是系统恢复。",[230,749,750],{},"密钥泄露或安全事件。需要立刻通知客户、协调应急处置、事后全面审计。",[230,752,753],{},"多个独立故障同时发生、相互放大的情况。需要多个人在不同维度分别操作。",[10,755,756,759],{},[190,757,758],{},"如果重来一次，优先级这样排","：",[227,761,762,765,768,771],{},[230,763,764],{},"最先做的是 L1 检测——让故障可见。这是一切自动化的前提。宁可产生虚报，也不能漏掉真实故障。",[230,766,767],{},"其次是 L0 预防——从配置、资源限制、网络参数这些基础设施层降低故障率。这些改动成本低、收益高。",[230,769,770],{},"然后才是 L2 自愈——只对低风险的故障做自动恢复。对于高风险操作，即使多花一个人工确认的时间，也要确保不会进一步破坏系统。",[230,772,773],{},"最后是文档和监控。这些不是\"最后的事情\"，而是贯穿全过程的——每个改动都要同步更新文档、每个故障都要写进档案。",[10,775,776,777,779],{},"那些回避的成本很高。曾经因为 NFS 挂载的 ",[51,778,654],{}," 参数导致节点冻死，这个参数的改动只需要改一行配置文件、然后滚动重启一次实例。但因为这个调整一直没做，就承受了几小时的无声故障。反过来说，那些看起来\"小\"的改动——改配置参数、改资源限制、加一个监控规则——才是最划算的投资。",{"title":49,"searchDepth":338,"depth":338,"links":781},[782,783,789,794],{"id":499,"depth":338,"text":499},{"id":544,"depth":338,"text":544,"children":784},[785,786,787,788],{"id":547,"depth":347,"text":548},{"id":586,"depth":347,"text":587},{"id":613,"depth":347,"text":614},{"id":627,"depth":347,"text":628},{"id":658,"depth":338,"text":658,"children":790},[791,792,793],{"id":661,"depth":347,"text":662},{"id":678,"depth":347,"text":679},{"id":691,"depth":347,"text":692},{"id":730,"depth":338,"text":730},"2026-07-26",{},"\u002F2026-07-26-1987ai",{"title":491,"description":496},"2026-07-26-峰值1987一个人维护AI平台的边界","从 270 到 1987 日活，每一次规模跃升前都先撞了一次墙。这条增长曲线记录的不是预设设计，而是每次故障都被完整归档后逐步演进出来的可行性边界。",[483,482,802,803,804,805],"容器编排","运维自动化","故障自愈","规模扩展","D-mYcaOLOBT0PhwjmrqVVEwCE7hl8Tqi9I57HrRlt5U",{"id":808,"title":809,"body":810,"column":471,"date":1091,"description":1092,"extension":473,"hero_image":474,"meta":1093,"navigation":476,"path":1094,"seo":1095,"series_id":474,"severity":474,"stem":1096,"summary":1097,"tags":1098,"__hash__":1104},"posts\u002F2026-07-14-分销体系的账本设计.md","一笔充值，要拆成几条流水？",{"type":7,"value":811,"toc":1081},[812,819,822,826,829,832,839,850,857,861,864,870,873,876,879,890,893,901,908,912,915,926,929,940,947,951,954,960,963,974,977,981,984,987,990,1001,1004,1012,1015,1026,1029,1032,1037,1044,1049,1060,1063,1066,1069,1075,1078],[10,813,814,815,818],{},"单笔用户充值，背后是一次复杂的资金拆分：用户充值 100 元，既是平台的收入，也是分销代理的佣金来源，可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题，是",[190,816,817],{},"现金流的问题","——差一分钱就是漏账，重复一次就是挪用。",[10,820,821],{},"分销账本设计的核心就四条铁律和一个恒等式。遵循它们，系统能撑到任何规模；跳过其中任何一条，早晚会在对账时翻车。",[17,823,825],{"id":824},"规则一佣金计算基数要先定死","规则一：佣金计算基数要先定死",[10,827,828],{},"从什么数字出发算佣金？这个问题比看起来复杂。",[10,830,831],{},"通常的选项有三个：订单总金额、用户实付金额、或者订单到账净额。乍看没区别，一旦遇上退款就完全不同。",[10,833,834,835,838],{},"采用的方案是",[190,836,837],{},"用户充值净额","（billing 系统中已确认到账的实付分）。理由很直白：",[227,840,841,844,847],{},[230,842,843],{},"退款处理天然免疫。用户充值后退款，billing 的该用户账户余额已经扣掉，充值净额自动反映了这笔冲销。分成计算只需聚合这个净额乘以比例，不用单独写退款冲正逻辑。",[230,845,846],{},"避免应收账款。如果以订单金额算，还没到账时代理已经看得到分成，这在 reporting-only 设计下容易造成认知错位（代理以为钱已经是他的，实际还在支付处理中）。",[230,848,849],{},"同源唯一。billing 是平台的权威账本，分成的基数来自这里，对账时只需验证\"代理分成之和 + 平台收入 = billing 总充值\"，一个公式搞定。",[10,851,852,853,856],{},"反过来说，如果公司后续引入退款主动冲补（而非被动扣减），这个基数设定会变得复杂。但在初期，",[190,854,855],{},"基数 = billing 已确认充值"," 是最简洁的切口。",[17,858,860],{"id":859},"规则二结算时点决定了数据流向","规则二：结算时点决定了数据流向",[10,862,863],{},"到底是在订单成交时计提佣金，还是账期结束时一次性结算？",[10,865,866,867,427],{},"这里的选择是 ",[190,868,869],{},"reporting-only 只读聚合，实时查询，不计提、不累积",[10,871,872],{},"具体含义是：代理看到的\"我的分成\"不是一条条流水记录，而是每次查询时现场计算出来的聚合数字。算法是\"我名下所有用户的充值净额总和 × 我的佣金比例\"。没有单独的\"分成计提\"操作，没有一条条的\"分成到账\"记录。",[10,874,875],{},"好处和代价是对偶的。",[10,877,878],{},"好处：",[507,880,881,884,887],{},[230,882,883],{},"免除计提时点的争议。不用决定在订单成交时、支付完成时、还是 T+1 时计提，因为根本不计提。",[230,885,886],{},"天然避免双扣。既然分成不落库不累积，就不存在\"发放一次、又重复发放一次\"的并发风险。同一笔充值无论被查询多少次，贡献的佣金永远相同。",[230,888,889],{},"简化对账。代理的分成数字永远等于\"最新充值净额 × 比例\"，无需追溯历史。",[10,891,892],{},"代价：",[507,894,895,898],{},[230,896,897],{},"代理无法看到\"分成流水\"。有些运营场景下，需要展示\"哪笔订单产生了多少佣金\"这样的明细，reporting-only 做不了（可以通过关联用户的充值明细变通，但那是用户维度的流水，不是分成维度的）。",[230,899,900],{},"退款时必须同步。如果用户退了 50 块钱，billing 系统立刻反映这笔扣减，代理的分成下一秒查询就会跌下来。这对代理来说是透明的（分成就是动态的），但运营沟通时需要提前说清楚。",[10,902,903,904,907],{},"选择 reporting-only 的核心原因是：",[190,905,906],{},"初期不出金、无提现","。既然分成只是一个数字展示、不涉及真金白银的打款，那就不用建立复杂的流水账体系。等到未来做提现时，可以在 reporting-only 的基础上加一层\"快照 + 冻结\"机制（即每个提现周期开始时拍一个快照，这个快照才是可提的分成额）。",[17,909,911],{"id":910},"规则三层级上限和循环检测","规则三：层级上限和循环检测",[10,913,914],{},"分销是分多少层级？",[10,916,917,918,921,922,925],{},"首期方案是",[190,919,920],{},"单层","。用户通过一个特定的渠道码注册（如 ",[51,923,924],{},"AB-48210377","），永久绑定到某个代理。代理无法有\"上级代理\"，也就无法有\"上级佣金\"这样的递归结构。",[10,927,928],{},"这一约束看起来很强，但在无提现的 reporting-only 下，是合理的。理由是：",[227,930,931,934,937],{},[230,932,933],{},"简化代理运维。总台只需管理一套代理的佣金比例（per-代理），不用维护代理之间的树形关系。",[230,935,936],{},"避免环形链。单层天然杜绝了\"A 的上级是 B，B 的上级是 A\"这类配置错误。多层结构下，环形检测本身又是一个故障点。",[230,938,939],{},"初期够用。大多数分销场景早期就是\"直销商（代理）→ 用户\"的二元关系，不需要分级。",[10,941,942,943,946],{},"但要注意，这个约束是",[190,944,945],{},"数据模型层的","，不是业务规则层的。如果未来需要升级到多层结构，数据模型需要改（Channel 表可能要加 parentResellerId 等），但已经发出去的单层记录无需回溯改造——它们天然是单层的。",[17,948,950],{"id":949},"规则四幂等键设计防重复计提","规则四：幂等键设计（防重复计提）",[10,952,953],{},"同一笔充值可能被多个系统调用、被回调多次。分成必须严格幂等：无论这笔充值被聚合几次，贡献给代理的佣金永远是\"充值额 × 比例\"这一个数字，不能是两倍、三倍。",[10,955,956,957,427],{},"因为采用 reporting-only + 只读聚合的设计，幂等性",[190,958,959],{},"自动满足",[10,961,962],{},"推理如下：",[227,964,965,968,971],{},[230,966,967],{},"billing 侧的充值流水本身是幂等的。同一个订单号的充值，billing 确保只入账一次（通过订单号的唯一性约束）。",[230,969,970],{},"分成聚合是无状态的。每次查询时，服务端都是\"遍历该代理名下的用户 → 调用 billing 的 summaryByUsers 接口 → 汇总充值净额 → 乘以比例\"。这个聚合过程不依赖任何之前的计提记录。",[230,972,973],{},"结论：即使 billing 错误地返回了同一笔充值两次，聚合结果也只会包含一次（因为底层是\"用户ID → 总充值净额\"的映射，不是\"订单 → 充值\"的流水列表）。",[10,975,976],{},"相反，如果设计成\"订单成交时计提一条分成记录\"的模式，就需要在分成记录上加幂等键（如 orderId），确保同一订单的分成只计提一次。这个幂等键检查本身就是一个额外的故障点。",[17,978,980],{"id":979},"一个恒等式对账的唯一标准","一个恒等式：对账的唯一标准",[10,982,983],{},"前面四条规则规范了流程，但最后的验证还是要靠一个简单的数学公式。",[10,985,986],{},"$$\n\\sum_^{n} \\text{Commission}_i + \\text{PlatformNetIncome} = \\text{TotalTopup}\n$$",[10,988,989],{},"其中：",[507,991,992,995,998],{},[230,993,994],{},"$\\text{Commission}_i$ 是第 $i$ 个代理的分成（所有名下用户的充值净额 × 佣金比例）",[230,996,997],{},"$\\text{PlatformNetIncome}$ 是平台的净收入（总充值 - 所有代理的分成总额）",[230,999,1000],{},"$\\text{TotalTopup}$ 是 billing 系统的总充值额（所有用户的到账充值之和）",[10,1002,1003],{},"这个等式是唯一可靠的对账标尺。任何时刻，只要这个等式不成立，就说明某个环节出了问题：",[507,1005,1006,1009],{},[230,1007,1008],{},"等式左侧大于右侧 → 某个代理的分成算重了，或者平台收入算多了",[230,1010,1011],{},"等式左侧小于右侧 → 某个代理的分成算少了，或者某笔收入漏了",[10,1013,1014],{},"而且这个等式不需要建立任何新表。完全可以通过查询三个现成的数据源验证：",[227,1016,1017,1020,1023],{},[230,1018,1019],{},"billing 的 SummaryByUsers（每个用户的充值净额）",[230,1021,1022],{},"代理表的 commissionRate（每个代理的佣金比例）",[230,1024,1025],{},"一行 SQL 的聚合（sum 和乘法）",[17,1027,1028],{"id":1028},"技术实现的两个关键点",[10,1030,1031],{},"光有规则还不够，实现层要支撑这些规则。素材中的设计有两个细节值得指出。",[10,1033,1034,427],{},[190,1035,1036],{},"其一，billing 要暴露 summaryByUsers 接口",[10,1038,1039,1040,1043],{},"分成聚合依赖\"按用户ID汇总充值净额和消耗\"这个操作。如果 billing 侧没有这个批量接口，代理端就得自己拼接多个单用户查询，性能和一致性都会打折扣。实现计划里新增的 ",[51,1041,1042],{},"POST \u002Fanalytics\u002Fsummary-by-users"," 就是为了这个。",[10,1045,1046,427],{},[190,1047,1048],{},"其二，reseller 侧的所有查询要服务端注入 channelId",[10,1050,1051,1052,1055,1056,1059],{},"代理登录后调用 ",[51,1053,1054],{},"\u002Fapi\u002Freseller\u002Fsummary"," 或 ",[51,1057,1058],{},"\u002Fapi\u002Freseller\u002Fusers","，后端不能信任请求体里的 channelId 参数。而是从 token 解析出代理身份 → 查表得出该代理对应的 channelId → 强制注入到查询条件里。这是防代理 A 越权查看代理 B 渠道的唯一有效方式。",[10,1061,1062],{},"代码里体现为：从 Admin token 反查 Channel 表的 resellerId 字段，确保该 Admin 只能看自己那行 Channel。",[17,1064,1065],{"id":1065},"与既有分账设计的呼应",[10,1067,1068],{},"这套账本设计不是凭空造出来的。平台此前在另一个系统里实现过五类角色的分账体系（Platform、Agency、Escort、Distributor、Merchant），每个角色各维护一张 Ledger 流水表。那个设计的核心思想是\"分账入表\"——即每一笔影响各角色收入的交易，都要对应地在各自的 Ledger 表里落一条记录。",[10,1070,1071,1072,427],{},"当前这套分销设计采用了相反的思路：不建 Ledger 表，而是在查询时通过聚合来推导分成。这的背景是 reporting-only 属性（不出金、只展示），使得可以接受动态聚合的方案。但底层的思想是一致的——",[190,1073,1074],{},"通过对账恒等式来保证多角色之间的收支平衡",[17,1076,1077],{"id":1077},"结尾",[10,1079,1080],{},"账本设计的目标不是漂亮的表格或丰富的报表，而是一个简单的数学等式永远成立。一旦等式破裂，对账人员立刻能定位是哪个环节失守。这比事后扑火要高效得多。",{"title":49,"searchDepth":338,"depth":338,"links":1082},[1083,1084,1085,1086,1087,1088,1089,1090],{"id":824,"depth":338,"text":825},{"id":859,"depth":338,"text":860},{"id":910,"depth":338,"text":911},{"id":949,"depth":338,"text":950},{"id":979,"depth":338,"text":980},{"id":1028,"depth":338,"text":1028},{"id":1065,"depth":338,"text":1065},{"id":1077,"depth":338,"text":1077},"2026-07-14","单笔用户充值，背后是一次复杂的资金拆分：用户充值 100 元，既是平台的收入，也是分销代理的佣金来源，可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题，是现金流的问题——差一分钱就是漏账，重复一次就是挪用。",{},"\u002F2026-07-14",{"title":809,"description":1092},"2026-07-14-分销体系的账本设计","分销系统最容易在财务对账出错。单笔充值需同时产生平台收入、代理佣金等多条记录，必须在同一事务内闭合。四条规则与一个恒等式是账本设计的全部。",[1099,1100,1101,1102,1103],"分销","账本设计","对账","幂等性","佣金结算","Q3NC9Z8JBJ7e2MLQFH10q68Igpbp2fMF5gT-do6Rxaw",{"id":1106,"title":1107,"body":1108,"column":471,"date":1418,"description":1112,"extension":473,"hero_image":474,"meta":1419,"navigation":476,"path":1420,"seo":1421,"series_id":474,"severity":474,"stem":1422,"summary":1423,"tags":1424,"__hash__":1430},"posts\u002F2026-06-29-记忆星系长期记忆可视化工作台.md","模型记错了，用户得能删掉",{"type":7,"value":1109,"toc":1408},[1110,1113,1116,1120,1126,1129,1132,1135,1138,1145,1148,1151,1183,1186,1189,1262,1269,1276,1279,1282,1326,1329,1332,1336,1339,1342,1345,1348,1351,1354,1357,1360,1363,1370,1377,1380,1383,1390,1393,1396,1399,1402,1405],[10,1111,1112],{},"长期记忆存起来容易，用户看不见也管不了。记忆一旦不可见，就会累积错误信息并持续污染后续对话——模型出错时没有纠正入口，错误就永久留存。",[10,1114,1115],{},"我在 yun-claude 的记忆设计中遇到的问题正是这个。聊天系统能自动从对话抽取持久要点并入库，但页面还是传统的卡片列表，看不出记忆的类型、重要性、使用频次。更严重的是，用户无法编辑或删除那些被错误标记的记忆。所以这次升级的核心不是加一个炫彩的可视化，而是把记忆管理变成一个真正可用的信息工作台。",[17,1117,1119],{"id":1118},"表格优先而不是星河优先","表格优先，而不是星河优先",[10,1121,1122,1123,427],{},"设计的第一个决策是：",[190,1124,1125],{},"主界面用表格承载记忆列表，不用星河画布作主体交互",[10,1127,1128],{},"这听起来反直觉。当初考虑过让星河画布成为核心——节点按重要性大小分布、按创建时间环形排列、搜索命中时高亮。视觉上是漂亮的，也更有\"知识宇宙沉淀\"的产品感。但约束改变了这个选择：",[10,1130,1131],{},"第一，信息密度。表格一屏可以显示 10-20 条记忆的核心属性（标题、类型、重要性、标签、创建时间），并支持排序和筛选。星河画布要展示相同的信息量，就必须让节点变小、缩放调整、甚至分屏展示——交互成本陡升。而用户——AI agent 的主人——需要快速浏览和定位记忆，不是浏览艺术装置。",[10,1133,1134],{},"第二，编辑成本。星河上的节点编辑通常要额外打开面板或弹窗。如果记忆管理的主要工作是\"检查是否有错记、删除重复、调整分类\"，那星河就不是最优方案。对比之下，表格 + 右侧详情面板的结构让用户可以同时看到列表和正在编辑的项目，没有上下文切换。",[10,1136,1137],{},"第三，用户规模。当前 yun-claude 没有真实用户，推断单个用户的记忆条数会比较少（几十到几百）。在这个规模下，表格就足够快了，不必依赖图形加速或向量索引的复杂优化。如果将来规模增大再考虑换方案。",[10,1139,1140,1141,1144],{},"所以最终设计是：",[190,1142,1143],{},"表格为主工作台，右侧详情面板负责编辑与整理，星系可视化只作为辅助视图（后续可选）","。这不是缺乏视觉想象力，而是对工作流的务实权衡。代价是产品感稍弱，但可用性强得多。",[17,1146,1147],{"id":1147},"五类语义记忆与设计令牌",[10,1149,1150],{},"为了让记忆可见可管，将长期记忆分成五类：",[507,1152,1153,1159,1165,1171,1177],{},[230,1154,1155,1158],{},[190,1156,1157],{},"核心记忆","（CORE）：与用户身份、核心项目、重要约束直接相关。模型在每轮对话发送前都应该检索。",[230,1160,1161,1164],{},[190,1162,1163],{},"常驻记忆","（PERMANENT）：用户的工作背景、技术栈偏好、团队结构等长期背景。",[230,1166,1167,1170],{},[190,1168,1169],{},"临时记忆","（TEMPORARY）：短期的任务进度、当前问题、临时约束。生命周期短。",[230,1172,1173,1176],{},[190,1174,1175],{},"知识星云","（KNOWLEDGE）：用户分享的文档要点、API 文档摘录、最佳实践。主要用于 RAG 增强。",[230,1178,1179,1182],{},[190,1180,1181],{},"其他","（OTHER）：模型无法明确分类或用户手动标记的记忆。",[10,1184,1185],{},"每一类的关键差异是生命周期和召回策略。核心记忆应该高频被注入 prompt，临时记忆应该自动清理，知识类应该被 RAG 系统共同使用。",[10,1187,1188],{},"为了在视觉上强化这个分类，为每类配置了一个独立的语义色：",[1190,1191,1192,1208],"table",{},[1193,1194,1195],"thead",{},[1196,1197,1198,1202,1205],"tr",{},[1199,1200,1201],"th",{},"类型",[1199,1203,1204],{},"颜色",[1199,1206,1207],{},"用途",[1209,1210,1211,1223,1233,1243,1253],"tbody",{},[1196,1212,1213,1217,1220],{},[1214,1215,1216],"td",{},"核心",[1214,1218,1219],{},"Amber-500",[1214,1221,1222],{},"节点、筛选按钮、卡片左边线",[1196,1224,1225,1228,1231],{},[1214,1226,1227],{},"常驻",[1214,1229,1230],{},"Sky-500",[1214,1232,1222],{},[1196,1234,1235,1238,1241],{},[1214,1236,1237],{},"临时",[1214,1239,1240],{},"Teal-500",[1214,1242,1222],{},[1196,1244,1245,1248,1251],{},[1214,1246,1247],{},"知识",[1214,1249,1250],{},"Violet-500",[1214,1252,1222],{},[1196,1254,1255,1257,1260],{},[1214,1256,1181],{},[1214,1258,1259],{},"Slate-500",[1214,1261,1222],{},[10,1263,1264,1265,1268],{},"关键的设计决策是：",[190,1266,1267],{},"颜色不只是装饰，而是结构的一部分","。在表格、筛选条、详情面板的边线上，用户都能看到同一个颜色，强化类型认知。这样的一致性是可信的信息工作台的标志——用户一眼知道哪条记忆是核心、哪条是临时。",[10,1270,1271,1272,1275],{},"颜色值不是散落在各个组件里的魔法数字，而是集中在一个 ",[51,1273,1274],{},"memoryStyles.ts"," 文件中管理。任何需要记忆类型颜色的地方都从这里引用。这样改一个颜色时不必跨多个文件搜索替换，也不会出现同类型在不同地方显示不同色的尴尬局面。",[17,1277,1278],{"id":1278},"记忆模型与后端约束",[10,1280,1281],{},"后端为每条记忆增加了结构化字段：",[507,1283,1284,1290,1296,1302,1308,1314,1320],{},[230,1285,1286,1289],{},[51,1287,1288],{},"title","：节点或列表行的标题，从正文前 18 个字符生成",[230,1291,1292,1295],{},[51,1293,1294],{},"type","：五类之一",[230,1297,1298,1301],{},[51,1299,1300],{},"importance","：1-100 的重要性分值，决定节点大小和列表排序",[230,1303,1304,1307],{},[51,1305,1306],{},"tags","：字符串数组，最多 8 个标签",[230,1309,1310,1313],{},[51,1311,1312],{},"lastUsedAt","：最近被召回的时间",[230,1315,1316,1319],{},[51,1317,1318],{},"usedCount","：被召回次数",[230,1321,1322,1325],{},[51,1323,1324],{},"metadata","：扩展字段，保存来源会话、模型、抽取动作等",[10,1327,1328],{},"当模型抽取记忆时，输出包含这些结构化字段。服务端必须做兜底和校验：type 非法时改为 OTHER、importance 超范围时裁剪、tags 去重去空白最多保留 8 个。如果抽取失败，仍然保存正文并用默认字段（importance=50, type=OTHER）。",[10,1330,1331],{},"这些默认值的设计是为了降级优雅。即便结构化抽取失败，记忆也不会丢失，只是分类不精准——这是可以接受的，因为用户随后可以手动调整。",[17,1333,1335],{"id":1334},"搜索筛选与编辑","搜索、筛选与编辑",[10,1337,1338],{},"用户在表格上方有一个搜索框和类型筛选条。搜索时调用后端的语义搜索接口，命中的记忆在表格中高亮，并自动选中最高相关性的一条，打开右侧详情面板。语义搜索基于向量数据库的余弦相似度匹配——记忆文本被嵌入为 4096 维向量，查询时也转换为向量并与库内存储按相似度排序召回，只返回分值达到阈值（≥0.3）的结果。这样的匹配比关键词搜索精准度高，也支持语义近似的记忆关联（比如\"TypeScript 后端\"和\"TS 服务端\"会被认为相关）。搜索失败时保留当前星河状态并 toast 提示，不中断工作流。",[10,1340,1341],{},"筛选可以按五类过滤，清除筛选则回到全量视图。如果当前选中的记忆被筛选隐藏了，系统会自动选中可见记忆中最高重要性的那一条；如果没有可见记忆，则关闭详情面板显示空状态。",[10,1343,1344],{},"详情面板里，用户可以编辑标题、正文、类型、标签和重要性。编辑表单使用产品内设计，不弹浏览器原生弹窗。保存后立即更新列表和节点（如果有星河视图的话）。这样用户修改一条记忆时，不必刷新页面或等待后台同步，改动立刻可见。",[10,1346,1347],{},"删除是另一个关键能力。必须让用户能删除被错误标记的记忆，否则错误就成了永久污染源。删除按钮放在详情面板的危险操作区，点击后需确认。删除成功后，该条记忆从表格和星河中消失，列表自动选中下一条（如果有的话）。",[17,1349,1350],{"id":1350},"星河与移动端降级",[10,1352,1353],{},"虽然主界面是表格，但在设计中保留了星河作为可视化补充。桌面端在表格下方或侧边可以显示一个小星图，让用户看到记忆的空间分布——核心记忆聚在中心，临时记忆分散在外围。星河上的节点与表格关联：点击表格行时，星河也高亮对应节点；点击星河节点时，表格定位到对应行。",[10,1355,1356],{},"但星河不是强制项。第一版的实现可能不包含完整星河，而是先确保表格工作台完整可用。星河可以作为后续的增强——用户如果觉得需要可视化辅助，才加上去。",[10,1358,1359],{},"移动端设计上，星河更是不现实（屏幕太小）。所以移动端完全降级为列表视图，点击列表项后通过底部抽屉显示详情和编辑表单。搜索和类型 tabs 放在顶部，保持核心交互可用。这样既避免了表格在手机上的横向溢出，也保证了可用性。",[17,1361,1362],{"id":1362},"节点布局与稳定性",[10,1364,1365,1366,1369],{},"如果星河要实现，一个重要的设计细节是：",[190,1367,1368],{},"节点位置必须稳定","。即便刷新页面或重新打开应用，同一批记忆应该保持相同的位置，这样用户才能形成空间记忆——\"核心记忆总在中心，临时的在右上角\"。",[10,1371,1372,1373,1376],{},"这意味着节点位置不能是随机的实时布局，也不能用物理模拟（那会每次都算不同的位置）。用 ",[51,1374,1375],{},"createdAt"," 字段参与布局计算，保证确定性：同一条记忆的创建时间固定了，它在环形排列中的角度也就固定了。结合 importance 决定距离中心的远近，位置就完全由数据驱动，刷新后毫厘不差。",[17,1378,1379],{"id":1379},"一致性与可维护性",[10,1381,1382],{},"这个设计的关键约束是一致性。如果记忆在表格中是 Amber-500（核心），那在星河、筛选、详情面板的边线上也必须是 Amber-500。任何拆散这个一致性的修改都会破坏用户的心智模型。",[10,1384,1385,1386,1389],{},"所以在设计系统里明确了这一点：",[190,1387,1388],{},"新增或调整记忆类型视觉时，先更新设计文档里的语义色板和 memoryStyles.ts，再在各组件中引用，不允许组件各自复制色值","。这样的约束看似严苛，但它保证了长期的可维护性——下次要改颜色时，只需改一个文件。",[17,1391,1392],{"id":1392},"代价与权衡",[10,1394,1395],{},"这个设计的代价是什么？",[10,1397,1398],{},"第一，视觉冲击力比不上星河优先。表格是务实的设计，不够\"黑科技\"感。如果产品定位是\"AI 记忆系统\"要卖视觉冲击，这个方案会显得保守。",[10,1400,1401],{},"第二，星河的潜力没有完全释放。放弃了复杂的关系推理、自由漫游、力导向布局这些\"高级\"可视化特性，原因就是表格工作台不需要它们，而加上去反而添加复杂度。",[10,1403,1404],{},"第三，设计系统的维护成本提高了。一致性的要求意味着每次改动都要考虑全局影响。但这其实是长期收益——减少了 bug 和不一致的可能。",[10,1406,1407],{},"这些代价对 yun-claude 是可以接受的，因为当前用户还不多，重点是让产品可用，而不是炫技。如果将来用户规模上升，数百条甚至千条记忆的管理场景出现，表格 + 星河的混合界面可能不够，那时再考虑更激进的可视化方案。现在，信息工作台的优先级明确高于视觉体验。",{"title":49,"searchDepth":338,"depth":338,"links":1409},[1410,1411,1412,1413,1414,1415,1416,1417],{"id":1118,"depth":338,"text":1119},{"id":1147,"depth":338,"text":1147},{"id":1278,"depth":338,"text":1278},{"id":1334,"depth":338,"text":1335},{"id":1350,"depth":338,"text":1350},{"id":1362,"depth":338,"text":1362},{"id":1379,"depth":338,"text":1379},{"id":1392,"depth":338,"text":1392},"2026-06-29",{},"\u002F2026-06-29",{"title":1107,"description":1112},"2026-06-29-记忆星系长期记忆可视化工作台","表格优先的记忆管理：用高信息密度工作台承载持久要点，可视化只作辅助，设计令牌贯穿全局。",[1425,1426,1427,1428,1429],"长期记忆","信息工作台","设计决策","语义搜索","设计令牌","gsJKxlNQ-FiADsw2ca6e5WJo-_uyYDFZ55iuiMr4jQE",1785406912237]