Skip to content

Redis 完整实践:Lua 原子限额与可重试 CLI ​

本轮仅全文静态审计,未安装依赖、启动服务、连接数据库或执行任何示例。下文实测、RED/GREEN及平台结果均为此前历史证据;本轮修改后的脚本未运行,命令与输出作为学习步骤和预期保留。

需求与不变量 ​

前置:类型、TTL 与原子性。本例是用户操作次数限额:首次接受开启一个相对固定窗口,同一主体各请求消耗正整数 cost,总消耗不超过 limit。不是自然分钟对齐,也不是滑动窗口。Redis 的数据丢失或驱逐会重置额度,因此用于可接受偶发放宽的保护,不能作为收费凭证或不可重复扣款账本。

用一个 Hash m4:quota:{alice} 保存 used、policy 和 request:<id>。cost 必须在1到limit之间;limit最多10000,window毫秒在1到86400000。请求ID限64个ASCII字母、数字、下划线或连字符。因为每次接受至少消耗1,同一窗口最多10000条接受记录,避免无界增长;主体数仍需外部限制。该上限是教学约束,不是 Redis 限制。

返回数组 [accepted, usedAtAcceptance, replay]。接受过的ID与相同cost重试返回当时结果而不增加消耗;变更cost返回CONFLICT。同窗改变limit/window返回POLICY。拒绝请求不登记,所以拒绝不具备跨窗口永久幂等;窗口过期后同一ID是新请求。必须由调用者稳定保存请求ID。服务端脚本只由本应用写键;外部手动篡改Hash会破坏模式,生产需通过ACL和键所有权限制。

依赖与运行 ​

目录包含 quota.mjs、quota.lua、quota.test.mjs、verify.mjs,以及并发反例race-negative.mjs。反例也由verify调用,不能漏复制。Node.js >=22、Docker CLI、前页Redis容器即可;没有 npm 包或站点业务后端。Node使用标准库 execFile 参数数组调用同镜像官方 redis-cli -e --json,这是便于复现的CLI适配层;它每次创建进程,不适合高吞吐生产。生产客户端应选择连接池或复用连接、明确超时和错误类型,本例不自制RESP协议解析器。

在仓库根目录执行,PowerShell与Bash相同:

sh
node --version
node examples/redis/quota.mjs alice order-1 2 5 60000
node examples/redis/quota.mjs alice order-1 2 5 60000
node examples/redis/quota.mjs alice order-2 3 5 60000
node examples/redis/quota.mjs alice order-3 1 5 60000
node examples/redis/quota.mjs alice order-1 1 5 60000
node examples/redis/quota.mjs alice bad -1 5 60000

使用一个未运行过的主体,预期依次 [1,2,0]、[1,2,1]、[1,5,0]、[0,5,0];后两条非零退出,分别是CONFLICT和非法输入。再用同一主体重复实验可能已消耗额度,等待60秒或选新主体。拒绝额度返回正常数组,不是传输故障;调用者据此决定提示或退避。

默认容器 showdocs-m4-redis-main;可用环境变量 REDIS_CONTAINER 指定其他符合 showdocs-m4-redis- 前缀的隔离容器。PowerShell 用 $env:REDIS_CONTAINER='showdocs-m4-redis-main',Bash 用 export REDIS_CONTAINER=showdocs-m4-redis-main。不要把环境变量指向业务Redis。10秒执行超时只约束等待时间,不表示服务端一定没有执行。

完整源码:校验先于写入 ​

以下直接包含实际执行的文件;修改示例后测试与正文引用同一份源码。EVAL每次传完整脚本,避免读者首次就处理NOSCRIPT;生产可用EVALSHA并在NOSCRIPT时重载,但超时重试仍必须保持相同请求ID。

lua
-- One short-lived hash per subject. Validation precedes every write.
local cost, limit, window = tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
if not cost or not limit or not window or cost % 1 ~= 0 or limit % 1 ~= 0 or window % 1 ~= 0
  or cost < 1 or limit < 1 or limit > 10000 or cost > limit or window < 1 or window > 86400000 then
  return redis.error_reply('INVALID integer cost/limit/window')
