一、业务场景

1、相关场景:

  • 超时未支付订单,5分钟/30分钟后自动关闭
  • 未收货订单,14天后自动收货
  • 会员到期前3天,短信/邮件通知续费

2、场景特点:

某一事件发生后,某段时间后完成一指定事件

3、场景分析:

对于数据量小,我们可以使用定时任务,一直轮询数据,每秒查一次,取出需要被处理的数据,然后处理,也可以实现这样的操作

但是对于任务量较大,时效性较强的场景,在活动期间数据量可能达到百万甚至千万级别,面对如此庞大的数据量,如果单纯使用轮询方式,可能对数据库造成很大压力,无法满足业务要求而且性能低下
因此我们引入延时队列用于处理此类问题

二、延时队列相关方案

1、redis-zse

利用redis-zset天然的排序属性,把时间戳设置为score,消费者定时获取该执行的数据进行处理,消费者可用Python脚本实现,定时执行可以使用linux定时任务实现。

2、rabbitmq

基本原理:利用rabbitmq中的ttl
推荐阅读【RabbitMQ】一文带你搞定RabbitMQ延迟队列 一文,详细了解。



java高并发更新redis数据 redis队列实现高并发java_redis

三、redis-zset + Lua脚本实现延时队列

1、实现原理

zset 自带的排序

2、基本接口

  • 添加任务
  • 删除任务
  • 获取任务

3、zset相关命令介绍

ZADD key score1 member1 [score2 member2]
向有序集合添加一个或多个成员,或者更新已存在成员的分数

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT]
通过分数返回有序集合指定区间内的成员

ZREMRANGEBYSCORE key min max
移除有序集合中给定的分数区间的所有成员

通过ZRANGEBYSCORE,我们可以获得指定区间的任务,但是还需调用ZREM删除对应的消息,这两步操作是非原子的,因此我们借助Lua脚本来实现其原子操作

local message = redis.call('ZRANGEBYSCORE', KEYS[1], ARGV[1], ARGV[2], 'LIMIT', 0, ARGV[3]);
if #message > 0 then
  for i = 1, #message do 
    redis.call('ZREM', KEYS[1], message[i]);
  end 
  return message;
else
  return {};
end

4、实战模拟:关闭30分钟内未支付的订单

添加未支付的订单

zadd paying_orders 1606723466 "orderid0002"

支付完毕之后删除订单

ZREM paying_orders "orderid0002"

定时任务删除未支付的订单,可以配置定时任务的粒度为每分钟,当然也可以在多台机器上进行脚本的配置,脚本如下:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import time

import redis

client = redis.StrictRedis(host="127.0.0.1", port=6379)


def mysql_close_orders(orderids):
    # TODO:Mysql批量关闭orderids个订单
    sql_1 = """
    UPDATE book_order
             SET status = CASE id
                 WHEN 1001 THEN 2
                 WHEN 1002 THEN 2
                 WHEN 1003 THEN 2
             END
    WHERE id IN (1001,1002,1003)
    """

    sql_2 = """UPDATE user SET status=1 WHERE id in ('1001,1002,1003');"""
    print(orderids)
    pass

def zrangebyscore_and_rem(key, min, max, limit):
    """获取某一范围订单,并删除"""
    zrangebyscore_and_rem_str = """
        local message = redis.call('ZRANGEBYSCORE', KEYS[1], ARGV[1], ARGV[2], 'LIMIT', 0, ARGV[3]);
        if #message > 0 then
            for i = 1, #message do 
                redis.call('ZREM', KEYS[1], message[i]);
            end 
            return message;
        else
            return {};
        end
    """
    zrangebyscore_and_rem_lua = client.register_script(zrangebyscore_and_rem_str)
    res = zrangebyscore_and_rem_lua(keys=[key], args=[min, max, limit])
    return [i.decode() for i in res]


def get_change_orders(time_min, time_max, limit):
    return zrangebyscore_and_rem("paying_orders", time_min, time_max, limit)


if __name__ == '__main__':
    try:
        # 当前秒级时间戳
        interval = 60
        time_second_now = int(time.time())
        time_second_ago = time_second_now - interval
        limit = 1000

        orderids = get_change_orders(time_second_ago, time_second_now, limit)
        if orderids:
            mysql_close_orders(orderids)
    except Exception as e:
        print(e)

四、其他延时方案

如果你是用的是Python-tornado框架或者java,可以使用其自带的一些工具实现简单的延时,不过性能方面因没有做过测试,具体不太清楚。如果是比较小的功能,对延时要求不高,可以直接使用自带的特性,毕竟不论是使用redis-zset或是其他消息队列实现的延迟队列都会增加对应的维护成本,这些也是值的考虑的内容

1、python-tornado

如果使用的Python-tornado框架,可以使用add_timeout函数做延时触发,该函数第一个参数为需要延时的时间,第二个为延时触发函数,后面对应的是延时参数需要传入的相关参数

IOLoop.current().add_timeout(time.time() + 10, myfunc, arg1, arg2, arg3, loop=loop-1)

2、java-DelayQueue

具体推荐查看:java延迟队列DelayQueue使用及原理