Redis 定长队列的探索和实践:用 List 与 Lua 搭一个按游戏分组的轻量消息模型

慢慢来
慢慢来
发布时间:2026-08-24| 145人浏览过
原创

前言

当业务要求按游戏维度聚合上报、支持单次 128 条批量处理,还要把数据时效压在 3 分钟内时,常规 MQ 往往只能解决“先收消息”,却解决不了“如何分组、何时批量、何时丢旧数据”。本文围绕这组约束,拆解 Redis List、Set 和 Lua 脚本如何拼出一个定长、可批量消费的轻量方案,并结合线上 QPS 与适用边界,帮助你判断它到底该用在什么场景。

作者:vivo 互联网服务器团队 / Wang Zhi

在常规的生产消费模型里,跨进程解耦通常会先想到 RocketMQ 或 Kafka,但一旦叠加“按游戏维度聚合”“单次最多批量上报 128 个行为”“只处理前 3 分钟内数据”这些约束,标准 MQ 并不能直接把问题解决完。这篇文章把原始方案重新拆开,说明为什么最终选择用 Redis List + Set + Lua 脚本实现一个“定长、可批量消费、允许丢弃旧数据”的伪消息中间件,以及这套方案到底适合哪些业务场景。

业务场景先定边界:这不是通用消息队列问题

技术选型首先要服从业务目标。在这里,系统需要按游戏维度收集和上报行为数据,而且不是无限保留,而是执行“尽最大努力交付”,允许少量数据丢弃。

具体约束主要有三条:

  • 按游戏维度进行批量上报;
  • 同一款游戏支持 128 个行为批量上报;
  • 上报数据必须控制在上报时刻前 3 分钟内。

这意味着,系统关心的不只是“把消息送到消费者”,还包括:

  • 消息必须先按游戏分组;
  • 消费时要能按组批量读取;
  • 旧数据过期后可以被覆盖或丢弃;
  • 消费速度要尽量持续快于生产速度。

为什么没有直接上 MQ,而是转向 Redis

方案一:RocketMQ / Kafka 只解决了中转,没有解决分组与时效

如果采用 RocketMQ 或 Kafka 来存储上报消息,消息本身可以可靠进入队列,但消费侧仍然要在业务进程里自行处理以下问题:

  • 按游戏维度拆分消息;
  • 在满足批量性的前提下组织上报;
  • 在满足时效性的前提下触发消费。

也就是说,MQ 在这里更像是“消息中转站”,并没有直接解决业务场景中的核心限制。

方案二:Redis 定长队列更贴近目标

最终采用的做法,是基于 Redis 的基础数据结构拼出一套轻量消息模型:

  1. 用 List 为每个游戏保存行为消息,形成按游戏拆分的定长队列;
  2. 用另一个 List 保存“当前存在待消费数据的游戏列表”;
  3. 用 Set 做去重,避免同一游戏被重复放入游戏列表。

在这个设计里,按游戏分组由 key 天然完成,批量消费由 LRANGE 提供,时效性则通过业务侧多线程消费和过期控制共同保证。对于高频游戏,还可以单独分配线程池,让热门队列优先被处理。

整体结构:三个 Redis 对象分工协作

这套方案可以理解为一个“按游戏拆分的伪 MQ”。它的核心不是单一队列,而是三个对象的配合:

展示 Redis 伪消息中间件的三类对象以及生产消费流转关系的信息图
Redis 定长队列整体结构按游戏分组后,行为队列、游戏列表和去重集合分别承担存储、调度和唯一性控制职责。
  • 游戏行为队列:以游戏包名作为 key,对应一个定长 List,保存该游戏的行为消息;
  • 游戏列表:保存当前有哪些游戏存在待消费数据;
  • 游戏集合:用于判断游戏是否已进入待消费列表,避免重复压入。

生产过程怎么走

步骤一:游戏维度的某行为数据 PUSH 到该游戏自己的队列中。

步骤二:判断该游戏是否已存在于游戏 Set 中;如果存在,直接返回;如果不存在,进入步骤三。

步骤三:把该游戏 PUSH 到游戏列表中,等待消费者轮询。

消费过程怎么走

步骤一:从游戏列表中循环取出一个游戏。

步骤二:根据该游戏对应的行为数据队列,批量获取消息并处理。

这个思路的重点在于:消息和调度分离。每个游戏的实际行为数据放在自己的 List 中,而“哪些游戏现在值得消费”则由独立的游戏列表来驱动。

