我们经常遇到的一种业务场景:完成A事件后,过一定时间,去做B事件;这种场景应该如何处理?

# 定时任务分为两种

  • 一种最简单的定时任务

    比如说每天凌晨三点自动运行起来跑一个脚本,这种一个 Crontab 任务 就能搞定。

  • 另一种是计时器任务

    比如用户触发了某个动作,那么从这个点开始过 二十四 小时 我们要对这个动作做点什么。那么如果有 1000 个用户触发了这个动作,就会有 1000 个定时任务。

计时器任务业务场景举例

  • 用户注册2个小时后给用户发送短信
  • 15分钟后关闭网络连接
  • 2分钟后再次尝试回调

# 基于轮训实现

常见的实现方式是轮训业务表,每隔一定时间间隔查询业务表

缺点

  • 轮询效率比较低

    当业务量比较大时,时间轮训会存在效率问题

  • 存在时间误差

    如果轮训间隔为一小时,误差最大即一小时

如何在保证效率的同时保证实时性?

# 基于redis的键空间消息

在redis 2.8.0版本之后,推出了一个新的特性键空间消息(Redis Keyspace Notifications),配合2.0.0版本的SUBSCRIBE 就能完成计时器任务。

# Keyspace Notifications

所谓的键空间通知,即是当某个键过期或者被修改时,会触发特定事件,并向订阅了该事件对应的通道推送消息。

默认情况下对于每个修改数据库的操作,键空间通知都会发送两种不同类型的事件。

比如说,对 0 号数据库的键 mykey 执行 DEL 命令时, 系统将分发两条消息, 相当于执行以下两个 PUBLISH 命令:

PUBLISH __keyspace@0__:mykey del
PUBLISH __keyevent@0__:del mykey
1
2

订阅第一个频道 keyspace@0:mykey 可以接收 0 号数据库中所有修改键 mykey 的事件,而订阅第二个频道 keyevent@0:del 则可以接收 0 号数据库中所有执行 del 命令的键。

以 keyspace 为前缀的频道被称为键空间通知(keyspace notification),而以 keyevent 为前缀的频道则被称为键事件通知(keyevent notification)。

del mykey命令执行时:

  • 键空间频道的订阅者将接收到被执行的事件的名字,在这个例子中,就是 del 。
  • 键事件频道的订阅者将接收到被执行事件的键的名字,在这个例子中,就是 mykey 。

# 启用Keyspace Notifications

因为开启键空间通知功能需要消耗一些 CPU ,所以在默认配置下,该功能处于关闭状态。

可以通过修改 redis.conf 文件, 或者直接使用 CONFIG SET 命令来开启或关闭键空间通知功能:

  • 当 notify-keyspace-events 选项的参数为空字符串时,功能关闭。
  • 另一方面,当参数不是空字符串时,功能开启。

notify-keyspace-events 的参数可以是以下字符的任意组合, 它指定了服务器该发送哪些类型的通知

  • K,表示 keyspace 事件,有这个字母表示会往 keyspace@ 频道推消息。
  • E,表示 keyevent 事件,有这个字母表示会往 keyevent@ 频道推消息。
  • g,表示一些通用指令事件支持,如 DEL、EXPIRE、RENAME 等等。
  • $,表示字符串(String)相关指令的事件支持。
  • l,表示列表(List)相关指令事件支持。
  • s,表示集合(Set)相关指令事件支持。
  • h,哈希(Hash)相关指令事件支持。
  • z,有序集(Sorted Set)相关指令事件支持。
  • x,过期事件,与 g 中的 EXPIRE 不同的是,g 的 EXPIRE 是指执行 EXPIRE key ttl 这条指令的时候顺便触发的事件,而这里是指那个 key 刚好过期的这个时间点触发的事件。
  • e,驱逐事件,一个 key 由于内存上限而被驱逐的时候会触发的事件。
  • A,g$lshzxe 的别名。也就是说 AKE 的意思就代表了所有的事件。

由于上文的需求,只需设置值为Ex就能满足。

直接修改redis.conf 文件notify-keyspace-events Ex, 或使用如下命令

$ redis-cli config set notify-keyspace-events KEA
1

# 动手实践

配置完后,重启redis服务后,测试如下:

启动一个客户端,对0号数据库订阅过期键事件通知

127.0.0.1:6379> SUBSCRIBE __keyevent@0__:expired
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "__keyevent@0__:expired"
3) (integer) 1
1
2
3
4
5

启动另一个客户端,设置mykey值为hh过期时间为5秒

127.0.0.1:6379> SET mykey hh EX 5
1

5秒后查看之前的客户端显示

127.0.0.1:6379> SUBSCRIBE __keyevent@0__:expired
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "__keyevent@0__:expired"
3) (integer) 1
1) "message"
2) "__keyevent@0__:expired"
3) "mykey"
1
2
3
4
5
6
7
8

# 优点与缺点

# 优点

  • 被动接受消息,相对于主动轮询被动接受效率更高。
  • 数据持久化,进程重启时任务数据不会丢失。
  • 跨进程通信,设置任务方和订阅消息方可以是不同进程。
  • 高效的第三方数据维护,内存管理更高效,解决了node单进程内存上限的问题。

# 缺点

  • 超时事件通知到达时,如何获取已超时的值

    当key超时,被删除,此时如何获取key对应的value,进行业务操作?

    Redis 2.8 notifications: Get value instead of key (on expire)

  • 如果redis宕机,keyspace的所有记录将无法保存,计时器任务将会丢失,健壮性有待提高

# 时间轮算法

系统设计:对于50万个客户端的websocket连接,服务端要主动关闭60秒以上没有活动的连接,如何设计?

这是一个拟问题,一般单机50万连接是不太可能的,主要考察我们对业务场景的分析。

# 心跳

方案不可取,性能太低,超时

# 有序链表LRU

# 时间堆

# 时间轮