圣堂之魂
认识 Short_NURL

设计细节


本文由 Claude Opus 4.6 生成,经过人工校对。

本文描述 Short_NURL v1.11.0;其他版本的认证、路由和恢复逻辑可能不同。

Short_NURL 需要 OpenResty 与 PHP-FPM 协作运行:OpenResty 处理跳转、热存储和定时恢复,PHP-FPM 处理 WebUI / 无头 API、认证与 JSON 持久化。


一、热存储与冷存储的分层

跳转是整个系统最高频的操作,延迟直接影响用户体验。OpenResty 的 lua_shared_dict 是一块所有 worker 进程共享的内存区域,URL 映射表常驻其中。无论哪个 worker 接到请求,查找的都是同一张表,不需要进程间同步,也不触碰磁盘。

与之对应,管理操作(创建、删除、查询)走 PHP-FPM,短链持久化到 perm.jsontemp.json。冷存储是短链存在性、内容和配额计数的权威来源;热存储是用于跳转和诊断计数的缓存投影。认证数据另存于 keys.json,PHP↔Lua 的内部通信令牌存于 internal_token

这个分层带来一个核心设计原则:创建和删除都先修改冷存储,再同步热存储;跳转只读热存储,管理查询和配额判断以冷存储为准。创建后热同步失败时,记录已经持久化,但在重新同步或恢复前可能暂时 404;删除后热同步失败时,旧热条目可能短暂保留。首次请求会惰性加载 JSON,定时 sweep 也会从冷存储恢复被共享字典驱逐的有效条目并校准计数。


二、为什么不用普通 Nginx

一个常见的替代思路是:让 PHP 在写入短链后将 URL 映射生成为 Nginx map 指令,再触发热重载。这在技术上可行,但有两个根本性问题。

第一是可靠性。PHP 负责生成 Nginx 配置文件,一旦出现异常(写入中断、并发竞争、编码错误),就可能导致配置校验失败或 reload 失败。当前设计不为每条短链改写 Nginx 配置;热存储计数可通过受保护的 /internal/stat 供 PHP 诊断,冷存储仍是权威来源。

第二是生效路径。为每次创建执行 nginx -s reload 会引入配置校验、进程切换和部署环境相关的等待;lua_shared_dict 写入成功后则对各 worker 共享可见,不需要为单条短链 reload。


三、内部通信的边界

PHP 层和 Lua 层的分工边界非常清晰:PHP 负责业务逻辑(去重、配额、认证、锁内写入),Lua 负责热存储操作、计数维护、恢复和跳转。两者通过独立内部监听端口上的 /internal/set/internal/delete/internal/stat 通信;Nginx 默认只允许本机回环和部署者明确配置的 Docker bridge 网段,公开站点还会拦截 /internal/*

内部通信还使用应用层认证:PHP 每次请求携带 LPA-Key,Lua 在 dispatch() 入口做常量时间比较。令牌由 nurl -new 在缺失时生成,也可用 nurl -itk 主动轮换,存储在 backend/data/internal_token。v1.11.0 两侧每次内部调用都重新读文件,因此轮换立即生效,无需 reload;令牌缺失或不可读时 Lua 以 500 拒绝请求,错误令牌以 403 拒绝,不再为兼容而跳过认证。

is_new 的判断(用于决定是否递增热计数器)和 su_url:set 之间并不是原子操作。PHP 的双 Store 锁和冲突检查会阻止同一短码并发写入冷存储,但热同步发生在释放文件锁之后,极端并发下热计数仍可能短暂漂移。因此 v1.11.0 不把这两个累积计数当作配额权威,而是用冷存储统计,并由 sweep 在驱逐恢复时校准热计数。


四、计数的精度与漂移

系统在热存储中维护 perm_counttemp_count 两个诊断计数器。它们在三个地方被修改:internal_set.lua(创建时递增)、internal_delete.lua(删除时递减)、expire_sweep.lua(清理过期临时链时递减或在驱逐恢复后重算);实际配额判断遍历冷存储中的有效条目。

临时链的热计数存在一个已知窗口:su_url 使用原生 TTL,到期后由 OpenResty 自动回收;但 temp_count 的递减要等到 expire_sweep 定时运行才触发,通常最长约为一个 su_expire_interval(默认 1 小时)。su_exp 使用 TTL=0,让 sweep 仍能找到索引、清理过期项并递减计数;若发生共享字典驱逐,则下一轮 sweep 从冷存储恢复条目并重算计数。

配额接口使用 countActive() 遍历当前可读取的 JSON 数据,不依赖热计数器,因此 api/stat.php 和无头统计以冷存储计数为权威,并把热计数仅作为诊断信息。这里的“权威”不等于文件绝不会损坏:v1.11.0 遇到 JSON 缺失结构或解码失败会拒绝继续覆盖并返回错误,应从备份和日志排查。


五、锁的设计

系统里有两套锁机制,服务于不同的场景。

PHP 层用文件锁(flock + lockBegin/lockEnd)保护 JSON 文件的读-检查-写操作。这是必要的。

去重检查和实际写入之间如果没有锁,两个并发的创建请求可能都通过去重检查,然后都写入,产生重复条目。文件锁确保这个临界区是串行的。

Lua 层用 su_meta 里的 lock_sweep 标志防止多个 worker 的 sweep 重叠。这个锁通过原子 add() 获取,TTL 为 su_expire_interval 的 2 倍;如果持锁 worker 崩溃,锁会自动释放,不会永久死锁。

这是分布式锁的一个经典权衡:TTL 防止死锁,代价是极端情况下可能有两次 sweep 重叠,但 sweep 的操作是幂等的(删除已经被删除的条目不会有副作用),所以重叠不会造成数据错误。


本页目录