最近看到一篇文章一张优惠券引发的血案,文章作者实现一个获取优惠券列表的RPC接口,接口中的执行流程大概是先在缓存里获取优惠券列表,有则返回,没有则在数据库里获取,将结果写入缓存再返回。然而,在高并发请求的情况下,缓存优惠券列表中出现大量的重复数据。
主要原因是:
- 程序只适应单例执行,没有考虑到多例并发的情况;
- 优惠券列表在缓存中存储结构是数组,而更新方法是直接追加;
- 直接在RPC接口中执行更新缓存。
文章作者最后通过反复判断来解决这个问题,但并不是一个好办法。其实这样需要动态更新缓存场景在开发中十分常见,所以在这里探讨一下相对完善的解决方案。
场景
假设有一些时效性的数据(比如最新资讯、今日天气等等),这些数据在一定时间内不会改变,用户经常会获取,而直接从数据库中读取相对耗时,那么这些数据很应该放在缓存中。
因为这些数据具有时效性,所以需要及时更新。用定时更新的话(一般都不会每个1秒更新一次),会有一些间隔;用动态更新的话(由用户触发更新),在用户请求获取这些数据的时候检查是否需要更新,是则更新并将新的数据返回给用户。
实现
数据结构
在缓存中存储数据时,应该选择合适的数据结构。
- 如果数据本身是有规则元素的集合,可以考虑采用数组结构存储,方便扩展;
- 如果数据本身是有规则元素的集合且元素各有主键,采用映射结构存储,可以单独对元素进行更新;
- 如果数据本身毫无规则,可以封装成JSON格式,采用字符串结构存储,更新时是全量更新。
另外,需要记录数据上一次更新的时间戳,即将这个时间戳也放进缓存,以此来判断数据是否有效,是否需要更新。当然,像Redis这类成熟的存储工具,可以直接利用其提供的TTL(time to live)来判断。
任务队列
首先先实现一个更新数据的任务,这里更新是指全量更新,伪代码如下:
1 | public static function updateTheDataTask() { |
然后,运行一个执行数据更新任务的队列进程,注意须是单进程,而非多进程,这样可以确保在一个时间点上只执行一个数据更新任务。其中做TTL判断,是为了过滤没必要任务执行;更新成功后休眠两秒(其实可以休眠更久点),主要等待Redis同步,以免后续任务获取到旧的数据;取消掉后续的数据更新任务是因为根本没必要执行了。
任务队列可以用gearman实现,而队列进程可以用supervisord来守护,这里不展开介绍。
网关接口
有了上面的任务队列,下面实现面向用户的获取数据接口就容易多了,伪代码如下:
1 | public function theData() { |
Queue::pushUpdateTheDataTask()
只是将数据更新任务放入队列,由队列处理进程去执行。这里强调一下,数据更新虽然是由用户触发,但是绝不在面向用户的网关接口里执行,因为面向用户的网关接口可能会有大量并发请求,接口内不应该做耗时操作,所以才建立任务队列来执行数据更新,减轻网关压力。
效果
这个方案在大量并发访问网关接口的情况下,缓存的数据可以保持准确,过程没有过多的无用操作,也节省了执行时间。
最后
《一张优惠券引发的血案》图文并茂,文中作者对进程、代码块、分布式锁等方面进行了讨论,最后也提供解决方法,然而读者更需要的是独立思考——同样的问题,可以有不同的、更好的解决方法。
开卷有益,贵在思考。