实现原理:用 Lua 把多条 Redis 命令包成原子操作

这套方案能稳定运行,关键不只是 Redis List,而是 Lua 脚本的原子性。因为无论是“判断长度后再裁剪并写入”,还是“先批量读取再裁剪剩余数据”,都不能拆成多个无保护命令交给客户端逐条执行。

Lua 脚本提供了什么能力

EVAL script numkeys key [key ...] arg [arg ...]
    时间复杂度:取决于脚本本身的执行的时间复杂度。
 
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
 
Redis uses the same Lua interpreter to run all the commands.
Also Redis guarantees that a script is executed in an atomic way:
no other script or Redis command will be executed while a script is being executed.
This semantic is similar to the one of MULTI / EXEC.
From the point of view of all the other clients the effects of a script are either still not visible or already completed.

Redis 对 Lua 脚本的保证很直接:脚本执行期间,不会插入其他 Redis 命令,效果类似 MULTI / EXEC。因此,多条命令可以作为一个原子动作执行。

在本文场景里,Lua 的作用主要有三点:

  • 把多条 List 命令合并为一次原子执行,避免并发下队列状态错乱;
  • 支撑定长队列写入与批量消费;
  • 在 Redis 集群模式下,尽量保持单 key 操作,减少多 key 带来的问题。

List 如何实现定长队列

生产阶段依赖的核心命令是 LLEN、LPOP 和 RPUSH。

LLEN key
    计算List的长度
    时间复杂度:O(1)。
 
LPOP key [count]
    从List的左侧移除元素
    时间复杂度:O(N),N为移除元素的个数。
 
RPUSH key element [element ...]
    从List的右侧保存元素
    时间复杂度:O(N),N为保存元素的个数。

组合方式很简单:

  • 先用 LLEN 判断当前队列长度;
  • 如果未达到上限,直接 RPUSH;
  • 如果已经达到上限,先 LPOP 丢掉最旧元素,再 RPUSH 写入新元素。

这样就得到一个固定长度、先进先出、允许覆盖旧数据的队列模型。

List 如何实现批量消费

消费阶段依赖的核心命令是 LRANGE 和 LTRIM。

LRANGE key start end
    时间复杂度:O(S+N), S为偏移量start, N为指定区间内元素的数量。
  
    下标(index)参数 start 和 stop 都以 0 为底,也就是说,以 0 表示列表的第一个元素,以 1 表示列表的第二个元素,以此类推。
    你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。
 
LTRIM key start stop
    时间复杂度:O(N) where N is the number of elements to be removed by the operation.
 
    修剪(trim)一个已存在的 list,这样 list 就会只包含指定范围的指定元素。

组合逻辑同样直接:

  • 先用 LRANGE 批量取出一段数据;
  • 再用 LTRIM 保留剩余数据,相当于“消费后出队”。

只要这两步放在同一个 Lua 脚本里,就能避免“刚读完数据还没裁剪,就被其他线程重复消费”的问题。

Set 在这里主要负责去重

SADD key member [member ...]
    往Set集合添加数据。
    时间复杂度:O(1)。
     
SISMEMBER key member
    判断Set集合是否存在元素。
    时间复杂度:O(1)。

Set 的职责比较单一:判断某个游戏是否已经进入待消费列表,避免重复调度。这一步不复杂,但对整体轮询效率影响很大。

生产消息:用 Lua 保证“定长写入 + 过期时间”一次完成

定义LUA脚本   
    CACHE_NPPA_EVENT_LUA =
        "local retVal = 0 " +
        "local key = KEYS[1] " +
        "local num = tonumber(ARGV[1]) " +
        "local val = ARGV[2] " +
        "local expire = tonumber(ARGV[3]) " +
        "if (redis.call('llen', key) < num) then redis.call('rpush', key, val) " +
        "else redis.call('lpop', key) redis.call('rpush', key, val) retVal = 1 end " +
        "redis.call('expire', key, expire) return retVal";
 
 
执行LUA脚本
    String data = JSON.toJSONString(nppaBehavior);
    Long retVal = (Long)jedisClusterTemplate.eval(CACHE_NPPA_EVENT_LUA, 1, NPPA_PREFIX + nppaBehavior.getGamePackage(), String.valueOf(MAX_GAME_EVENT_PER_GAME), data, String.valueOf(NPPA_TTL_MINUTE * 60));
 
