动态更新时效性缓存的一个解决方案

最近看到一篇文章一张优惠券引发的血案,文章作者实现一个获取优惠券列表的RPC接口,接口中的执行流程大概是先在缓存里获取优惠券列表,有则返回,没有则在数据库里获取,将结果写入缓存再返回。然而,在高并发请求的情况下,缓存优惠券列表中出现大量的重复数据。
主要原因是:

  1. 程序只适应单例执行,没有考虑到多例并发的情况;
  2. 优惠券列表在缓存中存储结构是数组,而更新方法是直接追加;
  3. 直接在RPC接口中执行更新缓存。

文章作者最后通过反复判断来解决这个问题,但并不是一个好办法。其实这样需要动态更新缓存场景在开发中十分常见,所以在这里探讨一下相对完善的解决方案。

场景

假设有一些时效性的数据(比如最新资讯、今日天气等等),这些数据在一定时间内不会改变,用户经常会获取,而直接从数据库中读取相对耗时,那么这些数据很应该放在缓存中。
因为这些数据具有时效性,所以需要及时更新。用定时更新的话(一般都不会每个1秒更新一次),会有一些间隔;用动态更新的话(由用户触发更新),在用户请求获取这些数据的时候检查是否需要更新,是则更新并将新的数据返回给用户。

实现

数据结构

在缓存中存储数据时,应该选择合适的数据结构。

  1. 如果数据本身是有规则元素的集合,可以考虑采用数组结构存储,方便扩展;
  2. 如果数据本身是有规则元素的集合且元素各有主键,采用映射结构存储,可以单独对元素进行更新;
  3. 如果数据本身毫无规则,可以封装成JSON格式,采用字符串结构存储,更新时是全量更新。

另外,需要记录数据上一次更新的时间戳,即将这个时间戳也放进缓存,以此来判断数据是否有效,是否需要更新。当然,像Redis这类成熟的存储工具,可以直接利用其提供的TTL(time to live)来判断。

任务队列

首先先实现一个更新数据的任务,这里更新是指全量更新,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
public static function updateTheDataTask() {
$ttl = Redis::getTheDataTTL();
if($ttl > NEED_TO_UPDATE) {
return false;
}
$data = MySQL::getTheData();
Redis::setTheData($data); // update expires of the data at the same time
sleep(2);
Queue::cancelOtherUpdateTheDataTasks();
return true;
}

然后,运行一个执行数据更新任务的队列进程,注意须是单进程,而非多进程,这样可以确保在一个时间点上只执行一个数据更新任务。其中做TTL判断,是为了过滤没必要任务执行;更新成功后休眠两秒(其实可以休眠更久点),主要等待Redis同步,以免后续任务获取到旧的数据;取消掉后续的数据更新任务是因为根本没必要执行了。

任务队列可以用gearman实现,而队列进程可以用supervisord来守护,这里不展开介绍。

网关接口

有了上面的任务队列,下面实现面向用户的获取数据接口就容易多了,伪代码如下:

1
2
3
4
5
6
7
8
9
10
public function theData() {
$data = Redis::getTheData();
if($data) {
return $data;
} else {
$data = MySQL::getTheData();
Queue::pushUpdateTheDataTask();
return $data;
}
}

Queue::pushUpdateTheDataTask()只是将数据更新任务放入队列,由队列处理进程去执行。这里强调一下,数据更新虽然是由用户触发,但是绝不在面向用户的网关接口里执行,因为面向用户的网关接口可能会有大量并发请求,接口内不应该做耗时操作,所以才建立任务队列来执行数据更新,减轻网关压力。

效果

这个方案在大量并发访问网关接口的情况下,缓存的数据可以保持准确,过程没有过多的无用操作,也节省了执行时间。

最后

《一张优惠券引发的血案》图文并茂,文中作者对进程、代码块、分布式锁等方面进行了讨论,最后也提供解决方法,然而读者更需要的是独立思考——同样的问题,可以有不同的、更好的解决方法。

开卷有益,贵在思考。