高并发投票系统:限流、幂等防刷与票数一致性的落地实践

------附一次校园评选第三轮白屏的故障复盘与单机阶梯压测数据

一、写在前面:先想清楚"页面为什么先崩"

去年帮一所高职院校做"十佳社团"评选,前两轮一切正常,第三轮晚上八点开放,班级群开始转发的第三分钟,投票页面就开始转圈,刷新十几次才偶尔进得去,负责老师在办公室急得转圈。事后复盘,那次故障并不是系统"变慢了",而是流量模型突然变了:前两轮投票集中在班级内部,第三轮的流量是跨年级、跨校区在同一时刻涌入,再叠加失败重试,瞬时请求量比第二轮高出一个数量级。

这件事让我把"并发承载"这件事重新做了一遍:从容量估算开始,到网关限流、缓存幂等、消息削峰、票数对账、防刷误伤,最后用阶梯压测把瓶颈拐点找出来。本文按这条线记录做法和踩过的坑,代码都可以直接落地;最后再补一节"没有自研资源时"的工程取舍,供没有后端的团队参考。

二、先把问题量化:拉票洪峰的 QPS 怎么估

很多主办方选型时只问"能支持多少人投票",这个问题本身就不对。真正该算的是峰值 QPS,一个够用的粗估式:

设计 QPS = 峰值 UV × 单用户会话请求数 × 高峰集中度 ÷ 高峰窗口秒数 × 重试放大系数

代入这次的场景:预计参与投票 3 万人,一次投票页包含 1 次页面加载加 4 次接口调用共 5 个请求;拉票的规律是开放后前 5 分钟集中完成约 70%;失败重试带来的放大系数经验值取 2 到 3。

复制代码
稳态请求量 = 30000 * 5 * 0.70 / 300s  = 350 QPS
含重试风暴 = 350 * 3                = 1050 QPS
设计上限   = 1050 * 3 (冗余)        = 3150 QPS

这里有两个容易被忽略的点。第一,重试放大系数不是可选项:页面一旦超时,用户会连续刷新,未做静态化的页面每次刷新都会重复拉取 JS、图片和候选照片,流量会自我放大,所以"限流返回明确文案 + 前端指数退避"和"静态资源上 CDN"其实是降流量的手段,不只是保命手段。第二,这个场景的瓶颈通常不在写库:按上面的量算,落库也只有约 70 行每秒,真正先耗尽的是应用实例的数据库连接池和读查询,默认 20 个连接在几百 QPS 的查询接口下就会把线程全部堵死。

三、整体架构与四条设计底线

基于上面的估算,我把系统拆成接入层、缓存层、削峰层和存储层四层,见图 1。

支撑这套拆法的是四条底线,后面每一节的实现都在服从它们:

第一,静态与动态分离。投票页 HTML、JS、候选照片全部走 CDN 与浏览器缓存,只有计票接口打到应用集群,先把"打不开"这个最要观感的故障从动态链路上摘出去。

第二,票数写走缓存、落库异步。用户提交后的响应只依赖 Redis 的原子操作,落库交给消息队列慢慢消化,把数百行每秒的写压力摊平成几批事务。

第三,所有写接口幂等。学生双击提交、双端同时打开、前端超时重发,都会造成重复请求,幂等必须同时做在缓存层和数据库唯一键上,只靠一层都不保险。

第四,网关限流保命优先于"所有人都能投上"。超出阈值的请求宁可立刻返回 429 和一句明确文案,也不要让请求排在超时队列里拖垮整个实例------那才是第三轮那种全站白屏的直接原因。

四、接入层:先把"打不开"这件事解决

(一)Nginx 限流与缓存配置

下面是这次上线用的网关配置,去掉了业务无关的部分,可以直接改域名套用。

复制代码
limit_req_zone $binary_remote_addr zone=vote_api:20m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=vote_conn:20m;

upstream vote_gateway {
    least_conn;
    server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
    keepalive 64;
}

server {
    listen 80;

    location /vote/ {
        expires 5m;
        add_header Cache-Control "public, max-age=300";
        try_files $uri $uri/ /vote/index.html;
    }

    location /vote/api/ {
        limit_req  zone=vote_api  burst=40 nodelay;
        limit_conn vote_conn 20;
        error_page 429 = @busy;
        proxy_pass http://vote_gateway;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 2s;
        proxy_read_timeout 5s;
    }

    location @busy {
        default_type application/json;
        return 429 '{"code":429,"msg":"投票人数较多,请 3 秒后重试"}';
    }
}

