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

背景

在使用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的公共方法:

    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
基本上能用到的都封装好了,开始业务代码:

    //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个请求,一个发出,九个等待的要求。但这种方式也有个缺点,这个方式只能在单节点生效,在有负载均衡的多节点中,这个方法是不行的,多节点之间也会有稍微的资源浪费。

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

你可能感兴趣的