执行效果
    实现固长队列的数据存储并设置过期时间

这段脚本做了三件事:

展示 Redis List 定长写入 Lua 脚本判断流程的信息图
定长队列写入流程生产阶段通过 Lua 把长度判断、淘汰旧值、写入新值和设置过期时间合并为一次原子操作。
  • 判断当前游戏队列是否达到上限;
  • 达到上限时淘汰最旧数据,再写入新数据;
  • 每次写入后都刷新过期时间。

它对应的是一种很明确的业务取舍:宁可丢弃更旧的数据,也要保证队列长度可控,并优先保留更接近当前时刻的数据。

消费消息:批量读取后立即裁剪,避免重复处理

定义LUA脚本
    QUERY_NPPA_EVENT_LUA =
        "local data = {} " +
        "local key = KEYS[1] " +
        "local num = tonumber(ARGV[1]) " +
        "data = redis.call('lrange', key, 0, num) redis.call('ltrim', key, num+1, -1) return data";
 
执行LUA脚本
    Integer batchSize = NppaConfigUtils.getInteger("nppa.report.batch.size", 1);
    Object result = jedisClusterTemplate.eval(QUERY_NPPA_EVENT_LUA, 1, NPPA_PREFIX + gamePackage, String.valueOf(batchSize));
 
执行效果
    取固定数量的对象,然后保留队列的剩余的消息对象。

这里的关键点是“先取后裁”,但两步必须在脚本内一起完成。这样可以确保批量消费的边界清晰,也能减少客户端协调成本。

展示 Redis 批量消费 Lua 脚本如何先读取再裁剪队列的信息图
批量消费与裁剪流程消费阶段通过 LRANGE 与 LTRIM 的原子组合,完成固定批次读取和剩余数据保留。

在线程模型上,文中采用的是 pull 模式:消费者多线程循环轮询可消费队列。相较于依赖 Redis pub/sub 通知的 push 模式,这种方式实现成本更低,在这个场景下也更容易控制节奏。

落地时要注意的几个点

1. Redis 集群下,Lua 脚本尽量只传单 key

在 Redis 集群模式中,执行 Lua 脚本建议传入单 key。多 key 操作容易触发重定向错误,这也是这类方案设计时要主动回避的限制。

2. 不同 Redis 版本对 Lua 的 null 返回处理可能不同

如果脚本需要处理空结果,实际行为要以对应版本的官方文档为准,不能直接假设所有版本完全一致。

3. 热门游戏要单独处理消费优先级

不同游戏的流量热度并不一致。如果所有游戏共用完全相同的轮询节奏,热点游戏可能堆积得更快。文中的处理方式是:通过配置为热门游戏单独开启消费线程,相当于给不同游戏设置不同优先级。

线上效果:约 1w QPS,批量上报后出口压力更低

这套方案已经在线上运行,文中的核心结果有两个:

  • 生产和消费的 QPS 约为 1w qps;
  • 经过批量上报后,真正对外的上报 QPS 会明显低于内部消息生产和消费 QPS。

另外,数据是按游戏包名作为 key 存储的,因此在这个场景下,性能上不存在明显热点问题。

这套方案适合什么场景,不适合什么场景

从本质上看,这不是在 Redis 上复刻一个通用 MQ,而是在特定约束下实现一个“够用”的轻量消息模型。它比较适合下面这类问题:

  • 单条生产、批量消费;
  • 需要按业务维度分组,例如按游戏、租户、设备或渠道拆分;
  • 允许丢弃旧数据,重点保留最新窗口内的数据;
  • 更关注吞吐、时效和实现成本,而不是完整消息语义。

除了消息场景,这种结构也可以降级成一个先进先出的定长日志队列。

但如果业务要求强一致投递、严格顺序保证、复杂重试、消费确认、死信处理等完整消息能力,那么它就不该替代成熟 MQ。

总结

这次实践的价值,不在于“Redis 能不能当 MQ 用”,而在于先把业务约束拆清楚,再用最贴近目标的数据结构组合出可落地方案。通过 Lua 脚本把 LLEN + LPOP + RPUSH 组合成定长写入,把 LRANGE + LTRIM 组合成批量消费,再配合 Set 去重和多线程 pull 模式,这套方案最终实现了按游戏分组、固定长度保留、批量上报和时效控制,并且已经在线上平稳运行。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。