使用Redis可扩展的延迟任务执行

时间:2012-06-03 07:10:42

标签: redis scheduled-tasks scalability

我需要设计一个Redis驱动的可扩展任务调度系统。

要求:

  • 多个工作进程。
  • 许多任务,但长时间闲置是可能的。
  • 合理的计时精度。
  • 闲置时资源浪费最少。
  • 应使用同步Redis API。
  • 应该适用于Redis 2.4(即即将推出的2.6中没有功能)。
  • 不应使用RPC的其他方式而不是Redis。

伪API:schedule_task(timestamp, task_data)。时间戳是整数秒。

基本理念:

  • 在列表中收听即将开展的任务。
  • 每个时间戳将任务放入存储桶。
  • 睡到最近的时间戳。
  • 如果出现时间戳小于最接近的任务的新任务,请将其唤醒。
  • 批量处理所有即将到来的时间戳≤现在的任务(假设 任务执行很快)。
  • 确保并发工作程序不会处理相同的任务。同时,如果我们在处理它们时崩溃,请确保没有任何任务丢失。

到目前为止,我无法弄清楚如何在Redis原语中使用它...

任何线索?

请注意,有一个类似的老问题:Delayed execution / scheduling with Redis?在这个新问题中,我介绍了更多细节(最重要的是,许多工人)。到目前为止,我无法弄清楚如何在这里应用旧答案 - 因此,这是一个新问题。

5 个答案:

答案 0 :(得分:9)

这是另一种基于其他几种解决方案的解决方案[1]。它使用redis WATCH命令删除竞争条件,而不使用redis 2.6中的lua。

基本方案是:

  • 将redis zset用于计划任务,使用redis队列执行准备运行任务。
  • 让调度程序轮询zset并将准备运行的任务移动到redis队列中。您可能需要多个调度程序来实现冗余,但您可能不需要或不需要很多。
  • 拥有尽可能多的工作人员阻止redis队列中的弹出窗口。

我没有测试过: - )

foo工作创建者会:

def schedule_task(queue, data, delay_secs):
    # This calculation for run_at isn't great- it won't deal well with daylight
    # savings changes, leap seconds, and other time anomalies. Improvements
    # welcome :-)
    run_at = time.time() + delay_secs

    # If you're using redis-py's Redis class and not StrictRedis, swap run_at &
    # the dict.
    redis.zadd(SCHEDULED_ZSET_KEY, run_at, {'queue': queue, 'data': data})

schedule_task('foo_queue', foo_data, 60)

调度员看起来像:

while working:
    redis.watch(SCHEDULED_ZSET_KEY)
    min_score = 0
    max_score = time.time()
    results = redis.zrangebyscore(
        SCHEDULED_ZSET_KEY, min_score, max_score, start=0, num=1, withscores=False)
    if results is None or len(results) == 0:
        redis.unwatch()
        sleep(1)
    else: # len(results) == 1
        redis.multi()
        redis.rpush(results[0]['queue'], results[0]['data'])
        redis.zrem(SCHEDULED_ZSET_KEY, results[0])
        redis.exec()

foo工作人员看起来像:

while working:
    task_data = redis.blpop('foo_queue', POP_TIMEOUT)
    if task_data:
        foo(task_data)

[1]此解决方案基于not_a_golfer,一个位于http://www.saltycrane.com/blog/2011/11/unique-python-redis-based-queue-delay/,另一个基于事务的redis文档。

答案 1 :(得分:7)

您没有指定您正在使用的语言。你至少有3种方法可以做到这一点,而不用在Python中编写一行代码。

  1. Celery有一个可选的redis代理。 http://celeryproject.org/

  2. resque是一个非常流行的使用redis的redis任务队列。 https://github.com/defunkt/resque

  3. RQ是一个简单的小型基于redis的队列,旨在“从celery和resque中获取好东西”,并且更加简单易用。 http://python-rq.org/

  4. 如果你不能使用它们,你至少可以看看它们的设计。

    但要回答你的问题 - 你想要的是用redis完成的。实际上我过去或多或少都写过。

    编辑: 至于在redis上建模你想要的东西,这就是我要做的:

    1. 使用时间戳排队任务将由客户端直接完成 - 您将任务放在一个有序集合中,时间戳作为分数,任务作为值(参见ZADD)。

      < / LI>
    2. 中央调度程序每N秒唤醒一次,检查此集合上的第一个时间戳,如果有任务准备好执行,它会将任务推送到“现在要执行”列表。这可以通过“等待”排序集上的ZREVRANGEBYSCORE来完成,获取时间戳&lt; = now的所有项目,这样您就可以立即获得所有准备好的项目。推动由RPUSH完成。

    3. 工作人员在“现在要执行”列表中使用BLPOP,当有东西可以工作时唤醒,并做他们的事情。这是安全的,因为redis是单线程的,没有2名工作人员会完成同样的任务。

    4. 完成后,工作人员将结果放回响应队列,由调度员或其他线程检查。您可以添加“待处理”存储桶以避免故障或类似情况。

    5. 所以代码看起来像这样(这只是伪代码):

      客户端:

      ZADD "new_tasks" <TIMESTAMP> <TASK_INFO>
      

      调度:

      while working:
         tasks = ZREVRANGEBYSCORE "new_tasks" <NOW> 0 #this will only take tasks with timestamp lower/equal than now
         for task in tasks:
      
             #do the delete and queue as a transaction
             MULTI
             RPUSH "to_be_executed" task
             ZREM "new_tasks" task
             EXEC
      
         sleep(1)
      

      我没有添加响应队列处理,但它或多或少像worker:

      工人:

      while working:
         task = BLPOP "to_be_executed" <TIMEOUT>
         if task:
            response = work_on_task(task)
            RPUSH "results" response
      

      编辑:无状态原子调度员:

      while working:
      
         MULTI
         ZREVRANGE "new_tasks" 0 1
         ZREMRANGEBYRANK "new_tasks" 0 1
         task = EXEC
      
         #this is the only risky place - you can solve it by using Lua internall in 2.6
         SADD "tmp" task
      
         if task.timestamp <= now:
             MULTI
             RPUSH "to_be_executed" task
             SREM "tmp" task
             EXEC
         else:
      
             MULTI
             ZADD "new_tasks" task.timestamp task
             SREM "tmp" task
             EXEC
      
         sleep(RESOLUTION)
      

