这是“用 Python 实现 Redis 任务队列”系列的第 1 篇。本文先使用 Redis List 建立最小模型;下一篇会把“任务已经取出但 worker 中断”的处理中状态补上。

先处理一个真实任务

假设 API 收到用户提交的五个项目,需要在后台逐个计算匹配度。接口不希望等待计算完成,于是把每个项目转换为一条任务:

API 进程:把任务写入 Redis
worker:等待并取出任务
worker:执行计算并记录结果
前端:通过业务接口查询状态

这里先只关心“任务如何等待和领取”。Redis 不负责业务结果的最终结构,计算结果仍然可以写入 PostgreSQL。

本篇选择 Redis List,是因为它只需要两个核心动作:生产者使用 LPUSH 入队,消费者使用 BRPOP 阻塞等待并取出。这个版本适合建立心智模型,不包含重试、超时接管、优先级和结果后端。

系列导航:

  1. 本文:Redis List + Python 的最小任务队列
  2. 下一篇:处理中队列、确认和 worker 中断后的恢复
  3. 后续:Redis Streams 消费者组、Pending 消息与 XAUTOCLAIM
  4. 选型篇:什么时候用 Redis,什么时候用 RabbitMQ、Celery 或 PostgreSQL

先准备 Redis 和 Python 客户端

本地没有 Redis 时,可以用 Docker 启动一个临时实例:

docker run --rm --name redis-task-demo -p 6379:6379 redis:7

新建虚拟环境并安装 Python 客户端:

python -m venv .venv
source .venv/bin/activate
python -m pip install redis

redis-py 是 Python 客户端,真正保存任务的是 Redis 服务。默认示例连接 redis://localhost:6379/0,也可以通过 REDIS_URL 改成其他地址。

先确认服务可连接:

redis-cli ping
# PONG

生产者只负责把任务放进去

先写一个生产者。每条消息使用 JSON 表示,至少包含业务任务编号和输入内容。任务编号由业务方生成,后续查询状态或实现幂等时都需要它。

# redis_task_queue.py
import argparse
import json
import os
from typing import Any

import redis


QUEUE_KEY = "demo:tasks"


def connect() -> redis.Redis:
    return redis.Redis.from_url(
        os.getenv("REDIS_URL", "redis://localhost:6379/0"),
        decode_responses=True,
    )


def publish(r: redis.Redis, task_id: int, value: str) -> None:
    message: dict[str, Any] = {
        "task_id": task_id,
        "value": value,
    }
    r.lpush(QUEUE_KEY, json.dumps(message, ensure_ascii=False))
    print(f"enqueued task_id={task_id}")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--value", default="demo")
    parser.add_argument("--count", type=int, default=5)
    args = parser.parse_args()

    if args.count <= 0:
        raise SystemExit("--count must be positive")

    r = connect()
    for task_id in range(1, args.count + 1):
        publish(r, task_id, f"{args.value}-{task_id}")


if __name__ == "__main__":
    main()

运行:

python redis_task_queue.py --count 5

LPUSH 把新任务放到列表左侧。消费者从右侧 BRPOP,因此最早加入的任务最先被取出:

第一次 LPUSH:      [task-1]
第二次 LPUSH:      [task-2, task-1]
BRPOP 取出:        task-1

如果只是想观察队列长度,可以执行:

redis-cli LLEN demo:tasks
redis-cli LRANGE demo:tasks 0 -1

生产者在这里的成功含义是“Redis 接受了消息”。它不表示计算已经开始,也不表示业务结果已经写入。

消费者在没有任务时阻塞等待

消费者使用 BRPOP。B 表示 blocking:队列为空时,Redis 不会立即返回空结果,而是在指定秒数内等待新消息。这样 worker 不需要每秒轮询,也不会因为短暂没有任务而退出。

def consume_once(r: redis.Redis, timeout: int = 5) -> bool:
    item = r.brpop(QUEUE_KEY, timeout=timeout)
    if item is None:
        return False

    _, raw_message = item
    message = json.loads(raw_message)
    print(f"received task_id={message['task_id']} value={message['value']}")
    return True

把它接成一个持续运行的 worker:

import time