(二)几个参数为什么这么定

rate 与 burst 要分开理解。rate=20r/s 是每个客户端 IP 的稳态速率,burst=40 是允许的瞬时堆积量------拉票洪峰本质是"到达的突发性"而不是"速率的持续提高",不给 burst,正常学生一进群就会看到 429。nodelay 则是让 burst 内的请求立即转发而不是排队,投票这种接口一旦排队就容易超时,用户超时后又会刷新,等于自己把流量再放大一次。

error_page 429 必须显式兜住并返回可读文案。前端拿到 429 后要做指数退避(第一次等 3 秒、再 5 秒、再 8 秒)并禁用提交按钮,这一步的收益比再加两台机器还明显:它从源头削掉了重试风暴。

proxy_read_timeout 收到 5 秒是有意的。宁可快速失败并给出重试提示,也不要让请求挂着占住网关 worker,最后表现为整站"转圈打不开"。

另外要提醒:如果投票页的 HTML 也在回源,接口限流配得再好,页面照样打不开。这次真正的第一个坑就在这个位置------候选照片没上 CDN,单次刷新要拉 2 MB 图片,带宽先被占满,网关限流根本没触发。

五、缓存层:一次投票的两条原子操作

最常见的写法是"先查是否投过、再计数、再标记",这三步在任何并发模型下都不是原子的:同一个账号在手机和电脑上同时提交,两次都读到"未投过",票数就多加了一次。正确做法是把判断和写入压进一段 Lua 脚本,利用 Redis 单线程执行脚本的特性保证原子性。

复制代码
-- vote_submit.lua
-- KEYS[1] = vote:cnt:{round}:{cid}      票数计数键
-- KEYS[2] = vote:uid:{round}:{uid}      该轮该用户的已投集合
-- ARGV[1] = 键过期秒数   ARGV[2] = voter_uid   ARGV[3] = candidate_id
if redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 1 then
    return -1                                  -- 已投过,幂等返回
end
local c = redis.call('INCR', KEYS[1])
redis.call('SADD',   KEYS[2], ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[2], ARGV[1])
redis.call('LPUSH',  'vote:queue', KEYS[1] .. '|' .. ARGV[2] .. '|' .. ARGV[3])
return c

要点有三个。一是返回值语义要区分清楚:-1 表示重复提交,正数是最新票数,前端据此提示"你已经投过啦",而不是弹一个系统错误。二是入队动作和计数放在同一段脚本里,保证"计了数的一定进了落库队列",否则进程重启会丢一批票。三是键设计要防热点:热门候选的 vote:cnt 会被高频写,单键在 Redis 里会打满单核,我把它拆成 16 个分片键(vote:cnt:{round}:{cid}:{shard}),展示时求和,这一步把后面的 P99 从 217 毫秒压到了 90 毫秒附近。

六、削峰与落库:消息队列加批量写入

(一)表结构:唯一键是最后一道防线