end
if not string.match(ARGV[1], '^[%w_-]+$') or string.len(ARGV[1]) > 64 then
  return redis.error_reply('INVALID request id')
end
local kind = redis.call('TYPE', KEYS[1]).ok
if kind ~= 'none' and kind ~= 'hash' then return redis.error_reply('WRONGTYPE quota requires hash') end
local policy = tostring(limit) .. ':' .. tostring(window)
local oldpolicy = redis.call('HGET', KEYS[1], 'policy')
if oldpolicy and oldpolicy ~= policy then return redis.error_reply('POLICY changed within window') end
local field = 'request:' .. ARGV[1]
local previous = redis.call('HGET', KEYS[1], field)
if previous then
  local oldcost, used = string.match(previous, '^(%d+):(%d+)$')
  if tonumber(oldcost) ~= cost then return redis.error_reply('CONFLICT request cost changed') end
  return {1, tonumber(used), 1}
end
local used = tonumber(redis.call('HGET', KEYS[1], 'used') or '0')
if used + cost > limit then return {0, used, 0} end
used = used + cost
redis.call('HSET', KEYS[1], 'policy', policy, 'used', used, field, tostring(cost) .. ':' .. tostring(used))
if not oldpolicy then redis.call('PEXPIRE', KEYS[1], window) end
return {1, used, 0}
javascript
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {readFileSync} from 'node:fs';
import {pathToFileURL} from 'node:url';
const exec=promisify(execFile);
const script=readFileSync(new URL('./quota.lua',import.meta.url),'utf8');
export async function command(...args){
 const container=process.env.REDIS_CONTAINER||'showdocs-m4-redis-main';
 if(!/^showdocs-m4-redis-[a-z0-9-]+$/.test(container))throw new Error('isolated container required');
 const {stdout}=await exec('docker',['exec',container,'redis-cli','-e','--json',...args.map(String)],{timeout:10000,maxBuffer:1024*1024});
 return JSON.parse(stdout);
}
export async function quota(key,id,cost,limit,window){
 if(!/^m4:quota:\{[a-zA-Z0-9_-]+\}$/.test(key))throw new Error('invalid namespaced key');
 if(typeof id!=='string'||!/^[a-zA-Z0-9_-]{1,64}$/.test(id))throw new Error('invalid request id');
 if(![cost,limit,window].every(Number.isSafeInteger)||cost<1||limit<1||limit>10000||cost>limit||window<1||window>86400000)throw new Error('invalid integer cost/limit/window');
 return command('EVAL',script,1,key,id,cost,limit,window);
}
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){
 const [subject,id,cost='1',limit='5',window='60000']=process.argv.slice(2);
 try {
  if(!subject||!id)throw new Error('usage: quota.mjs subject request-id [cost limit window-ms]');
  console.log(JSON.stringify(await quota(`m4:quota:{${subject}}`,id,Number(cost),Number(limit),Number(window))));
 }
 catch(error){console.error(error.message);process.exitCode=1;}
}

验证顺序是参数→键类型→策略→接受记录→剩余额度→写入→首次过期。写入与首次过期仍是两个内部调用,脚本原子保证其他客户端看不到中间态;如果写入后发生运行错误,并不会回滚。预先校验减少常见失败,不能把OOM或服务故障变成持久事务保证。一个Hash把关联数据放在同一键上,同时让整个窗口一次到期。重试不调用PEXPIRE,避免把窗口拖成无限延长。

完整测试与失败路径 ​

sh
node --test examples/redis/quota.test.mjs
node examples/redis/verify.mjs
javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { quota, command } from './quota.mjs';
import {readFileSync} from 'node:fs';

