Express.js投票接口express-rate-limit限频失效及计数异常问题
问题背景
个人网站计划上线问答论坛模块,需支持用户对回答内容进行点赞/点踩操作,业务规则为:单用户对单条回答仅可投一次票,同时支持用户取消、修改自己已投的票。
前端通过Ajax向后端发送投票请求,因前端代码对客户端完全可见、易被篡改,必须在后端实现投票频率与权限的校验逻辑。
现有配置与故障表现
查阅教程后选用express-rate-limit库实现接口限频,参考配置如下:
const rateLimit = require('express-rate-limit'); const limiter = rateLimit({ windowMs: 2000, max: 1 }); router.post('/route', limiter, ...);
该配置实际运行不符合预期:用户快速连续点击投票按钮时,大量请求会同时发送到后端,最终导致投票计数错乱。
当前使用的后端投票处理逻辑代码如下:
exports.postVote = (req, res, next) => { const errors = validationResult(req) if (!errors.isEmpty()) return res.status(400).json(errors.array()[0].msg) return Message .findById(req.body.messageId) .then((message) => { if (message.userId.toString() == req.user._id.toString()) return res.status(400).json('You cannot vote on your own post!') else { return Vote .findOne({ userId: req.user._id.toString(), messageId: message._id.toString() }) .then((vote) => { if (vote) { if (vote.direction == req.body.direction) { if (vote.direction == 'up') message.votes-- else if (vote.direction == 'down') message.votes++ else return res.status(500).json('Something didn\'t work out, please try again') message.save() vote.remove() return res.json({ message: 'removed', votes: message.votes }) } else { if (req.body.direction == 'up') message.votes+=2 else if (req.body.direction == 'down') message.votes-=2 message.save() vote.direction = (vote.direction == 'up' ? 'down' : 'up') vote.save() return res.json({ message: 'changed', votes: message.votes }) } } else { let messageVote = new Vote({ messageId: message._id, userId: req.user._id, direction: req.body.direction }) if (req.body.direction == 'down') message.votes-- else if (req.body.direction == 'up') message.votes++ else return res.status(500).json('Something didn\'t work out, please try again') message.save() messageVote.save() return res.json({ message: 'added', votes: message.votes }) } }) .catch(e => { console.log(e) return res.status(500).json('Something didn\'t work out, please try again') }) } }) .catch(e => { console.log(e) return res.status(500).json('Something didn\'t work out, please try again') }) }
待解答疑问
- 部分资料提到
express-rate-limit仅在请求数超过max配置值后才会触发限频,该说法是否属实?当前配置的limiter是否代表2秒内仅允许1次请求?如果超阈值才触发限频的规则为真,是否需要配置max:0来实现即时限频? express-rate-limit是直接拦截超出限制的请求,还是仅对请求做延迟处理?如果是直接拦截超限请求,为何配置了2秒1次的限频规则后,仍会出现投票计数错乱的问题?给出可行的解决方案。
问题解答
关于express-rate-limit的触发规则
“超过max值才触发限频”的说法完全属实。你当前配置windowMs: 2000, max: 1的语义是:在默认按IP划分的限流维度下,2秒时间窗口内最多允许1个请求通过,只有窗口内累计请求数达到阈值后,第2个及后续请求才会被拦截,不存在提前拦截的逻辑。
配置max:0的语义是对应时间窗口内完全禁止任何请求通过,和“即时限频”没有关系,完全不符合业务需求。
express-rate-limit的默认处理逻辑
该库默认会直接拦截超限请求,返回429状态码,不会对请求做延迟处理。
投票计数错乱的核心原因
计数错乱和限频逻辑没有直接关系,核心是两个底层问题:
- 存在竞态条件漏洞:现有代码采用“先查询数据、再内存修改值、最后写入数据库”的非原子流程,当多个请求几乎同时到达时,会同时查到相同的初始投票状态、相同的初始票数,各自基于旧数据做修改后写入,最终票数必然出现计算错误。例如两个同时到达的点赞请求,都查到当前用户无投票记录,都执行票数+1操作,最终就会多计1票。
- 限流维度和业务不匹配:默认限流按IP维度统计,没有对齐“单用户对单条回答”的业务规则,同IP下多用户、用户切换IP都可以绕过限流;同时限流统计在请求入口层执行,时间窗口边界上依然可能有并发请求漏过,根本无法阻挡业务层面的并发提交。
另外现有代码中所有数据库异步操作(message.save()、vote.remove()等)都没有加await或返回Promise,会出现接口已经给前端返回响应、数据库操作还未执行完成的情况,进一步放大并发下的数据不一致问题。
可行修复方案
- 补前端基础体验优化:投票按钮点击后立刻设置
disabled禁用状态,等接口返回结果后再解除,从入口减少无效重复请求,注意该逻辑仅做体验优化,不能作为后端安全校验依据。 - 数据库层面加唯一约束:给Vote表的
userId和messageId字段创建联合唯一索引,从存储层保证同一个用户对同一条回答最多只能存在一条投票记录,从根源避免重复插入投票记录的问题。 - 替换非原子写逻辑:废弃“先查再改”的写法,改用MongoDB提供的原子更新算子(如
findOneAndUpdate、$inc)实现票数、投票状态的修改,保证读取、判断、修改、写入的整个流程不会被并发请求打断。 - 调整限流规则匹配业务维度:自定义限流key生成逻辑,将限流key设置为
${req.user._id}:${req.body.messageId},把限流粒度从IP调整为“单用户+单回答”,时间窗口设为1秒、max值设为1,精准拦截同一业务对象的高频重复请求。 - 补全异步操作等待逻辑:所有数据库异步操作前加
await,确保数据写入完成后再给前端返回响应,避免读写时序错乱。
内容的提问来源于stack exchange,提问作者Jacob Hornbeck
相关产品推荐
相关产品推荐

