You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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操作,最终就会多计1票。
  2. 限流维度和业务不匹配:默认限流按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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 09:39:42