Redis队列与Lua脚本:在共享工作池中确保分区内的严格顺序
在高负载系统中,消息处理需要在逻辑分区内保持严格顺序,涉及数十万键。有限的工作池必须均匀分配负载,避免被繁重分区阻塞。通过Redis与Lua脚本在smart-redis-queue库中实现了重试和延迟任务支持。
该方案利用Lua的原子性操作ZSET、HASH和STRING,无竞态条件。键以queue:{queueName}:为前缀。有序分区(代码前缀为!)通过锁定独占处理,而常规分区并行处理。
与Kafka和RabbitMQ的对比
Kafka确保主题分区内的顺序,并通过消费者组扩展,但将分区固定分配给消费者。动态工作池从任何就绪分区选取任务需要不同的语义。Kafka中的TTL无法替代精确的定时调度。
RabbitMQ支持TTL和死信队列,但多消费者下的严格顺序因轮询而破坏。为百万键单独设队列不可扩展。
Redis + Lua提供轻量级替代方案:每次原子操作仅需一轮往返,无需集群或复杂监控。
键结构
| 键 | 结构 | 用途 |
|------|-----------|------------|
| queue:{name}:partitions | SET | 活跃分区 |
| queue:{name}:partition:{code}:{priority} | ZSET | 按优先级排列的任务队列,分数 = 可用时间(毫秒) |
| queue:{name}:partition:{code}:priorities | ZSET | 优先级级别 |
| queue:{name}:payload:{taskId} | STRING | 负载数据 |
| queue:{name}:partition:{taskId} | STRING | 分区代码 |
| queue:{name}:consumer:{id}:tasks | HASH | 处理中的任务 |
| queue:{name}:partition:{code}:lock | STRING | 有序分区锁 |
| queue:{name}:partition:{code}:block | STRING (TTL) | 临时阻塞(速率限制) |
任务生命周期
- 发布:幂等添加,使用
SET NX,写入ZSET,分数 = 调度时间(毫秒)。分区添加到SET。
local ok = redis.call('SET', payloadKey, payload, 'NX')
if ok then
redis.call('ZADD', partitionQueueKey, scheduled, taskId)
redis.call('SADD', partitionsKey, partitionCode)
end
- 获取:心跳机制,迭代分区,检查有序分区的锁/阻塞。按降序优先级(
ZREVRANGE)获取,如果调度时间 <= 当前时间,则从ZSET头部取。移至消费者的HASH。
- 确认:从HASH、负载数据和键中移除。如果有序计数器为0,则释放锁。
- 拒绝:返回ZSET并设置新分数。对于有序分区——优先级+1,使用reject_seq打破平局。如果第一个任务被拒绝,则拒绝整个有序分区批次。
性能基准测试
测试环境:Go 1.22+、Redis 5、Intel i7-13700H。
发布(任务/秒):
- 1个任务:17–23千
- 批量10个:80–92千
- 批量100个:100–117千
批处理通过单次Lua调用提高吞吐量。
单个工作器消费(任务/秒):
| 预取 | 微秒/操作 | 任务/秒 |
|----------|--------|---------|
| 1 | 80–103 µs | 9.7–12.5千 |
| 5 | 57–67 µs | 15–17.5千 |
并行处理:
- 2个工作器:28–30千/秒
- 8个工作器:23–26千/秒
由于Redis争用,扩展呈非线性。
故障处理
- 心跳机制,TTL约120秒。
- 活跃消费者通过分布式锁扫描失效消费者。
- 失效消费者HASH中的任务返回队列(有序分区优先级增加)。
速率限制:RejectWithDelay设置带TTL的阻塞。
建议:优先级步长设为100+,避免与拒绝增量冲突。
关键要点
- Lua原子性确保任务在ZSET和HASH间移动时无竞态条件。
- 有序分区通过独占锁和拒绝逻辑保持顺序。
- 发布/获取中的批处理将吞吐量提升至10万+任务/秒。
- 工作器故障后自动恢复,无任务丢失。
- 适用于百万分区场景,无集群开销。
— Editorial Team
暂无评论。