const key=()=>`m4:quota:{${randomUUID()}}`;
test('reject invalid input without creating a key',async()=>{
 const k=key();
 for(const cost of [-1,0,1.5,NaN])await assert.rejects(quota(k,'r',cost,5,1000));
 assert.equal(await command('EXISTS',k),0);
 const script=readFileSync(new URL('./quota.lua',import.meta.url),'utf8');
 await assert.rejects(command('EVAL',script,1,k,'r',-1,5,1000),/INVALID/);
 assert.equal(await command('EXISTS',k),0);
});
test('accepted retry returns original result; conflicting retry fails',async()=>{
 const k=key();
 assert.deepEqual(await quota(k,'a',2,5,5000),[1,2,0]);
 assert.deepEqual(await quota(k,'b',1,5,5000),[1,3,0]);
 assert.deepEqual(await quota(k,'a',2,5,5000),[1,2,1]);
 await assert.rejects(quota(k,'a',1,5,5000),/CONFLICT/);
 await assert.rejects(quota(k,'c',1,6,5000),/POLICY/);
});
test('missing or non-string request IDs must fail before writes',async()=>{
 const k=key();
 await assert.rejects(quota(k,undefined,1,5,1000));
 await assert.rejects(quota(k,['r'],1,5,1000));
 assert.equal(await command('EXISTS',k),0);
});
test('concurrent callers never exceed limit and denial does not increment',async()=>{
 const k=key();
 const replies=await Promise.all(Array.from({length:20},(_,i)=>quota(k,`r${i}`,1,5,30000)));
 assert.equal(replies.filter(r=>r[0]===1).length,5);
 assert.equal(await command('HGET',k,'used'),'5');
});
test('expiry is not extended by retries and a new window starts',async()=>{
 const k=key();
 await quota(k,'a',1,2,1800);
 const first=await command('PTTL',k);
 await new Promise(r=>setTimeout(r,150));
 await quota(k,'a',1,2,1800);
 const second=await command('PTTL',k);
 assert.ok(second>0&&second<first);
 await new Promise(r=>setTimeout(r,1850));
 assert.equal(await command('PTTL',k),-2);
 assert.deepEqual(await quota(k,'a',1,2,1800),[1,1,0]);
});
test('wrong type and invalid server arguments leave existing value intact',async()=>{
 const k=key();await command('SET',k,'keep');
 await assert.rejects(quota(k,'a',1,2,1000),/WRONGTYPE/);
 assert.equal(await command('GET',k),'keep');
});
test('script runtime failure does not roll back prior writes',async()=>{
 const k=key();
 await assert.rejects(command('EVAL',"redis.call('SET',KEYS[1],'written'); return redis.call('INCR',KEYS[1])",1,k),/integer/);
 assert.equal(await command('GET',k),'written');
});

原有7项服务测试的预期是全部通过且无skip(本轮未执行):负数/零/小数/NaN不建键、接受重放和冲突、缺失或非字符串ID拒绝、20个并发请求只接受5个、重试不续期与过期重开、WRONGTYPE不覆盖原数据、脚本先写后错仍留值。测试使用UUID键;不会FLUSHALL或清理其他数据。verify另验证CLI非法参数退出1、各类型、TTL、AOF重启和独立RDB恢复。完整运行证据位于仓库 migration/knowledge-rebuild/m4/redis/results.json。

测试起点曾因缺少quota.mjs失败(RED);实现后实际服务通过(GREEN)。后续改动先调整行为断言,再运行;单线程成功不能替代并发断言。网络故障返回非零不等于限额拒绝:服务不可用时调用者应明确fail-open或fail-closed策略,不能悄悄返回“成功”。

练习与参考解法 ​

提示:分别区分请求身份、接受时用量与当前用量,先考虑记录还在不在原窗口。

题目1:将limit改为3,20个并发cost=1应接受多少?题目2:第一次接受后应用丢失响应,用新ID重试会怎样?题目3:为何不能给返回结果中的used直接标成“当前已用”?

参考解法
  1. 恰好3个,其他拒绝,HGET used=3。测试主体要用新UUID。2. 新ID会被当新操作再次扣额度;使用原ID才可重放,仍仅在窗口和数据存续期间。3. 重放返回首次接受时的used,后来其他请求可能已消耗额度;字段语义是usedAtAcceptance。若需要当前值,应单独读,且不要把额外读取与原操作合称一个原子快照。

继续阅读