圣堂之魂
认识 Short_NURL

设计细节


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

Short_NURL 需要两个服务同时运行:OpenResty(处理跳转)和 PHP-FPM(处理管理 API)。


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

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

与之对应,管理操作(创建、删除、查询)走 PHP-FPM,数据持久化到 perm.jsontemp.json 两个 JSON 文件。这是冷存储,是数据的唯一权威来源。热存储是冷存储的缓存投影,而非独立的数据源。

这个分层带来一个核心设计原则:写入顺序必须是先冷后热,读取顺序必须是先热后冷。创建短链时,PHP 先写入 JSON 文件获得持久化保证,再通过内部 API 通知 Lua 层写入共享内存。删除时同样如此。如果中间某步崩溃,最坏的情况是热存储里有一条多余的记录(但冷存储里已没有),下次重启时冷启动会重建热存储,状态自动修复,不会有幽灵数据永久存在。


二、为什么不用普通 Nginx

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

第一是可靠性。PHP 负责生成 Nginx 配置文件,一旦出现异常(写入中断、并发竞争、编码错误)配置文件损坏,下一次 nginx -s reload 直接导致整个服务不可用。跳转服务的可用性完全绑定在 PHP 写文件操作的健壮性上,这是一个危险的依赖。热存储的状态随时可以通过 /internal/stat 查询,出了问题有迹可查;而几百行 map 指令出了问题,只能肉眼排查文本。

第二是延迟。nginx -s reload 是进程级操作,最快也要数百毫秒。在这个窗口内新创建的短链无法跳转,旧的短链如果刚好在重载瞬间被访问,行为也不确定。lua_shared_dict 的写入是内存操作,在 internal_set.lua 执行完的瞬间立刻对所有 worker 可见,没有窗口。


三、内部通信的边界

PHP 层和 Lua 层的分工边界非常清晰:PHP 负责所有业务逻辑(去重、配额、认证、锁内写入),Lua 负责纯缓存操作(读写共享内存、计数维护、跳转处理)。两者通过 /internal/set/internal/delete/internal/stat 三个内部端点通信,这些端点在 Nginx 层通过 IP 白名单强制隔离,外部不可访问。

v1.10.1 起内部通信新增应用层认证:PHP 每次请求携带 LPA-Key 请求头,Lua 在 dispatch() 入口校验。令牌由 nurl -new 自动生成(或用 nurl -itk 单独操作),存储在 backend/data/internal_token 文件中,PHP 和 Lua 共读同一文件。未生成令牌时跳过校验,向后兼容。

is_new 的判断(用于决定是否递增计数器)和 su_url:set 之间并不是原子操作,理论上存在竞态窗口。但这个窗口在实践中不会触发,因为 PHP 层的文件锁(lockBegin/lockEnd)保证了同一个 code 不会被并发写入。当第二个请求到达 internalSet 时,第一个已经完成了冷存储写入,第二个在 PHP 层的去重或冲突检查就会拦截。Lua 层的计数逻辑依赖 PHP 层的这个保证,这是一个显式记录的跨层约定,而不是遗漏。


四、计数的精度与漂移

系统维护 perm_counttemp_count 两个计数器用于配额控制。这两个计数器在三个地方被修改:internal_set.lua(创建时递增)、internal_delete.lua(删除时递减)、expire_sweep.lua(批量清理过期临时链时递减)。

临时链的计数存在一个已知的漂移窗口:su_url 使用原生 TTL,到期后由 OpenResty 自动回收;但计数器的递减要等到 expire_sweep 定时运行才触发。这个窗口最长是 su_expire_interval(默认 1 小时)。为了解决这个问题,su_exp 使用 TTL=0(永不自动过期),让 sweep 始终能找到条目并精确递减。

冷存储没有漂移问题,因为 countActive() 是无状态遍历,每次调用都实时计算,不依赖任何累积状态。这是 api/stat.php 以冷存储为权威来源的根本原因。热存储的计数可能有最长一个 sweep 周期的延迟,冷存储的计数永远精确。


五、锁的设计

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

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

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

Lua 层用 su_meta 里的 lock_sweep 标志防止 sweep 并发运行。这个锁带有 TTL(等于 su_expire_interval),如果持锁的 worker 崩溃,锁会在 TTL 到期后自动释放,不会产生永久死锁。

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


本页目录