关于node模拟同步锁的方案畅想,解决防止缓存击穿

Author Avatar
tangdaohai 5月 17, 2017
  • 在其它设备中阅读本文章

背景

在使用vue做一个项目的时候,有些需要keep-alive的内容,这些数据请求一次就不会再变,而且大部分用户的数据都是一样的,所以这块加个缓存再好不过了。

问题-缓存击穿

部署好redis,非常欢快的加上了node redis的插件,然后包装一下,跑通了,happy得不得了。但随即而来的问题是这样:

  1. 在服务刚起来的时候,或者数据过期的时候,需要重新请求数据库然后再缓存。
  2. 这个时候有10个用户同时发起同样的请求(参数完全一致的请求为同样的请求),会同时去redis中拿数据(因为redis中还没有数据)。
  3. 10个同样的请求都没有从缓存里面拿到数据,最终这10个请求都去后台数据库请求了,然后一遍一遍的又写到缓存里面去了。

这就发生了缓存击穿问题,严重的资源浪费!

解决思路

既然有10个同样的请求,那么其实只让第一个请求去数据库拿数据,然后其余9个请求只需要等待第一个请求回来就好了,然后10个请求一起拿着第一个请求回来的数据返回到vue。这样10个请求在服务器端只发生了一次http请求(数据库在另一台机器),数据库只处理了一个查询,减少资源浪费又减轻了数据库压力。

解决方案

后端使用的node+koa2,众所周知node是单线程,对于这种问题,在多线程语言中解决起来及其方便,node的问题就在于如何让其余9个问题处于挂起等待状态,使其等待第一个请求回来。

既然是要挂起等待,那肯定是要异步了,那要异步肯定要Promise + async/await了。不得不说koa对于异步流程的处理真的很棒。

那只有让着9个请求进入异步模式就能解决这个问题了。想来想去还是借助了node Events模块。一种订阅/发布模式的高级实现。events对事件的封装非常完美,在node内部也大量使用了events模块。

这样使用Events的once 与 emit,与Promise配合起来,基本上就解决问题了。

coding

因为使用了koa2,对于异步的处理机器方便。

首先,需要一个key,这个key可以代表一个请求连接,相同的请求那么key也是一个了。
这个key会在events.once中使用。先写一个events的公共方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import EventEmitter from 'events';

const emitter = new EventEmitter();

/**
* 获取等待的数据
*
* @export
* @param {String} key
* @returns {any}
*/
export async function awaitData(key) {
//返回一个Promise,外层已被async包装
return new Promise(resolve => {
//因为 emitter 注册监听器默认的最大限制是10,所以在并发多的时候出问题。需要动态调整数量
emitter.setMaxListeners(emitter.getMaxListeners() + 1);
emitter.once(key, (data) => {
//返回数据
resolve(data);
//减去当前监听器的数量
emitter.setMaxListeners(Math.max(emitter.getMaxListeners() - 1, 0));
});
});
}

/**
* 第一个请求向后台发起查询请求
* 并且占位,告知后面的请求,这件事情我去办了,你们等着我回来就可以了
*
* @export
* @param {string} key
* @param {any} params
* @returns {any}
*/
export async function queryData(key, params) {
// 这里是个关键,起到占位的用途,后面的请求会通过emitter.eventNames()去判断前面有没有请求去数据库了。也可以使用其他方式实现这个步骤
emitter.once(key, () => { });
return new Promise(resolve => {
//这里为去后台数据库请求的操作,这块使用setTimeout模拟异步操作
setTimeout(() => {
const data = 'just a test.';
//eimt 触发事件,将data传递给其他监听这个key的函数
myEE.emit(key, data);
//返回给第一个请求
resolve(data);
}, 3000); // 为了效果明显可以时间再长点
});
}
/**
* 查询当前事件是否被监听,如果被监听说明有请求去数据库了,我也继续监听等待第一个回来
*
* @export
* @param {any} key
* @returns {boolean}
*/
export function hasEvent(key){
//查询所有事件监听器中有没有这个key
return emitter.eventNames().includes(key);
}

关于监听器数量的默认限制可以看官方文档的说法https://nodejs.org/api/events.html#events_eventemitter_defaultmaxlisteners
基本上能用到的都封装好了,开始业务代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//koa2
app.use(async (ctx, next) => {
//这里不写路由了,直接path判断模拟下路由
if (ctx.path === '/getData') {
//使用md5去生产key,md5怎么来的就不写了
const key = md5(ctx.path + JSON.stringify(ctx.query));

//判断当前key有没有被监听
if (event.hasEvent(key)) {
//监听事件 等待被触发,这里使用异步与事件结合,使当前请求处于pendding挂起状态
return ctx.body = await event.awaitData(key);
} else {
//这里作为第一个请求,去数据库拿数据,然后触发其他等待的事件。
return ctx.body = await event.queryData(key);
}
}else{
return next();
}
})

这样基本上完成了10个请求,一个发出,九个等待的要求。但这种方式也有个缺点,这个方式只能在单节点生效,在有负载均衡的多节点中,这个方法是不行的,多节点之间也会有稍微的资源浪费。

以上,致那颗骚动的心……