复制代码
CREATE TABLE vote_record (
  id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  round         SMALLINT      NOT NULL COMMENT '轮次',
  candidate_id  INT           NOT NULL,
  voter_uid     CHAR(32)      NOT NULL COMMENT 'openid 单向哈希',
  fingerprint   CHAR(32)      NOT NULL COMMENT '设备指纹哈希',
  ip            VARBINARY(16) NOT NULL,
  status        TINYINT       NOT NULL DEFAULT 1 COMMENT '1有效 2待复核 3已剔除',
  created_at    DATETIME(3)   NOT NULL,
  UNIQUE KEY uk_round_voter (round, voter_uid),
  KEY idx_round_time (round, created_at),
  KEY idx_round_cand (round, candidate_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_round_voter 这个唯一约束(round + voter_uid)是防重复计票的最后一道保险:Redis 挂了、脚本重试、消费端重复投递,都靠它兜住。票数结算最终只认这张表里 status=1 的行数。

(二)批量落库

复制代码
BATCH, FLUSH_SEC = 500, 0.5

def flush(conn, rows):
    if not rows:
        return 0
    sql = ("INSERT IGNORE INTO vote_record "
           "(round, candidate_id, voter_uid, fingerprint, ip, status, created_at) "
           "VALUES (%s,%s,%s,%s,%s,1,NOW(3))")
    with conn.cursor() as cur:
        cur.executemany(sql, rows)      # IGNORE: 唯一键冲突自动丢弃, 天然幂等
    conn.commit()
    return len(rows)

# 消费端: 攒够 BATCH 条或等满 FLUSH_SEC 秒就落一次, 二者先到为准

INSERT IGNORE 加上唯一键,等于把幂等下沉到了数据库层,消费端可以放心重复消费。攒批的意义在于事务数:数百行每秒如果逐条提交,会产生大量刷盘和行锁竞争;攒到 500 条一批,事务数降到个位数,落库延迟从秒级压到亚秒级。

(三)读写分离的坑:票数会"回退"

把查询接口全部切到从库之后,我遇到了一个很难解释的现象:候选人票数从 1200 变回 1198。原因是主从延迟,新写入的票还没同步到从库,页面重新拉取就出现了倒退。票数是公开数据,倒退一次就有一次质疑。解决办法是把读的边界划清楚:实时票数展示只读 Redis 计数,从库只承担看板和导出的复杂查询,结算与公示以主库落库结果为准。

七、票数一致性:谁为准,怎么对账

"Redis 计数"和"落库行数"是两个数据源,不一致一定会发生,关键是提前定义谁为准、以及不一致怎么处理。这次遇到的三类场景如表 1。

不一致场景 表现 根因 处理动作
落库积压 Redis 票数高于落库行数 MQ 消费跟不上,队列堆积 展示以 Redis 为准,扩容消费者,积压清零后再对账
Redis 计数丢失 落库行数高于 Redis 票数 实例重启且未开 AOF,计数键丢失 从落库行数全量重建计数键,强制开启 AOF everysec
风控事后剔票 公示前票数偏高 刷票在赛后识别,置 status=3 重算 status=1 行数并回写展示层,公示口径注明复核时间窗

表 1 三类票数不一致场景与处理动作

对账逻辑不复杂,难在把它做成固定动作而不是事后救火。每轮结束后跑一次全量比对,差异超阈值直接告警:

复制代码
-- 落库口径: 每轮每人的有效票数
SELECT round, candidate_id, COUNT(*) AS db_votes
FROM vote_record
WHERE round = 3 AND status = 1
GROUP BY round, candidate_id;

-- 展示口径: Redis 分片计数求和
--   vote:cnt:3:{candidate_id}:0 .. :15
-- 规则: |redis_sum - db_votes| > 3 触发告警, 一律以 db_votes 回写展示层

口径可以总结成一句话:展示用 Redis,结算与公示用落库。展示层允许短暂的乐观偏差,公示出来的数字必须可追溯到唯一键约束下的行。

八、防刷:三层校验、布隆去重与误伤治理

(一)三层校验

单靠"一个 IP 一天只能投一次"这种规则,在真实校园场景里几乎无效:学生换流量卡、借同学手机都很常见,同时它还容易误伤。我用的是三层叠加,任何一层单独拿出来都不作为封禁依据。

复制代码
import time

def check_vote(uid, ip, fp, cid, round_id):
    # 第 1 层: 黑名单 (由第 3 层累积写入)
    if r.get('bl:fp:%s' % fp):
        return reject('BLACKLIST')
    # 第 2 层: 设备指纹布隆 (同指纹换账号)
    if bf.exists(fp):
        mark_review(uid, cid, 'DUP_FP')        # 置 status=2 待复核, 不直接拒
        return reject('DUP_FP')
    # 提交锁: 3 秒内同账号重复请求判为重放
    if r.set('lock:%s:%s' % (round_id, uid), 1, nx=True, ex=3) is None:
        return reject('REPLAY')
    # 第 3 层: IP 滑动窗口频次
    key = 'rl:ip:%s' % ip
    now = time.time()
    if r.zcount(key, now - 60, '+inf') >= 5:
        r.setex('bl:fp:%s' % fp, 3600, '1')    # 高频指纹进黑名单
        return reject('RATE')
    r.zadd(key, {'%s:%s' % (uid, cid): now})
    r.expire(key, 120)
    bf.add(fp)
    return ACCEPT

(二)布隆过滤器的容量与误判

三万人量级、可接受 1% 误判率时,布隆过滤器大约只需要几百 KB 内存,代价完全可以忽略。但误判就是误伤:被判"设备指纹已投过"的正常学生不能直接拒。我的处理是把布隆命中只作为"标记复核"(落库时写 status=2),人工或规则二次确认后再置为无效,公示前给一次申诉入口。公信力敏感的评选里,误伤一个真实投票者的代价高于漏掉一张刷出来的票。

(三)校园场景最常见的误伤:宿舍共用出口 IP

这次误伤最集中的是宿舍网:一栋楼共用一个出口 IP,按 IP 限频会把整栋楼正常投票的学生一起拦下。两处调整解决了问题:一是 IP 频次阈值按楼栋网段聚合后再放宽(同段整体阈值调高,个体阈值靠指纹和账号维度收紧);二是封禁粒度落在"设备指纹 + 时间窗"而不是 IP 段。

(四)数据采集的边界

投票系统只需要"确认一人一票"所需的最小信息:openid 的单向哈希、设备指纹哈希、IP 和时间戳。姓名、手机号、班级这类信息一律不落投票库,候选人的展示信息也单独放在报名表里由本人确认。这既是《个人信息保护法》的最小必要原则要求,也实实在在降低了被拖库时的损失面。

九、压测:阶梯加压把拐点找出来

(一)压测脚本

用 k6 的 stages 做阶梯加压,比直接压固定 QPS 更容易看清拐点位置。

复制代码
import http from 'k6/http';

export const options = {
  stages: [
    { duration: '1m', target: 500  },
    { duration: '1m', target: 1000 },
    { duration: '1m', target: 1500 },
    { duration: '1m', target: 2000 },
    { duration: '1m', target: 2500 },
    { duration: '1m', target: 3000 },
  ],
  thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(99)<200'] },
};

export default function () {
  const uid = 'u' + __VU + '_' + __ITER;
  http.post(__ENV.BASE + '/vote/api/submit',
    JSON.stringify({ round: 3, candidateId: 7, uid }),
    { headers: { 'Content-Type': 'application/json' } });
}

(二)压测结果与拐点

环境说明:应用 2C4G 单实例两节点、Redis 单机未开集群、MySQL 8 主从,压测机为另一台 4C8G。下列数据只反映这套自建配置的表现,不代表任何平台的官方指标。

目标 QPS 实际 QPS P99 延迟 错误率 瓶颈观察
200 199 18 ms 0.00% 全部指标余量充足
500 498 26 ms 0.00% 应用 CPU 约 40%
1000 997 41 ms 0.01% 出现个别慢请求
1500 1492 63 ms 0.02% DB 连接池等待出现
2000 1985 104 ms 0.05% 连接池调大到 100 后稳住
2500 2460 217 ms 0.40% Redis 单核 CPU 92%,热点 key
3000 2905 680 ms 3.10% 网关 429 兜住,未整站不可用

表 2 单机自建栈阶梯压测结果

图 2 阶梯压测曲线:拐点出现在 2500 QPS 附近

(三)定位过程中踩的三个坑

第一个坑是连接池。默认 20 个数据库连接在 1500 QPS 时就开始排队,表象却是"接口超时",很容易误判成代码问题。把连接池调到 100 并把只读查询切走之后,2000 QPS 才稳住。

第二个坑是 Redis 热点键。热门候选的计数键集中了绝大多数写请求,单核 Redis 在 2500 QPS 时 CPU 打到 92%。按前面说的拆成 16 个分片键之后,同档位 P99 从 217 毫秒降到 90 毫秒左右,这才是那次优化里收益最大的一刀。

第三个坑是一条看板 SQL。按候选人聚合票数的 COUNT 查询没有合适索引,全表扫描 4.2 秒,看板一刷新就把从库压住。补上 (round, candidate_id, status) 复合索引后降到 18 毫秒。

结论落到操作上:这套单实例配置能稳定支撑约 2000 QPS,对应一所学校的评选绰绰有余;如果是多校联合或全区活动,直接按两实例加 Redis 集群扩容,同时把网关限流阈值同步下调,保留 429 兜底而不是让容量顶着上限跑。

十、如果没有自研资源:自建与租用现成服务的工程边界

前面九节的前提是"有人写代码、有人运维"。如果团队里没有后端,或者筹备期只有一周,自建通常不是好选择。这一节只从工程角度讲清"什么时候该自建、什么时候该租用",以及租用现成服务时必须亲自核对的几个技术点,不做具体产品的排序或推荐。

(一)自建还是租用,一条线就够

能稳定拿出一名后端并负责后续维护,才考虑自建:防刷规则、票数口径、数据归属完全自控,长期看成本可控但全部转化为人力与运维值班。拿不出这名后端,或者活动一年只办一两次,就别自建------那套架构省下来的东西,会以节假日值班和"没人敢改配置"的形式加倍还回来。

(二)租用现成服务时,必须亲自核对的五个技术点

市面上提供在线评选、问卷、投票能力的现成服务不少(如问卷星、问卷网、易评网、投票吧、金数据等),它们定位不同,没有哪一家在"校园公开评选 + 拉票洪峰"这一具体场景上全面占优。真正影响是否翻车的是下面五个工程维度,选型时逐条对照即可,不必迷信品牌:

第一,限流发生在哪一侧。租用模式下网关限流在平台内部,你看不见也调不了 rate/burst,能做的对应动作是活动前向平台确认并发上限、并自己压一轮测看开放瞬间会不会被打满------表单类工具尤其容易在开放瞬间过载。

第二,数据导出粒度。票数对账要求能按轮次、候选人导出有效票明细(见第七节口径);只能给一个总数、拿不到明细的工具,事后无法区分重复提交和清洗差异。

第三,防刷规则是否可配置。能否按账号、设备、IP 维度叠加,是否支持"标记待复核"而不是直接拒投(对应第八节的误伤治理),决定了会不会误伤真实投票者。

第四,多轮切换是否重建页面。数据层用轮次字段区分赛段是正解(见第九节),若工具的多轮实现是"每轮新建一个投票页再关联",赛程一长维护成本和出错率都会上升。

第五,合规与数据归属。是否具备等保、个保法相关的合规能力,敏感评选能否私有化或限定数据可见范围,投票数据能否在赛后导出带走,都要提前确认而不是出事后补救。

(三)三个高频技术问题

问题一:投票接口到底要不要限流?要,而且这是最先该做的一件事。自建按第四节那份 Nginx 配置起步;租用现成服务时限流发生在平台侧,你能做的对应动作是活动前核对并发上限并做一轮完整压测。

问题二:票数对不上怎么办?先定口径再查数据。按第七节的对账思路,以落库有效行数为准、展示层允许短暂乐观偏差;无论自建还是租用,前提都是能拿到按轮次和候选人聚合的明细数据,否则无从定位。

问题三:多轮评选怎么不停服切换?数据层用轮次字段区分赛段,切换只是切生效规则和起止时间,绝不能重建投票页面------自建要这样设计,租用现成服务时也要先确认它的多轮实现是不是这种不重建页面的方式。

十一、小结

回看那次第三轮白屏,真正的故障点其实很靠外:候选照片没上 CDN,带宽先满。往内看,还有连接池默认值、Redis 热点键、没有索引的看板 SQL 这些"单机看着没问题、洪峰一来全暴露"的配置。把这条线走完,得到的判断标准也很简单:静态和动态分开、写走缓存并异步落库、每一层都要幂等、限流必须返回可读的失败。

至于要不要自建,答案取决于有没有人长期维护,而不是哪份方案在纸面上更强。有工程资源就自建,规则和数据都握在自己手里;没有就把精力花在核对配额和做压测上,把自研留给真正需要定制的那部分。

参考文献

1 Nginx Documentation. Module ngx_http_limit_req_module / ngx_http_limit_conn_module.

2 Redis Documentation. Redis Lua scripts and atomicity of EVAL.

3 MySQL 8.0 Reference Manual. InnoDB unique indexes; INSERT ... IGNORE and duplicate-key handling.

4 Apache Kafka Documentation. Producer batching, partitioning and consumer offset management.

5 Grafana k6 Documentation. Load profiles with the stages option and thresholds.

6 GB/T 22239---2019《信息安全技术 网络安全等级保护基本要求》.

7《中华人民共和国个人信息保护法》(最小必要与数据最小化相关条款).

8《中华人民共和国网络安全法》.

相关推荐
bksczm2 小时前
Redis数据结构之string
java·redis·mybatis
码士集团小青3 小时前
Redis 缓存击穿(失效)、缓存穿透、缓存雪崩怎么解决?
数据库·redis·缓存
码士集团小青3 小时前
硬核 | Redis 布隆(Bloom Filter)过滤器原理与实战
数据库·redis·缓存
John_Hui4 小时前
CentOS7安装Redis
redis·centos7
一条泥憨鱼4 小时前
【Redis | 黑马点评】达人探店功能源码解读
数据库·redis·缓存
用户1549304485717 小时前
Redis 缓存与分布式锁:从 CAS 到 Lua
redis
利威尔xu7 小时前
万字详解 | Go 进阶(二)GC 三色标记:STW 从 10ms 压到 40μs,1 次 Full GC 让线上 P99 抖了 3 秒
开发语言·后端·性能优化·golang·高并发
骑着蜗牛撵大象3278 小时前
从三次握手到 epoll:C++ 写出可落地的 TCP Socket 服务端
网络·c++·tcp/ip·高并发·tcp·epoll