Node.js文件计数器并发递增问题及解决方案问询
首先直接给结论:完全有可能出现两个请求都读到10,最终文件写入11的情况,这就是典型的竞态条件(Race Condition)。
为什么会发生这种情况?
因为fs.readFile和fs.writeFile都是异步非阻塞操作。当两个请求几乎同时到达时,它们的读取操作会先被触发,都从文件里读到初始值10;接着两个请求各自把10加1得到11,然后先后执行写入操作——最后一次写入会覆盖前一次的结果,所以文件最终是11,而不是预期的12。
举个时间线例子:
- 请求A:发起
readFile,读到10 - 请求B:几乎同时发起
readFile,也读到10 - 请求A:计算10+1=11,发起
writeFile写入11 - 请求B:计算10+1=11,发起
writeFile写入11 - 文件最终值是11,丢失了一次增量
怎么解决这个问题?
下面是几种实用的解决方案,你可以根据自己的场景选择:
1. 改用同步文件操作(适合低并发场景)
把异步的readFile/writeFile换成同步的readFileSync/writeFileSync,这样每个请求的"读-加1-写"流程会变成原子操作——同一时间只有一个请求能执行这个完整流程,避免竞态。
代码示例:
const fs = require('fs'); const path = require('path'); const express = require('express'); const app = express(); const counterFile = path.join(__dirname, 'counter.txt'); app.get('/increment', (req, res) => { try { // 同步读取当前值 const currentValue = parseInt(fs.readFileSync(counterFile, 'utf8'), 10); const newValue = currentValue + 1; // 同步写入新值 fs.writeFileSync(counterFile, newValue.toString()); res.send(`当前计数器值:${newValue}`); } catch (err) { console.error('计数器操作失败:', err); res.status(500).send('操作失败'); } }); app.listen(3000, () => console.log('服务启动在3000端口'));
⚠️ 注意:同步操作会阻塞Node.js的事件循环,如果你的服务并发量很高,这种方法会导致请求排队,性能下降,所以只适合低流量场景。
2. 使用内存计数器+定时持久化(适合高并发,可接受短暂数据丢失)
把计数器存在内存变量里,每次请求直接对内存变量做增量操作,然后定期把内存中的值写入文件(比如每几秒一次),或者在进程退出时写入。这种方式性能最好,但要注意:如果进程意外崩溃,最近一次持久化之后的增量会丢失。
代码示例:
const fs = require('fs'); const path = require('path'); const express = require('express'); const app = express(); const counterFile = path.join(__dirname, 'counter.txt'); // 初始化内存计数器,从文件读取初始值 let counter = parseInt(fs.readFileSync(counterFile, 'utf8'), 10) || 0; // 每5秒持久化一次到文件 setInterval(() => { fs.writeFileSync(counterFile, counter.toString()); }, 5000); // 进程退出时强制持久化 process.on('exit', () => { fs.writeFileSync(counterFile, counter.toString()); }); app.get('/increment', (req, res) => { counter++; res.send(`当前计数器值:${counter}`); }); app.listen(3000, () => console.log('服务启动在3000端口'));
3. 使用数据库的原子操作(高并发场景首选)
用数据库代替文件存储计数器,因为数据库本身提供了原子性的增量操作,能完美解决竞态问题。比如Redis的INCR命令,或者SQL数据库的UPDATE ... SET counter = counter + 1语句。
以Redis为例的代码示例:
const redis = require('redis'); const express = require('express'); const app = express(); const fs = require('fs'); const path = require('path'); const counterFile = path.join(__dirname, 'counter.txt'); // 连接Redis const redisClient = redis.createClient({ // 配置你的Redis地址、密码等 }); (async () => { await redisClient.connect(); // 初始化计数器(如果不存在则设为0) const current = await redisClient.get('counter'); if (!current) { await redisClient.set('counter', '0'); } })(); app.get('/increment', async (req, res) => { try { // Redis的INCR命令是原子性的,直接返回增量后的值 const newValue = await redisClient.incr('counter'); res.send(`当前计数器值:${newValue}`); // 可选:定期把Redis的值同步到文件做持久化 if (newValue % 100 === 0) { fs.writeFileSync(counterFile, newValue.toString()); } } catch (err) { console.error('Redis操作失败:', err); res.status(500).send('操作失败'); } }); app.listen(3000, () => console.log('服务启动在3000端口'));
4. 使用应用层互斥锁(轻量级,无需额外依赖)
通过第三方库实现应用层的互斥锁,确保同一时间只有一个请求执行读写文件的流程。比如用async-mutex库:
代码示例:
const fs = require('fs').promises; const path = require('path'); const express = require('express'); const { Mutex } = require('async-mutex'); const app = express(); const counterFile = path.join(__dirname, 'counter.txt'); // 创建一个互斥锁 const mutex = new Mutex(); app.get('/increment', async (req, res) => { let release; try { // 获取锁,确保同一时间只有一个请求进入代码块 release = await mutex.acquire(); // 读取当前值 const currentValue = parseInt(await fs.readFile(counterFile, 'utf8'), 10); const newValue = currentValue + 1; // 写入新值 await fs.writeFile(counterFile, newValue.toString()); res.send(`当前计数器值:${newValue}`); } catch (err) { console.error('计数器操作失败:', err); res.status(500).send('操作失败'); } finally { // 释放锁 if (release) release(); } }); app.listen(3000, () => console.log('服务启动在3000端口'));
总结
- 如果是低并发场景,用同步文件操作最简单;
- 高并发场景优先选Redis或数据库的原子操作;
- 想要轻量且性能不错,选内存计数器+定时持久化(注意数据丢失风险);
- 不想用数据库又要保证数据安全,用应用层互斥锁。
内容的提问来源于stack exchange,提问作者user2039981

