防止NodeJS中的并发处理

时间:2018-10-07 15:07:09

标签: node.js express

我需要NodeJS来防止相同请求的并发操作。据我了解,如果NodeJS收到多个请求,则会发生以下情况:

REQUEST1 ---> DATABASE_READ
REQUEST2 ---> DATABASE_READ
DATABASE_READ complete ---> EXPENSIVE_OP() --> REQUEST1_END
DATABASE_READ complete ---> EXPENSIVE_OP() --> REQUEST2_END

这将导致运行两个昂贵的操作。我需要的是这样的:

REQUEST1 ---> DATABASE_READ
DATABASE_READ complete ---> DATABASE_UPDATE
DATABASE_UPDATE complete ---> REQUEST2 ---> DATABASE_READ ––> REQUEST2_END
                         ---> EXPENSIVE_OP() --> REQUEST1_END

这就是代码中的样子。问题出在应用开始读取缓存值和完成写入缓存之间的窗口。在此窗口中,并发请求不知道已经有一个正在运行相同itemID的请求。

app.post("/api", async function(req, res) {
    const itemID = req.body.itemID

    // See if itemID is processing
    const processing = await DATABASE_READ(itemID)
    // Due to how NodeJS works, 
    // from this point in time all requests
    // to /api?itemID="xxx" will have processing = false 
    // and will conduct expensive operations

    if (processing == true) {
        // "Cheap" part
        // Tell client to wait until itemID is processed
    } else {
        // "Expensive" part
        DATABASE_UPDATE({[itemID]: true})
        // All requests to /api at this point
        // are still going here and conducting 
        // duplicate operations.
        // Only after DATABASE_UPDATE finishes, 
        // all requests go to the "Cheap" part
        DO_EXPENSIVE_THINGS();
    }
}

编辑

我当然可以做这样的事情:

const lockedIDs = {}
app.post("/api", function(req, res) {
    const itemID = req.body.itemID
    const locked = lockedIDs[itemID] ? true : false // sync equivalent to async DATABASE_READ(itemID)
    if (locked) {
        // Tell client to wait until itemID is processed
        // No need to do expensive operations
    } else {
        lockedIDs[itemID] = true // sync equivalent to async DATABASE_UPDATE({[itemID]: true})
        // Do expensive operations
        // itemID is now "locked", so subsequent request will not go here
    }
}

lockedIDs的行为类似于内存中的同步键值数据库。如果它只是一台服务器,那就可以了。但是,如果有多个服务器实例怎么办?我需要有一个单独的缓存存储,例如Redis。而且我只能异步访问Redis。因此,不幸的是,这行不通。

2 个答案:

答案 0 :(得分:2)

您可以创建一个本地Map对象(在内存中用于同步访问),该对象包含任何itemID作为正在处理的密钥。您可以使该密钥的值成为一个承诺,该承诺可以解决以前处理该密钥的任何人的结果。我认为这就像是守门员。它跟踪正在处理的itemID。

此方案告诉以后要等待相同itemID的请求,并且不会阻止其他请求-我认为这很重要,而不是仅对所有与itemID处理相关的请求使用全局锁定。

然后,作为处理的一部分,您首先检查本地Map对象。如果该密钥在其中,则当前正在处理它。然后,您可以等待Map对象的promise,看看它何时完成处理,并从先前的处理中获取任何结果。

如果它不在Map对象中,则说明它现在不在处理中,您可以立即将其放在Map中以将其标记为“处理中”。如果将promise设置为值,则可以通过此对象处理得到的任何结果来解决该promise。

随之而来的任何其他请求都将仅在等待该诺言时结束,因此您将只处理一次该ID。以该ID开头的第一个请求将对其进行处理,而在处理该ID时出现的所有其他请求将使用相同的共享结果(从而节省了繁重的计算工作)。

我试图编写一个示例,但并没有真正理解您的伪代码试图做的足够好以提供一个代码示例。

像这样的系统必须具有完美的错误处理能力,以便所有可能的错误路径都能处理Map并保证正确嵌入Map中。

基于您相当轻巧的伪代码示例,下面是一个类似的伪代码示例,用于说明上述概念:

const itemInProcessCache = new Map();

app.get("/api", async function(req, res) {
    const itemID = req.query.itemID
    let gate = itemInProcessCache.get(itemID);
    if (gate) {
        gate.then(val => {
            // use cached result here from previous processing
        }).catch(err => {
            // decide what to do when previous processing had an error
        });
    } else {
        let p = DATABASE_UPDATE({itemID: true}).then(result => {
            // expensive processing done
            // return final value so any others waiting on the gate can just use that value
            // decide if you want to clear this item from itemInProcessCache or not
        }).catch(err => {
            // error on expensive processing

            // remove from the gate cache because we didn't get a result
            // expensive processing will have to be done by someone else
            itemInProcessCache.delete(itemID);
        });
        // mark this item as being processed
        itemInProcessCache.set(itemID, p);
    }
});

注意:这依赖于node.js的单线程。在此请求处理程序返回之前,没有其他请求可以开始,以便itemInProcessCache.set(itemID, p);在对此itemID的任何其他请求可以开始之前被调用。


此外,我对数据库不是很了解,但这似乎非常像一个好的多用户数据库可能内置的功能,或者具有使此操作变得容易的支持功能,因为不想成为一个不常见的想法有多个请求都试图做相同的数据库工作(或者更糟糕的是,互相挫败了对方的工作)。

答案 1 :(得分:2)

好,让我对此进行解释。

因此,我对此问题所遇到的问题是,您对问题的抽象如此之多,以至于很难帮助您进行优化。目前尚不清楚您的“长期运行的流程”在做什么,它在做什么将影响如何解决处理多个并发请求的挑战。您担心消耗资源的API在做什么?

起初,我从您的代码中猜到您正在开展某种长期运行的工作(例如文件转换之类的东西),但是随后的一些编辑和注释使我认为这可能只是一个复杂的过程针对需要大量计算才能正确执行的数据库查询,因此您想缓存查询结果。但是我也可以看到它是另外一回事,例如针对您正在聚合的一堆第三方API的查询或其他内容。每个方案都有一些细微差别,可以改变最佳方案。

也就是说,我将解释“缓存”场景,您可以告诉我是否对其他解决方案之一更感兴趣。

基本上,您已经在缓存的正确位置。如果您还没有的话,我建议您考虑一下cache-manager,它可以在某些情况下简化您的样板(让我们设置缓存无效,甚至可以进行多层缓存)。您缺少的部分是,您基本上应该始终使用缓存中的内容进行响应,并在任何给定请求范围之外填充缓存。以您的代码为起点,像这样(省去了所有try..catches和错误检查等工作),

// A GET is OK here, because no matter what we're firing back a response quickly, 
//      and semantically this is a query
app.get("/api", async function(req, res) {
    const itemID = req.query.itemID

    // In this case, I'm assuming you have a cache object that basically gets whatever
    //    is cached in your cache storage and can set new things there too.  
    let item = await cache.get(itemID)

    // Item isn't in the cache at all, so this is the very first attempt.  
    if (!item) {
        // go ahead and let the client know we'll get to it later. 202 Accepted should 
        //   be fine, but pick your own status code to let them know it's in process. 
        //   Other good options include [503 Service Unavailable with a retry-after 
        //   header][2] and [420 Enhance Your Calm][2] (non-standard, but funny)
        res.status(202).send({ id: itemID });

        // put an empty object in there so we know it's working on it. 
        await cache.set(itemID, {}); 

        // start the long-running process, which should update the cache when it's done
        await populateCache(itemID); 
        return;
    }
    // Here we have an item in the cache, but it's not done processing.  Maybe you 
    //     could just check to see if it's an empty object or not, but I'm assuming 
    //     that we've setup a boolean flag on the cached object for when it's done.
    if (!item.processed) {
        // The client should try again later like above.  Exit early. You could 
        //    alternatively send the partial item, an empty object, or a message. 
       return res.status(202).send({ id: itemID });
    } 

    // if we get here, the item is in the cache and done processing. 
    return res.send(item);
}

现在,我不知道您的全部工作是什么,但是如果是我,上面的populateCache是一个非常简单的函数,它仅调用我们用来执行长期工作的任何服务然后将其放入缓存。

async function populateCache(itemId) {
   const item = await service.createThisWorkOfArt(itemId);
   await cache.set(itemId, item); 
   return; 
}

让我知道是否不清楚,或者您的情况是否与我的猜测确实不同。

如评论中所述,这种方法将涵盖您所描述的方案可能遇到的大多数正常问题,但是,如果它们的执行速度比写入请求的速度快,它仍将允许两个请求同时启动长时间运行的过程。您的缓存存储区(例如Redis)。我认为发生这种情况的几率很低,但是如果您真的对此感到担心,那么下一个更偏执的版本将是从Web API中完全删除长时间运行的过程代码。取而代之的是,您的API仅记录有人请求该事件发生,并且如果高速缓存中没有任何内容,则像我上面所做的那样做出响应,但完全删除实际调用populateCache的块。

相反,您将运行一个单独的工作进程,该工作进程将定期(取决于您的业务状况)检查缓存中是否有未处理的作业,并启动处理这些作业的工作。通过这种方式,即使您对同一项目有1000个并发请求,也可以确保只处理一次。当然,不利的一面是您将检查的周期性添加到获取完全处理的数据的延迟中。