用 Redis Lua 重写对象缓存批量操作:把 N 次往返压成 1 次,顺便拿到原子性
本文是我们自研对象缓存后端(Redis / APCu 双驱动)的第三块拼图:前两篇讲了 dropin 的静默失败与核心契约,这篇讲 Redis 驱动里最具体的一段实现——用 Lua 脚本做批量操作。素材都是线上跑着的代码,删掉了业务类,只留能说明问题的片段。
有一句话先说在前面:本文不提供任何性能数字。我们没有做过压测,所有收益只用「往返次数从 N 次降为 1 次」这类结构性描述,具体能快多少需要你自己压测验证。
引子:对象缓存有两处天生别扭
对象缓存的调用模式是「一次页面渲染里几十到几百次 wp_cache_get()」。核心为此提供了 get_multiple() / set_multiple() / add_multiple() / delete_multiple() 这类批量 API。问题在于:核心的默认实现,就是循环调用单键方法。如果驱动层不实现批量,N 个键就是 N 次网络往返。
第二处别扭是 incr()。它的语义不是「让 Redis 加一」,而是「读出现值 → 加上 offset → 写回」。这是一个典型的读改写(read-modify-write)序列,天然需要三步:GET、计算、SET。三步意味着三次往返,更意味着中间没有任何锁:
- 请求 A 读到 100,请求 B 也读到 100;
- A 算出 101 写回,B 算出 101 也写回;
- 正确结果应该是 102,实际是 101。丢了一次更新,而且谁都不会报错。
批量是往返次数的问题,incr 是原子性的问题。Lua 脚本一次把两个都解决掉。
为什么必须是脚本:管道、事务都差一口气
很多人第一反应是「用管道(pipeline)不就行了」,或者「用 MULTI/EXEC 包起来」。都不行,而且原因不同,值得分清楚:
| 方案 | 省往返 | 原子性 | 能读中间结果 |
|---|---|---|---|
| 管道 pipeline | 是 | 否 | 是 |
| 事务 MULTI/EXEC | 基本是 | 是(连续执行) | 否 |
| Lua 脚本 | 是 | 是 | 是 |
- 管道只是把多条命令打包成一次发送,省的是网络往返。命令之间仍然可能插入其它客户端的命令,所以
GET和后面的SET之间,值可能已经被别人改了。 - 事务保证这批命令连续执行、不被打断,但你在事务里读不到中间结果——
GET的返回值要等EXEC才拿到,无法「先读、根据读到的值算、再写」。WATCH可以做乐观锁,但需要重试循环,而且批量场景下几十个键一起WATCH,基本不可行。 - Lua 脚本在服务端原子执行整段逻辑,脚本内部可以读值、计算、写回,整个过程一次往返。
两个收益都拿到,代价是两条约束:
- 脚本必须是确定性的。不能调用
TIME、SRANDMEMBER、随机键访问这类非确定性命令,否则主从复制与 AOF 重放会产生不一致。我们的脚本只用了GET/SET/TTL/MGET/UNLINK/DEL。 - 脚本是单线程执行的。Redis 主线程一次只跑一个脚本,脚本慢就等于整个实例被阻塞。所以脚本里不能干重活:不扫大键空间、不做几十万次循环、不做复杂字符串拼接。
先交代两件事:值长什么样,键长什么样
后面所有脚本都建立在这两个前提上,不交代清楚没法看代码。
值:Redis 里存的是 PHP 序列化字符串,前面还挂了一个 4 字节自定义标记,形如 \x00iz\x00 + serialize($value)。这个标记的作用是区分「本驱动写入的值」和「同一个 Redis 库里其它应用写入的值」——共享实例是很常见的部署形态,不能假设库里只有你自己的数据。
键:形如 {站点标识}_{SALT}_ms_{blog_id}_{group}_{key}(多站点)或 {站点标识}_{SALT}_global_{group}_{key}(全局组)。站点标识由数据库名、用户、主机、站点根路径等拼接后取哈希得到,不依赖 HTTP_HOST(多站点下域名会变,用它做标识会让同一份数据存进两个命名空间)。
这两个「长什么样」直接影响脚本:值带标记,所以自增时要能解析 PHP 序列化格式;键带前缀,所以批量构建键时必须复用单键的前缀逻辑,不能另写一份。
五个脚本:get、set、add、delete、incr
get:一行解决
return redis.call('MGET', unpack(KEYS))
没别的。MGET 一次取回整批,未命中的键 Redis 返回 nil bulk,PHP 侧(phpredis)拿到的是 false,据此区分命中与未命中。
唯一要注意的是 unpack(KEYS):键数太多会触发 Lua 栈错误(”too many results to unpack”),所以必须分批——见后面「分批」一节。
set / add:差别只有一个 NX
-- set
local ttl = ARGV[#ARGV]
local expire = tonumber(ttl)
local res = {}
for i = 1, #KEYS do
if expire > 0 then
redis.call('SET', KEYS[i], ARGV[i], 'EX', ttl)
else
redis.call('SET', KEYS[i], ARGV[i])
end
res[i] = 1
end
return res
-- add:键已存在则失败
for i = 1, #KEYS do
local ok
if expire > 0 then
ok = redis.call('SET', KEYS[i], ARGV[i], 'NX', 'EX', ttl)
else
ok = redis.call('SET', KEYS[i], ARGV[i], 'NX')
end
res[i] = ok and 1 or 0
end
return res
两点约定值得说明:
- ARGV 末位是 TTL 秒数(0 表示不过期),前面
#KEYS个 ARGV 与 KEYS 一一对应。这个约定让「一批写入共用一个 TTL」成为可能,省掉 N 个参数,也让脚本体保持简单。 add用NX,是为了与 APCu 侧apcu_add()的「键已存在则失败」语义完全一致。两个后端行为必须对齐,否则上层业务在换后端后表现不同——这类 bug 极难定位,因为它只在特定部署下出现。
delete:优先 UNLINK,优雅降级 DEL
local res = {}
local use_unlink = true
for i = 1, #KEYS do
local removed
if use_unlink then
local ok, n = pcall(function() return redis.call('UNLINK', KEYS[i]) end)
if ok then
removed = n
else
use_unlink = false
end
end
if not use_unlink then
removed = redis.call('DEL', KEYS[i])
end
res[i] = removed
end
return res
DEL 是同步回收内存,删一个大键(比如几百 KB 的聚合缓存)会阻塞主线程;UNLINK 只是把键从键空间摘掉,真正的回收交给后台线程。老版本 Redis 没有 UNLINK,所以用 pcall 探测。
这里有个细节值得学:一旦探测失败就把 use_unlink 置 false,整批都走 DEL,而不是每个键都做一次 pcall 判断。命令支持与否是实例级别的事实,不是键级别的事实,没必要重复探测。PHP 侧的批量删除同样遵循「优先 unlink,失败回落 del」,并按 500 分批。
最有料的一个:原子自增脚本
这是五个脚本里唯一一个「非它不可」的——前面四个换成管道或多条命令,损失的只是往返次数;incr 换成非脚本实现,损失的是正确性。先给完整脚本:
local key = KEYS[1]
local offset = tonumber(ARGV[1])
local mark = ARGV[2]
local raw = redis.call('GET', key)
if not raw then
return nil
end
local num = nil
local as_float = false
if #mark > 0 and string.sub(raw, 1, #mark) == mark then
local body = string.sub(raw, #mark + 1)
local int_form = string.match(body, '^i:(%-?%d+);$')
if int_form then
num = tonumber(int_form)
else
local dbl = string.match(body, '^d:(%-?[%d%.eE%+%-]+);$')
if dbl then
num = tonumber(dbl)
as_float = true
else
-- PHP 序列化字符串数字 s:N:"123";
-- 不解析会落到 PHP 侧读改写兜底:三次往返、非原子、并发丢更新
num = tonumber(string.match(body, '^s:%d+:"(%-?%d+)";$'))
end
end
else
num = tonumber(raw)
end
if num == nil then
return nil
end
local new = num + offset
local floor = math.floor(new)
local value
if not as_float and new == floor then
value = mark .. 'i:' .. string.format('%d', floor) .. ';'
else
value = mark .. 'd:' .. tostring(new) .. ';'
end
local ttl = redis.call('TTL', key)
if ttl and ttl > 0 then
redis.call('SET', key, value, 'EX', ttl)
else
redis.call('SET', key, value)
end
return value
难点一:值不是裸数字,是 PHP 序列化字符串
Redis 原生 INCRBY 只能处理裸整数字符串,对 i:123; 这种格式无能为力——它会直接报错。所以要在脚本里自己解析 PHP 的序列化格式。三种形态:
| 形态 | 例子 | 匹配模式 |
|---|---|---|
| 整数 | i:123; | ^i:(%-?%d+);$ |
| 浮点 | d:1.5; | ^d:(%-?[%d%.eE%+%-]+);$ |
| 字符串数字 | s:5:"12345"; | ^s:%d+:"(%-?%d+)";$ |
第三项是我们后来补上的。一开始只处理了整数和浮点,结果 WordPress 里那些以字符串形式存进去的数字(不少插件会 wp_cache_set($k, '123'))全部解析失败,静默回退到 PHP 侧的「GET + 计算 + SET」兜底。
这个兜底为什么不报错?因为它在代码里是一条完全合法的路径:读不到就自己算一遍再写回去,功能上「是对的」。但它从 1 次往返变成 3 次,并且非原子——并发下丢更新。这类「不报错但悄悄降级」的问题是最难查的一类:日志干净、功能正常,只有并发量上来之后计数才对不上。
回写时还有个容易忽略的点:尽量保持整数形态。new == floor 就写 i: 而不是 d:,否则一个整数计数器自增几次之后变成浮点,PHP 侧取出来类型就漂了,下游 === 比较会莫名其妙失败。
难点二:键不存在时返回 nil,而不是凭空创建
Redis 原生 INCRBY 对不存在的键会当作 0 再加,返回 offset。但核心 WP_Object_Cache::incr() 的语义是「键不存在返回 false」,APCu 侧的 apcu_inc() 也是失败。
两个后端必须对齐核心语义,而不是对齐 Redis 原生行为。所以脚本在 not raw 时直接返回 nil,让 PHP 侧翻译成 false。如果照抄 Redis 的行为,上层「先 set 再 incr」与「直接 incr」的逻辑分支就会在不同后端表现不一致。
难点三:自增不能丢掉原来的 TTL
SET key value 不带 EX 会清掉过期时间。一个本该 60 秒过期的计数器,自增一次之后变成永不过期——这个 bug 极隐蔽,因为功能「看起来完全正常」,只是内存里慢慢堆积了一批再也不会消失的键,要等到内存告警那天才会被注意到。
脚本的做法是先 TTL 读出来,大于 0 就 SET ... EX ttl 写回。这一步放在脚本内是「免费」的(不增加往返);放在 PHP 侧就得多一次 TTL 往返,而且读和写之间又撕开一个窗口。
顺带一个收益:脚本最后
return value直接返回新值字符串,PHP 侧反序列化后就能返回给调用方,省掉了「自增完再 GET 一次」的往返。
编排层:EVALSHA、NOSCRIPT 与通道熔断
脚本写好了,怎么调用同样有讲究。下面是编排层的实现:
private function eval_lua(string $name, array $keys, array $args = []): mixed {
if ($this->lua_disabled || !$this->redis instanceof \Redis) {
return false;
}
$script = self::lua_script($name);
if ($script === '') {
return false;
}
$payload = $args === [] ? $keys : array_merge($keys, $args);
$num_keys = count($keys);
$sha = self::$lua_sha[$name] ?? null;
if ($sha !== null) {
try {
$result = $this->redis->evalSha($sha, $payload, $num_keys);
if ($result !== false) {
return $result;
}
} catch (\Throwable $e) {
// 仅 NOSCRIPT(脚本缓存丢失 / 服务端重启)才重新加载,其它错误直接回退
if (stripos($e->getMessage(), 'NOSCRIPT') === false) {
$this->disable_lua($e);
return false;
}
}
unset(self::$lua_sha[$name]);
}
try {
$result = $this->redis->eval($script, $payload, $num_keys);
} catch (\Throwable $e) {
$this->disable_lua($e);
return false;
}
if ($result === false) {
// 服务端禁用脚本等场景:本次请求内不再重复尝试
$this->lua_disabled = true;
return false;
}
self::$lua_sha[$name] = sha1($script);
return $result;
}
- 用
EVALSHA而不是每次EVAL:避免每个请求都把整段脚本正文传过去。SHA 在 PHP 侧用sha1($script)算,不需要先SCRIPT LOAD——首次用EVAL执行成功后再把 SHA 缓存进静态数组即可。 NOSCRIPT的判断必须精确。如果笼统地把所有异常都当成「脚本缓存没了」去重新EVAL,那么在服务端禁用脚本、Lua 运行时报错这类场景下,每个请求都会多做一次注定失败的EVAL。只在错误信息含NOSCRIPT时才重载。- 通道熔断。一旦确认脚本不可用,
lua_disabled = true,本次请求内后续调用直接返回false,由业务侧走逐键命令。错误只记一次日志——否则一个高流量站点能把磁盘写满。 - 所有调用点都必须处理
eval_lua()返回false。返回值是false不是「查不到」,而是「脚本这条路走不通」,语义完全不同,不能混为一谈。
分批:键数与字节的双阈值
// 单个 Lua 脚本一次处理的键数量上限(避免 Lua 栈溢出与超大参数表)
private const LUA_MAX_KEYS = 500;
// 单个 Lua 脚本一次处理的键名 + 值总字节上限,超过即分批
private const LUA_MAX_BYTES = 1048576;
private function chunk_batch(array $storage_keys, array $values): array {
$chunks = [];
$index = [];
$keys = [];
$vals = [];
$bytes = 0;
foreach ($storage_keys as $position => $storage_key) {
$value = $values[$position] ?? '';
$size = strlen($storage_key) + strlen($value);
if ($keys !== [] && (count($keys) >= self::LUA_MAX_KEYS || $bytes + $size > self::LUA_MAX_BYTES)) {
$chunks[] = ['index' => $index, 'keys' => $keys, 'values' => $vals];
$index = [];
$keys = [];
$vals = [];
$bytes = 0;
}
$index[] = $position;
$keys[] = $storage_key;
$vals[] = $value;
$bytes += $size;
}
if ($keys !== []) {
$chunks[] = ['index' => $index, 'keys' => $keys, 'values' => $vals];
}
return $chunks;
}
为什么两个阈值都要有:
- 只按键数分:遇到「100 个键、每个值 200KB」,单条命令体照样会被撑到几十 MB,写进去的时候整条链路都在等。
- 只按字节分:遇到「十万个短键」,
unpack(KEYS)会直接报 “too many results to unpack”——这是实战里最容易踩的一个,因为它在小数据量下完全不会暴露。
$index 是关键:批次里必须保留每个键在原始数组中的下标,脚本的返回值才能正确回填到调用方传入的 $data。脚本返回值按 KEYS 顺序对齐,PHP 侧用 $position++ 逐个取,不要假设顺序。
只读类操作(如 get)用 array_chunk($keys, 500, true) 保留键名就够了;读写类(set / add)要用上面这个 chunk_batch(),因为它要同时搬运键、值和下标三份数据。
分批改不了一个事实:批与批之间不再原子。批量写入不是事务,跨批完全可能「前 500 个成功、后 500 个失败」。这是下一节降级策略必须存在的根本原因。
降级:为什么不能返回「部分成功」
以 get_multiple 为例(已精简掉与主题无关的部分):
public function get_multiple(array $keys, string $group = 'default', bool $force = false): array {
$keys = array_values($keys);
if ($keys === [] || $this->is_non_persistent($group) || !$this->enabled || !$this->connect()) {
return parent::get_multiple($keys, $group, $force);
}
$values = array_fill_keys($keys, false);
$storage_keys = $this->build_key_batch($keys, $group);
// 先吃掉请求内本地副本,只把未命中的键发往后端
$missing = [];
foreach ($storage_keys as $index => $storage_key) {
$found = false;
$cached = $this->local_read($storage_key, $force, $found);
if ($found) {
$values[$keys[$index]] = $cached instanceof \stdClass ? clone $cached : $cached;
continue;
}
$missing[$index] = $storage_key;
}
if ($missing === []) {
return $values;
}
foreach (array_chunk($missing, self::LUA_MAX_KEYS, true) as $chunk) {
$raw_values = $this->eval_lua('get', array_values($chunk));
if (!is_array($raw_values)) {
// 脚本不可用:整体回退到逐键读取,绝不返回「半批脚本 + 半批失败」
return parent::get_multiple($keys, $group, $force);
}
// ...按 $position 回填,并把命中的值写入请求内副本
}
return $values;
}
- 契约优先。批量接口必须返回「包含全部请求键、未命中为
false」的数组。某个批次脚本失败时,正确做法是整体回退到逐键实现,而不是把已成功的部分和失败的部分拼起来返回——调用方无法判断某个键到底是「真的未命中」还是「压根没查」。 - 批量也要吃本地副本。同一请求内读过的键不必再发往后端。这不属于 Lua 的内容,但它是「批量接口真正省下往返」的前提:否则一次
get_multiple(200 个键)里可能有 150 个是刚读过的,脚本再快也白搭。 - 键的批量构建要复用单键逻辑。
build_key_batch()必须复用单键构建的前缀逻辑,避免两处实现漂移;并且内部要处理多站点下switch_to_blog()之后前缀变化的情况。
对照组:APCu 上没有 Lua,同样语义怎么实现
我们的另一个驱动是 APCu。它没有脚本能力,同一套语义只能换一种方式实现。对比一遍,反而能看清 Redis 侧哪些设计是为了「对齐语义」而不是为了「用脚本」:
| 操作 | Redis(Lua) | APCu |
|---|---|---|
add | SET NX,脚本批量 | apcu_add(),原生原子 |
replace | SET XX,原子 | 没有原生原子 replace(apcu_cas 只支持整数比较交换),只能 exists + store,有极小竞态窗口 |
incr | Lua 脚本原子,保留 TTL | apcu_inc(),但只对整型存储值有效;字符串数字要读改写兜底,且 TTL 要自己回写 |
get_multiple | Lua MGET,一次往返 | apcu_fetch(array $keys) 数组形式,一次调用取回整批 |
delete | UNLINK 回落 DEL | apcu_delete() |
| 按前缀清空 | SCAN + 批量删 | APCUIterator + 逐键删(APCu 没有命名空间,这是能力限制,无解) |
几个展开的点:
apcu_fetch()传数组时只返回命中的条目,所以要用array_key_exists判断某个键是否命中,不能用isset——值本身可能就是null。返回值不是数组说明该形式不可用,同样要回退逐键。- APCu 没有「取单个键剩余 TTL」的 API。自增后要保留 TTL,只能用
APCUIterator传一个精确匹配的正则迭代一次拿到。别为了一条元数据去apcu_cache_info()导出整张表,那代价大得多。 - 「两个后端行为必须一致」是贯穿性约束。
apcu_inc失败要兜底成与 Redis 一样能处理字符串数字;incr对不存在的键,两个后端都要返回false(而不是 Redis 原生的「凭空创建」)。
有意思的是,APCu 在 add 和 get_multiple 上反而更省事——它有原生原子语义。真正麻烦的是 replace 和「按前缀清空」:前者只能接受一个极小的竞态窗口,后者是能力限制,只能逐键删。
我们踩过的坑
- Lua 里漏解析 PHP 序列化的字符串数字形态 → 静默降级到非原子的 PHP 侧兜底,不报错,但并发下丢更新。
unpack(KEYS)键数过大炸栈 → 必须按数量分批。- 只按数量分批、没按字节分批 → 大值场景下单条命令体过大。
EVALSHA的异常没区分NOSCRIPT→ 服务端禁用脚本时每个请求都多做一次注定失败的EVAL。- 批量结果回填时丢了原始下标 → 返回值对不上调用方的键。
- 脚本内用了非确定性命令(我们的实现里没有,但值得作为检查项留着)。
- 忘记在脚本内保留 TTL → 自增后计数器永不过期。这个 bug 极隐蔽,因为功能「看起来正常」。
收尾:写 Redis Lua 脚本的检查清单
- 只用确定性命令:禁
TIME、SRANDMEMBER、随机键访问、依赖过期时间的读取。 - 键全部走
KEYS,参数全部走ARGV,不要在脚本内拼键名。 - 参数表长度设上限,
unpack会炸;键数与字节双阈值分批。 - 脚本内不做重活:不
SCAN大空间、不做几十万次循环——慢脚本阻塞整个实例。 - 明确返回值语义:区分「未命中 nil」与「失败 false」;批量返回值按
KEYS顺序对齐。 - 语义对齐业务契约,而不是照抄 Redis 原生命令行为(如「键不存在时
incr应失败」)。 - 写回时保留 TTL:
TTL大于 0 才带EX。 - 保持值的类型形态:整数写
i:,不要一律转成d:。 EVALSHA+ 精确NOSCRIPT判定 + 熔断;失败只记一次日志。- 降级路径必须整体回退,绝不返回「部分成功」;并且要有一份不含脚本的等价实现可退。
最后一句实话:Lua 脚本不是银弹。它换来的是「往返次数」与「原子性」两件事,换来这两件事的代价是可观测性变差(脚本里的错误要么靠返回值要么靠日志)、以及一个必须长期维护的降级路径。如果一批操作里根本没有读改写,管道往往就够了。真正非脚本不可的,只有 incr 这类读改写场景。
相关阅读
- WordPress 对象缓存 dropin 出错时为什么「静默」:一次 502 零日志事故的复盘
- 写 WordPress 对象缓存 dropin 前,先对齐这 11 处核心契约
- 对象缓存 dropin 的原子性、性能与安全边界
参考文献
- Redis Ltd. Redis Lua scripting / EVAL[EB/OL]. https://redis.io/docs/latest/develop/interact/programmability/eval-intro/ (访问日期: 2026-10-12).
- Redis Ltd. SCRIPT LOAD / EVALSHA[EB/OL]. https://redis.io/docs/latest/commands/evalsha/ (访问日期: 2026-10-12).
- PHP.net. phpredis: eval / evalSha[EB/OL]. https://github.com/phpredis/phpredis (访问日期: 2026-10-12).
- PHP.net. APCu 扩展[EB/OL]. https://www.php.net/manual/en/book.apcu.php (访问日期: 2026-10-12).
- WordPress.org. WP_Object_Cache class reference[EB/OL]. https://developer.wordpress.org/reference/classes/wp_object_cache/ (访问日期: 2026-10-12).