def run_worker(r: redis.Redis) -> None:
    while True:
        item = r.brpop(QUEUE_KEY, timeout=5)
        if item is None:
            print("no task; waiting")
            continue

        _, raw_message = item
        message = json.loads(raw_message)
        try:
            print(f"start task_id={message['task_id']}")
            time.sleep(1)  # 用真实的业务计算替换
            print(f"succeeded task_id={message['task_id']}")
        except Exception as exc:
            print(f"failed task_id={message['task_id']} error={exc}")

将生产者和消费者拆成两个终端运行:

# 终端 1
python redis_task_queue.py --count 5

# 终端 2
python -c 'from redis_task_queue import connect, run_worker; run_worker(connect())'

如果消费者启动得更晚,任务仍会留在 Redis List 中。消费者启动后,BRPOP 会依次取出它们。启动两个相同的 worker,就能让两个消费者共同处理同一个队列;但每个任务只会被一个 BRPOP 调用取出。

为什么这个版本还不可靠

BRPOP 的关键语义是:**任务返回给消费者时,已经从待处理 List 删除。**这使得同一条消息不会一直停留在待处理列表中,但也带来一个直接的故障窗口:

1. worker 从 Redis BRPOP 取出 task-7
2. Redis 已经删除 task-7
3. worker 还没完成计算就被杀死
4. task-7 不在待处理队列,也没有完成记录

这叫“取出后丢失”问题。它和任务是否异步无关,而是确认时机没有被表达出来。

不能只在 BRPOP 后写一句“处理失败就重新入队”来解决所有情况。worker 可能在捕获异常之前就崩溃,也可能计算已经产生外部副作用,却在写完成状态前断电。下一篇会把任务先移动到处理中 List,成功后显式确认,并讨论处理中任务的超时恢复。

阻塞消费和可靠消费是两件事

BRPOP 解决的是等待方式:没有消息时阻塞,避免固定间隔轮询。它没有解决以下问题:

问题本篇 List + BRPOP 是否解决
空队列时等待是
多个 worker 分摊任务是
任务顺序(同一 List)是,按 FIFO 取出
处理成功后确认否
worker 中断后重新认领否
业务结果幂等否
失败重试和死信否

因此,这段代码适合演示和某些“丢失也可以接受”的临时任务。涉及扣费、通知、数据写入或用户明确等待结果时,还需要补充可靠投递和业务幂等。

任务状态应该放在哪里

Redis List 只保存等待中的消息。如果前端需要查询状态,通常还要单独保存任务状态:

task:{task_id} = {
  status: "pending | running | succeeded | failed",
  result: ...,
  error: ...
}

生产者可以在 LPUSH 后写 pending,消费者取出后写 running,结束后写 succeeded 或 failed。但如果这两次 Redis 操作之间进程中断,状态和队列仍可能不一致。可靠方案需要明确状态变更顺序、幂等键和恢复路径;不能因为有一个 status 字段,就自动拥有事务。

如果业务结果本来就保存在 PostgreSQL,Redis 可以只作为消息代理,任务状态和结果仍由业务数据库管理。选择 Redis 不等于必须把所有业务数据都放进 Redis。

用多少 worker 才合适

假设每个任务平均处理 2 秒:

单 worker 理论吞吐 = 60 / 2 = 30 个任务/分钟
4 个 worker 理论上限 = 4 × 30 = 120 个任务/分钟

这是理想上限。实际吞吐还受 Redis 网络、数据库连接池、模型服务并发和任务耗时长尾影响。如果下游模型只允许两个并发请求,启动十个 worker 只会让更多任务同时等待或触发限流。

可以先观察三个指标:

  • LLEN demo:tasks:等待积压长度;
  • 单任务从入队到开始执行的等待时间;
  • 处理失败、重新入队和最终失败的数量。

队列长度长期增长说明输入速率高于消费能力。增加 worker 只是一个选择,也可以限制生产者、降低单次提交数量或优化任务耗时。

本篇代码的边界

本篇完成了一个可运行的最小生产消费链:生产者使用 LPUSH,消费者使用 BRPOP,空队列时阻塞,多个 worker 可以分摊同一个 List。

它没有承诺“恰好执行一次”。BRPOP 之后的进程故障会丢失任务;即使补上重试,外部副作用也可能重复。因此,下一篇将把“等待中”和“处理中”拆开,加入确认、超时和重新认领,再比较它与 Redis Streams 消费者组的差异。

参考与延伸阅读