两天后,三道裂缝同时出现
上一篇《当今最前沿的全栈架构》交卷的时候,我把话说得很满:每一层都取到了 2026 年技术栈的前端,跑在真实流量上。那篇文章发出去两天,这台机器就逼着我把三件事办了——一件是被迫的,一件是顺手的,一件是欠着的。
被迫的:SQLite 死了。上篇里 CAF actor 批写埋点落的是本地 SQLite page_views 表,部署到线上后我翻库文件,0 字节。写入路径从来没有真正工作过。Drogon ORM 重度依赖运行时类型反射,而它和 C++ modules 编译出来的类型信息互相看不懂,注册环节直接 Demangle 报错,整条 ORM 链路在 modules 后端上形同虚设。
顺手的:clang 23.1 的 C++26 支持已经齐了。反正 modules 这种新东西都吃下去了,标准往上拨一格的边际成本接近零。
欠着的:HTTP/3。Nginx 的 QUIC 配置早写完了,公网 UDP 443 却始终拿不到确凿的通过证据,一度差点被我冤判成安全组配置问题。发稿前几小时它终于结案:双栈实测闭环。怎么结的,值得单独说。
一场被迫的手术:TrailBase 成为唯一持久层
先处理最疼的。0 字节的 SQLite 意味着两件事:page_views 统计一直是空转,admin_sessions 会话全靠进程内存硬扛,后端一重启所有登录态蒸发。这不是优化问题,是数据面残废。
修法没什么可犹豫的:既然 TrailBase 本来就是文章数据的唯一真源,运行时数据没有理由开第二张桌子。手术清单如下:
- 删除 zjbackend_db.cppm:整个 SQLite schema 模块连同 Drogon ORM 的 include 一起清除,State 里不再有 db/dbPath 这类成员
- page_views:CAF actor 批写逻辑原样保留(≥8 条或 800ms 触发),落库目标从“单事务写 SQLite“换成 POST TrailBase 的 /api/records/v1/page_views
- admin_sessions:迁进 TrailBase,id 用 INTEGER PRIMARY KEY、token 加 UNIQUE 索引。TrailBase 只认 INTEGER 或 UUID 主键,这是我用一条报错换来的知识
- 会话校验:TrailBase 是 HTTP 服务,同步的 AdminFilter 不可能每次鉴权都回环打一跳。启动时 loadAdminSessions() 全量载入,运行期 State.sessionCache(token → expires_at,mutex 保护)扛住同步检查,login/logout 异步回写
埋点管线从进程内挪到了跨进程,图变成这样:
beacon → HTTP 线程(仅投递,微秒级返回)
↓ anon_send
CAF detached actor(攒批:≥8 条 或 800ms)
↓ 批量 POST
TrailBase REST(loopback :4000)
多了一跳 loopback REST,换来的是:统计和会话数据在后端重启后原样活着,而且和文章数据共用同一份备份、同一套 ACL。ROS 2 的老经验又应验了一次:数据落在一个节点本地内存里,就等于把故障域焊死在这个节点上;走总线,才谈得上故障域隔离。
TrailBase record API 有四个坑,都是一手摸出来的:
- 分页上限 1024/页。 统计接口要全量拉 page_views 在内存里聚合,cursor 分页循环必须同时判断两个条件:光看 cursor 为空不够,TrailBase 即使返回了部分页也总是带一个 cursor,正确退出条件是“records 数组为空“。这个坑让我第一批趋势图少了一半数据。
- DELETE 认 id 不认字段。 先用
?field=value精确过滤查到记录拿到数字 id,再对/api/records/v1/xxx/{id}发 DELETE。logout 删会话就是这么两步。 - config.textproto 改完要重启。 record_apis 的 ACL(acl_world / acl_authenticated)写在 textproto 里,热改不生效,必须重启 TrailBase。第一次联调时 401 到怀疑人生,重启即通。
- 内部 token 永不出进程。 refresh token 依然只活在 Drogon 内存里,Nginx 层继续把 TrailBase 的所有特征路径 404 掉。数据搬了家,安全边界没退半步。
C++26:不为版本号,为反射占跑道
标准从 C++23 拨到 C++26,编译配置层面其实平淡:CMAKE_CXX_STANDARD 一行,29 个目标全部编译通过,仅剩 1 个 CAF 的 deprecation 警告。10 个模块单元的边界纹丝不动。上篇划的“模块化边界划在我能掌控的代码“这条线,升标准之后依然成立。
有两个值得留档的发现。
第一,中间件反射在模块下又裂出一个新形态。上篇写过 Drogon 用 typeid demangle 后的类名匹配路由,这次的表现是:启动日志打 “middleware AdminFilter not found”,但鉴权实际是好的。排查结论:filter 的反射注册在 modules 环境下确实拿不到预期名字,项目里真正干活的是注册时的 pre-routing advice,中间件告警属于“启动时喊一嗓子但没人应“的预期噪音。把这条留在这儿,下一个用 Drogon + modules 的人看到同样日志,不用回头怀疑自己的代码。
第二,C++26 真正的引力是反射(P2996)。这轮没有实际用上,说白了就是先把地基打好:等静态反射在手,TrailBase 的 record JSON 映射、路由参数绑定这些样板代码都有机会从运行时反射换成编译期生成,那时候 C++26 才真正开始给这个项目付利息。另外上篇欠的账还在:clang 23.1 的 Homebrew 构建依然没给标准库 header units,import std 继续排队。
HTTP/3 终验结案:一场本地宽带制造的冤案
Nginx 侧该配的早已配完:
- listen 443 quic(UDP)+ listen 443 ssl(TCP,HTTP/2 兜底)
- Alt-Svc 头对全部 HTML/CSS/JS/SW 路径广播,浏览器先走 h2、拿到广告后升级 QUIC
- h3 带来的体感在首包:0-RTT 重连和队头阻塞消除,对移动端弱网最敏感
但 IPv6 上的 QUIC 一直是桩悬案:握手始终不通。第一嫌疑人是阿里云安全组。登服务器查证:nginx 在 v4/v6 的 TCP+UDP 443 四个监听全部在位,安全组规则逐条核对无一缺失。服务器端挑不出毛病,但测试机发包就是不到。
于是换了个无 root 的取证法:在服务器上对比 /proc/net/snmp6 的 Ip6InReceives 收包计数。发包前后计数一动不动,20 个探测包全军覆没。证据似乎坐实了“云端丢弃“,我差点就去控制台改安全组了。
反转来自对照实验:往服务器 IPv4 地址发 UDP,socket 直接报 Errno 65 “No route to host”,可 v4 UDP 443 上的 QUIC 握手上一刻还是 200。继续放大:这台宽带对 Google DNS 的 v6 UDP 53、Cloudflare 的 v6 UDP 443、甚至自家站点的 v6 TCP 443,都会在几分钟内从“通“抖成“全拒“。三份“服务器丢包“的证据,全是本地宽带 IPv6 出站间歇抖动污染的。冤案就此平反。
最终裁决换在手机蜂窝网络上做:连上热点,curl --http3-only -6 直打 zjchina.net,HTTP/3 200,动态文章页走 IPv6 QUIC 全链路 0.13 秒。IPv6 三件套(AAAA、VPC 网关、安全组 ::/0)其实早已配齐,双栈 HTTP/3 至此实测闭环。
写 ROS 2 时 DDS 全栈跑在 UDP 上,我对“UDP 不可靠“这个说法一直有保留:UDP 本身是中性协议,真正拿捏它的是路径上的每一台中间盒。这一次算是用生产站点亲身验证了,协议栈的最后一公里,往往不在代码里,也不在云上,而在你家光猫里。
静态面速度跑:wasm 瘦身 36%,Nginx 零 CPU 直出
数据面动手术的同时,静态面做了一轮纯赚的速度跑。
WASM 瘦身。 上篇的 wasm 产物 1,370,137 字节,接进 wasm-opt(binaryen 的优化器)跑完降到 876,069 字节,36% 直接蒸发,gzip 后线上传输 330KB。构建脚本顺手合并了原本重复的两段预压缩流程,现在的顺序是:wasm-opt → 注入 index.html → 外置 boot.js → 全量 gzip/brotli。
Nginx 直出。 /assets/dioxus/ 不再穿 Drogon 的手,Nginx 直接以 gzip_static 吐预压缩文件,零 CPU、零应用层跳数,预压缩的 .gz 在磁盘上等命。这符合上篇定下的原则:能由更靠前的层完成的事,就不让应用层碰。
顺带记一笔构建链的坑,都是生产服务器上撞出来的:
- linuxbrew 的 libcrypto 要求 GLIBC 2.38,服务器只有 2.36:链接时必须把 -L/usr/lib/x86_64-linux-gnu 排在最前,优先吃系统 libcrypto,brew 的那份只能用于本地开发
- 仓库自带的 build-linux.sh 编译器探测只认 g++12,linuxbrew 的 clang 23.1 它看不见,CMake 得手动配 linuxbrew 路径
- 服务器和本地直连 GitHub 下载都失败(一个 exit 18,一个 HTTP/2 framing error),gh-proxy.com 镜像救场,wasm-opt 就是从这儿拿到的
又记了几笔学费
上篇有“打磨期的真实战报“,这篇对应的是运维侧的学费:
日志会撒谎。 后端在 journald 下跑,stdout 是块缓冲,业务日志会攒到 4KB 以上才成批冒出来。我一度因为“日志里没有“断定某条链路没执行,后来靠 TrailBase 里的数据才确认代码跑过了、只是日志迟到。教训:缺日志不能作为“代码没跑“的证据,要么刷够日志量冲掉缓冲,要么直接查副作用。
scp 被自己加固死了。 之前给 sshd 做安全加固时顺手把 scp 的通道关了,传文件的时候才发现。解法是 tar 管道:tar czf - | ssh 'tar xzf -',单个二进制就用 gzip 管道。加固的代价总是未来的自己来付,这回付得心服口服。
深挖日志时还发现一个反直觉的行为:Chrome DevTools 里明明看到 WASM 已经加载了 1.37MB 旧版本——那是 Service Worker 在护旧。上篇抱怨过 SW 版本靠手改常量,这次升级再次验证了这条债有多烦人,自动版本化依然排在待办前列。
发布半小时后,缓存反咬了一口
这篇文章发布到线上半小时,我打开装好的 PWA 想欣赏一下成果:首页只剩居中的 logo 和背景粒子,九个 section 全空。发出去的文章还没捂热,机器先给我上了一课。
第一步先排除服务器。用干净的浏览器实测,九个 section 全部在、console 零错误、42 个请求全 200。服务端没问题,问题锁死在“老访客“的缓存层。我自己就是最深的老访客:装了 PWA、注册着 SW、HTTP 缓存里躺着几个月的资产。
第二步看粒子这个线索。粒子还在跑,说明旧世界的增强脚本活着;内容区全空,说明新世界没接管。这不是加载失败,是两套版本在同一个浏览器里打架。
真凶藏在响应头里。后端静态服务的规则是“所有 css/js 一律 immutable 一年“:site.css?v=、nav.js?v= 带版本参数,URL 一变缓存自然失效,没毛病。但 sw.js 没有版本参数,也被这条规则盖了 immutable。浏览器规范:HTTP 缓存里的 sw.js 不满 24 小时不会绕过重取。于是新 SW 装不上,旧 SW 继续用它缓存的 index-shell 应答,壳是 CSR 降级路径,引用的资源对不上,结果就是一张跑着粒子的空白壳。
修法三层,各干各的活:
- 后端静态服务(C++):sw.js / register-sw.js 单独判 no-cache,可缓存但每次 revalidate;其余带版本参数或内容 hash 的资源照旧 immutable,该快的照样快
- Service Worker:缓存版本 v5 → v6,新 SW 激活即删除全部旧缓存(含那张陷阱壳),clients.claim 立即接管
- Nginx:404 响应不再带 immutable(add_header 的 always 后缀只留给安全头),坏掉的旧 hash 文件名不会被浏览器缓存一年
修完的验收标准我写成了这么一句:缓存策略的及格线不是新访客多快看到页面,而是老访客能不能无感升级。 强刷新解决的是我自己的问题,解决不了一个月前访问过的人的问题。三层修复之后,新版本生效是服务端闭环:HTML no-cache 拿新引用,版本参数和 hash 命名击穿资源缓存,sw.js no-cache 保证 SW 逻辑即时接管,访客零操作。
有一处诚实交代:已经中招的访客,缓存里的旧 sw.js 要等满 24 小时才会被浏览器绕过重取,这是规范上限,服务端无法再快。但过了这个窗口,此后每一次发版都是即时生效。
这一课的讽刺性在于:我上午刚在草稿里写下“SW 自动版本化仍欠“,下午就为这条欠账付了一轮实打实的利息。
对账:两天后,“最前沿“还站得住吗
上篇结尾留了三笔欠账,两天过去对个账:
- import std:仍欠,卡在工具链 header units,非我方能为
- Service Worker 自动版本化:部分兑付,sw.js 的更新链路已用 no-cache 闭环,CACHE 版本常量还是手改,全自动仍欠着
- 线上错误监控:仍欠,下一个要补的洞
但结构性的账还清了一笔大的:数据面从“半残“修成“单一真源“,ORM 这层名存实亡的中间人被整体移除,后端从此只有一条数据通路。删代码永远比加代码舒坦,这次删的是整个 SQLite 模块。
现在这台机器的真实状态是:C++26 modules 的后端、TrailBase 唯一持久层、CAF actor 批写跨进程落库、Nginx 直出预压缩静态资源、HTTP/3 双栈实测闭环。和上篇一样,每个数字都来自真实测量,每句话都能在 zjchina.net 上找到对应的实体。
最后还是那句交代:这轮手术相当一部分是我和 AI 结对完成的——它翻 TrailBase 文档、对 CMake 报错、把 0 字节的库文件第一次摆到我面前;我拍板删 ORM、定缓存模式、决定哪些欠账继续欠。48 小时之内,一台已上线的机器完成三处换件而不停机,这种事放在人肉运维时代要靠运气,现在靠的是上篇打下的底子:渐进降级让每个部件都可以单独动刀。
如果你也守着一台刚上线的机器,我的建议是:别急着加东西,先让第一次真实流量把裂缝照出来——裂缝出现的位置,往往比架构图上的任何箭头都诚实。