Node.js中Redis并发问题:executeIsolated作用解析
executeIsolated作用解析 问题场景
我的Redis中有一个名为stocks的键,值为1000。假设有300位客户同时请求购买(每人买100股),最终应该只有10位客户能成功购买。
问题重现与解决方案
我知道下面的buy函数无法解决这个问题,先看初始实现:
/** * @param {import("redis").RedisClientType} instance * @param {string} clientId * @param {number} n */ async function buy(instance, clientId, n) { // --- (1) ---- // 获取当前库存数量 let stocks = await instance.GET("stocks"); stocks = parseInt(stocks); // --- (2) ---- // 验证剩余库存是否足够购买 if (stocks < n) { console.log("error: company does not have enough stocks"); return new Error("error: company does not have enough stocks"); } // --- (3) ---- // 更新公司当前库存并记录购买用户 await instance.INCRBY("stocks", -n); console.log("client @%s has bought %s stocks successfully!", clientId, n); }
为测试该函数,我编写了调用buy函数300次的测试代码:
const redis = require("redis"); const crypto = require("crypto"); const { buy } = require("./buy"); async function main(customers = 300) { const instance = await redis .createClient({ url: "redis://localhost:6379" }) .connect(); // --- (1) ---- // 设置库存 await instance.SET("stocks", 1000); // --- (2) ---- // 让每位客户并发购买100股 let pool = []; for (let i = 0; i < customers; i++) { let userId = crypto.randomBytes(4).toString("hex"); pool.push(buy_v3(instance, userId, 100)); } await Promise.all(pool); // --- (3) ---- // 获取剩余库存 let shares = await instance.GET("stocks"); console.log("the number of free shares the company has is: %s", shares); await instance.disconnect(); } main();
输出结果:
... error: company does not have enough stocks error: company does not have enough stocks error: company does not have enough stocks error: company does not have enough stocks the number of free stocks the company has is: -29000
正如预期,这个实现完全无效。之后我用以下方法修复了问题:
/** * @param {import("redis").RedisClientType} instance * @param {string} clientId * @param {number} n */ async function buy(instance, clientId, n) { try { await instance.executeIsolated(async (client) => { // --- (1) ---- // 获取当前库存数量 let stocks = await client.GET("stocks"); stocks = parseInt(stocks); // --- (2) ---- // 验证剩余库存是否足够购买 if (stocks < n) { throw new Error("error: company does not have enough stocks"); } // --- (3) ---- // 更新公司当前库存 await client.INCRBY("stocks", -n); }); console.log("client @%s has bought %s stocks successfully!", clientId, n); } catch (err) { console.log(err.message); } }
再次测试后的输出:
... error: company does not have enough stocks error: company does not have enough stocks error: company does not have enough stocks error: company does not have enough stocks the number of free stocks the company has is: 0
这说明问题已解决。
疑问
这个解决方案运行正常,但我对executeIsolated的作用有点困惑。我知道它只是创建一个新连接,通常用于需要独占连接的命令(比如watch命令)。请问executeIsolated在这个场景里具体起到了什么作用?
关于executeIsolated的作用解析
在这个库存扣减场景中,executeIsolated的核心作用是为库存查询、校验、扣减这一系列操作提供一个无命令交错的独占执行环境,具体原因如下:
- 避免连接池复用导致的命令插队
node-redis默认使用连接池复用Redis连接,多个并发请求会共享同一连接,命令会异步发送到Redis服务器。这就会出现这样的问题:A请求刚用连接查完库存(此时库存还有1000),还没执行扣减,B请求就复用同一个连接查了库存,也拿到1000的结果,最后两个请求都执行扣减,导致超卖——这正是你最初代码出现负库存的根本原因。
而executeIsolated会创建一个独立的、专属的Redis连接,在这个连接上执行的所有命令都是按顺序串行发送的,不会被其他请求的命令打断。也就是说,回调里的GET、库存判断、INCRBY这三步,会在这个专属连接上连续执行,Redis服务器会按顺序处理这组命令,不会被其他连接的命令插入到中间步骤。
- 通过独占连接实现串行化控制
虽然你没有使用Redis的WATCH/MULTI事务,但独占连接本身就实现了"串行化"效果:同一时间只有当前请求在使用这个连接操作stocks键,其他请求的操作必须等这个连接的命令执行完才能轮到。这就从客户端层面避免了多个请求同时读取旧库存值并执行扣减的情况。
不过要注意,这种方式本质是客户端层面的串行化,和Redis原生的原子操作(Lua脚本、事务)不同。更推荐的库存扣减方案是使用Lua脚本,因为Lua脚本在Redis服务器端是原子执行的,不需要依赖客户端的连接独占性,性能和可靠性更高,示例如下:
-- 库存扣减Lua脚本 local key = KEYS[1] local num = tonumber(ARGV[1]) local current = tonumber(redis.call('GET', key)) if current >= num then redis.call('INCRBY', key, -num) return 1 -- 购买成功 else return 0 -- 库存不足 end
在node-redis中调用该脚本,就能保证查询、校验、扣减的原子性,无需依赖executeIsolated。
内容的提问来源于Stack Exchange,提问作者Alireza

