对于超大列表,直接使用LRANGE很容易拖慢Redis,因为它的时间复杂度是 O(N)。当列表达到几十万条数据时,单次调用往往会消耗上百毫秒,进而阻塞Redis单线程,导致其他命令持续排队;尤其是执行 LRANGE 0 -1 这种全量读取时,风险会更高。更稳妥的做法,是使用lindex按批次读取,或通过SCAN进行分片删除。

为什么大列表直接用 LRANGE 会卡住 Redis?
原因其实很直接:LRANGE 的时间复杂度本身就是 O(N)。如果一个列表中有几十万条记录,那么一次执行很可能就会占用上百毫秒。由于 Redis 采用单线程模型,这段时间内其他命令都只能等待执行,因此你可能会看到 ERR BUSY Redis is busy running a script 这样的报错,或者在监控中发现 used_cpu_sys 指标突然升高,很多时候都是大范围读取造成的。
更需要警惕的是,很多开发者习惯写 LRANGE key 0 -1 来“全部取出”,但这种写法不管列表长度是多少,都会一次性读取完整数据,本质上就是人为制造高风险阻塞点。
- 不要以为“只是读操作”就一定安全——读取同样会大量消耗 CPU
LTRIM key 100 -1虽然是 O(1),但前提是前面的LRANGE没有先把 Redis 卡住- 客户端做分页拉取再逐条
LPOP,会增加网络往返次数和锁持有时间,实际性能往往更差
EVALSHA 必须配 fallback,否则重启后脚本失效
在生产环境中,不能只依赖 EVAL 来执行 Lua 脚本,因为每次调用都需要重新解析和编译脚本,在高频场景下会带来明显额外开销。更推荐的方式是:首次使用 EVAL 注册脚本,获取对应的 SHA 值(例如 "4e251b7a876c17b4f1a773818e843d2a2a9c8d9e"),之后统一通过 EVALSHA 调用。
不过要注意,Redis 重启之后,脚本 SHA 缓存会被清空,此时继续调用 EVALSHA 会返回 nil,而不是直接抛错。如果业务代码没有做好判空处理,并在失败后 fallback 到 EVAL,就很容易出现静默失效的问题。
- fallback 逻辑必须具备,而且要保证幂等性,重复注册 SHA 也不能影响业务
- 不要把 Lua 脚本内容直接拼接在业务代码里,建议在启动阶段预加载,或从配置中心统一拉取
EVALSHA的参数顺序是:EVALSHA[ ...] [ ...]
分批消费大列表:用 lindex 替代全量 LRANGE
如果业务只需要读取前 N 条并立即删除,那么使用 LRANGE key 0 N-1 + LTRIM key N -1 通常是安全的,但前提是 N 必须足够小,比如不超过 100。一旦 N 会动态放大,例如根据页码计算偏移量,风险就会迅速增加。
更稳定、可控的做法,是改为使用 lindex 逐个分批读取,避免一次性把整段数据加载到内存中:
local items = {}
for i = 0, 99 do
local v = redis.call('lindex', KEYS[1], i)
if not v then break end
table.insert(items, v)
end
redis.call('ltrim', KEYS[1], #items, -1)
return items
lindex单次查询是 O(1),即使循环 100 次也只是 O(100),虽然相比LRANGE 0 99略慢,但执行风险更低、可控性更强- 当列表为空或 key 不存在时,
lindex会返回nil,必须显式break,否则脚本逻辑可能出现异常甚至陷入死循环 - 这种模式更适合“小批量、稳定地消费列表”,并不适合一次拉取几万条大数据
清空海量 key:别用 KEYS,改用 SCAN + 分片删除
如果使用 KEYS pattern 先扫描再删除,那么在百万级 key 场景下,Redis 很可能会被阻塞数秒。原因在于它会一次性遍历整个数据库,执行过程缺乏可控性,线上环境风险非常高。
正确方式应该是改用 SCAN 的游标分页机制,并结合 COUNT 控制每次扫描数量:
local cursor = 0
local deleted = 0
repeat
local res = redis.call('scan', cursor, 'match', ARGV[1], 'count', ARGV[2])
cursor = tonumber(res[1])
local keys = res[2]
for i, k in ipairs(keys) do
redis.call('del', k)
deleted = deleted + 1
end
until cursor == 0
return deleted
ARGV[2]通常建议设置在 100~500 之间,太小会导致扫描轮次过多,太大又可能引发性能抖动- 如果脚本中没有加
replicate_commands(),那么在 AOF/RDB 或主从复制场景下,可能会存在数据丢失风险 - 当 key 为空或 pattern 没有匹配结果时,
res[2]返回的是空表{},此时#keys为 0,不会报错
redis.call('lrange', 'missing', 0, 99) 这样读取不存在的 key 时,会返回 {},通常不会出问题;而 redis.call('lindex', 'missing', 0) 返回的却是 nil。这个返回差异如果没有做好判断,就很容易让循环逻辑失控。