答案 2 :(得分:1)

如果您正在寻找Java上的现成解决方案。 Redisson适合您。它允许使用熟悉的Redisson nodes api并基于Redis队列在ScheduledExecutorService上以分布式方式安排和执行任务(使用cron-expression支持)。

这是一个例子。首先使用java.lang.Runnable接口定义任务。每个任务都可以通过注入的RedissonClient对象访问Redis实例。

public class RunnableTask implements Runnable {

    @RInject
    private RedissonClient redissonClient;

    @Override
    public void run() throws Exception {
        RMap<String, Integer> map = redissonClient.getMap("myMap");
        Long result = 0;
        for (Integer value : map.values()) {
            result += value;
        }
        redissonClient.getTopic("myMapTopic").publish(result);
    }

}

现在已准备好将其汇总到ScheduledExecutorService

RScheduledExecutorService executorService = redisson.getExecutorService("myExecutor");
ScheduledFuture<?> future = executorService.schedule(new CallableTask(), 10, 20, TimeUnit.MINUTES);

future.get();
// or cancel it
future.cancel(true);

cron表达式的示例:

executorService.schedule(new RunnableTask(), CronSchedule.of("10 0/5 * * * ?"));

executorService.schedule(new RunnableTask(), CronSchedule.dailyAtHourAndMinute(10, 5));

executorService.schedule(new RunnableTask(), CronSchedule.weeklyOnDayAndHourAndMinute(12, 4, Calendar.MONDAY, Calendar.FRIDAY));

所有任务都在Redisson node上执行。

答案 3 :(得分:0)

综合方法似乎有道理:

  1. 没有新任务时间戳可能小于当前时间(如果更少则钳位)。假设可靠的NTP同步。

  2. 所有任务都以密钥形式进入存储桶列表,后缀为任务时间戳。

  3. 此外,所有任务时间戳都转到专用zset(键和分数 - 时间戳本身)。

  4. 客户可以通过单独的Redis列表接受新任务。

  5. 循环:通过zrangebyscore ... limit获取最早的N个过期时间戳。

  6. BLPOP,其中包含新任务列表上的超时和已获取时间戳的列表。

  7. 如果有旧任务,请处理它。如果是新的 - 添加到存储桶和zset。

  8. 检查已处理的存储桶是否为空。如果是这样 - 从zset删除列表和entrt。可能不会检查最近过期的存储桶,以防止时间同步问题。结束循环。

  9. 批判?评论?替代?

答案 4 :(得分:0)

Lua

我做了一些类似于这里建议的东西,但优化了睡眠持续时间以更精确。如果您对延迟任务队列的插入很少,则此解决方案很好。这是我使用 Lua 脚本的方法:

local laterChannel = KEYS[1]
local nowChannel = KEYS[2]
local currentTime = tonumber(KEYS[3])

local first = redis.call("zrange", laterChannel, 0, 0, "WITHSCORES")

if (#first ~= 2)
then
    return "2147483647"
end

local execTime = tonumber(first[2])
local event = first[1]

if (currentTime >= execTime)
then
    redis.call("zrem", laterChannel, event)
    redis.call("rpush", nowChannel, event)
    return "0"
else
    return tostring(execTime - currentTime)
end

它使用两个“通道”。 laterChannelZSETnowChannelLIST。每当执行任务时,事件都会从 ZSET 移至 LIST。 Lua 脚本响应调度程序在下一次轮询之前应该休眠的 MS 数量。如果 ZSET 为空,则永远休眠。如果是时候执行某事,请不要睡觉(即立即再次轮询)。否则,休眠直到执行下一个任务。

如果在调度员睡觉时添加了一些东西怎么办?

此解决方案与关键空间事件结合使用。您基本上需要订阅 laterChannel 的键,每当有添加事件时,您就会唤醒所有调度程序,以便他们再次轮询。

然后你有另一个调度器使用 nowChannel 上的阻塞左弹出。这意味着:

  • 您可以让调度程序跨多个实例(即它正在扩展)
  • 轮询是原子的,所以你不会有任何竞争条件或双重事件
  • 任务由任何空闲的实例执行

有一些方法可以进一步优化这一点。例如,不是返回“0”,而是从 zset 中获取下一项并直接返回正确的睡眠时间。

到期

如果你不能使用Lua脚本,你可以在过期文档上使用键空间事件。 订阅频道并在Redis驱逐它时接收事件。然后,抓住一把锁。这样做的第一个实例会将其移动到列表(“立即执行”通道)。然后你不必担心睡眠和轮询。 Redis 会告诉你什么时候该执行某事。

execute_later(timestamp, eventId, event) {
    SET eventId event EXP timestamp
    SET "lock:" + eventId, ""
}

subscribeToEvictions(eventId) {
    var deletedCount = DEL eventId
    if (deletedCount == 1) {
        // move to list
    }
}

然而,这有其自身的缺点。例如,如果您有很多节点,它们都会收到事件并尝试获取锁。但我仍然认为这里的任何建议的要求总体上都较少。