ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

投票系统防刷票实战:从数据模型到规则引擎的完整方案

投票系统防刷票实战:从数据模型到规则引擎的完整方案 简介一份基于Java Web技术的投票系统源代码主要面向Java初学者与需要完成课程设计的在校学生解决“搭建在线投票功能并思考防刷票机制”的实际需求。系统以教师投票为核心运行后展示“欢迎给老师投票”的标题并在表格中列出教师编号、姓名与当前得票数得票数通过红色进度条和数值同时呈现点击投票链接后票数即时更新反馈直观。源码除完整实现上述投票流程外还对数据库访问代码进行了封装提供独立的DAO包与数据库工具类复用性较好同时保留“如何防止刷票”的扩展思考点引导分析常见安全隐患适合作为理解Web应用分层、学习请求处理与数据持久化的教学案例。压缩包共包含24个文件以Java源代码、编译后的class文件、JSP页面为主另有依赖库和XML配置文件整体体积约956KB结构精简方便导入开发工具运行和二次修改。目前已有1931人学习使用无论作为课程设计起步模板还是入门实战项目都能帮助读者快速理清投票计数、进度条展示、数据库交互等关键实现细节。1. 投票系统防刷票先搞清楚刷票的人到底在刷什么最近给一个模拟项目X做投票系统上线当晚票数曲线就开始不正常同一个候选人的票以每秒2次的节奏稳定上涨间隔几乎精确到毫秒全部来自同一个IP且请求里没有带任何浏览器特征。这不是真人投票是有人在拿脚本刷票。投票系统一旦要做公开活动刷票几乎必然出现差别只在于你的源代码有没有提前防住。防刷票的核心不是“拦住所有脚本”而是“提高刷票成本让脚本和真人无法同频”。本文会从数据模型、接口校验、限流代码、配置化风控引擎一条线讲下来并穿插一些我自己踩过的坑。适合的人群是需要自己从零写一个投票系统、或者正在接手一个已经能跑但怕被刷的活动页的开发者。读完你至少知道防刷代码该写在哪一层、参数怎么设、上线后怎么调。2. 投票系统防刷票的架构与核心策略从数据模型到接口设计2.1 数据模型怎么设计才能撑住防刷校验很多投票系统翻车源头是表结构设计得太天真一张表只有 id、candidate_id、created_at连投票人是谁、从哪个IP来的都不记录。等到刷票发生你想按用户去重、按IP做分析都没有依据。所以第一步是把数据模型补齐至少要能回答三个问题谁投的、用什么设备投的、从哪个网络入口投的。我一般会这样设计投票记录表CREATE TABLE vote_record ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, activity_id INT NOT NULL COMMENT 活动ID, candidate_id INT NOT NULL COMMENT 候选人ID, voter_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 用户ID匿名时为0, device_fp CHAR(32) NOT NULL DEFAULT COMMENT 设备指纹, ip VARCHAR(45) NOT NULL DEFAULT COMMENT 客户端IP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_candidate (candidate_id), KEY idx_activity (activity_id), UNIQUE KEY uk_voter_activity (voter_id, activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明uk_voter_activity是数据库层的第一道兜底保证一个登录用户在同一个活动里只能投一次。这里有一个前提业务规则是“每人每活动最多一票”。如果活动规则是“每人每天最多X票”那把唯一键改成(voter_id, activity_id, DATE(created_at))这种组合也行但更好的是拆一张投票批次表否则很难控制并发下的合法上限。注意device_fp和ip一定要存下来。它们看起来只是“审计字段”但后面做风控规则回放、判断误杀时这两列就是你的唯一证据。匿名用户没法用 voter_id 去重就得靠 device_fp 加 IP 的组合做约束。没有这些字段刷票发生后你最多只能看到票数异常却没法定位到底是谁在刷。2.2 投票接口的幂等性与校验顺序投票是一个写操作但它的请求可以被被轻松重复发送。用户手抖点两下、前端重试、恶意脚本多线程打接口都是同一类问题。所以投票接口必须是幂等的同一个投票人或者同一个设备对同一个活动重复提交只能成功一次。幂等不能靠“先查再写”因为在并发情况下两个请求都查不到记录然后都插入成功。正确做法是在接口入口先做去重再走到业务校验。我习惯的校验顺序是这样的活动是否存在、是否在投票时间内基于 IP / 设备指纹的限流检查这一步挡住高频脚本用户是否登录登录用户需要校验 token该用户是否已经投过票验证码校验通常放在第4步之后避免正常用户每次都要输验证码写入投票记录更新票数。这个顺序的核心是把代价最低、最粗暴的检查放在前面把需要用户交互或依赖数据库的检查放在后面。高频脚本一般在第2步就被拦掉了根本走不到验证码也打不穿数据库。如果你把验证码放最前面机器人可能导致所有正常用户都被验证码烦走。2.3 为什么不能把防刷逻辑只写在客户端如果你在页面里禁用按钮、加一个30秒倒计时、或者用 localStorage 标记“已投票”这些对真人有点用但对刷票者一点用都没有。因为刷票者不打开你的网页交互他们直接抓你提交投票的那个 HTTP 接口用脚本循环调用。前端的任何限制在中台脚本面前就是一层透明纸。我见过一个系统把防刷逻辑做在 JS 里点完投票后生成一个 token 附带在请求里后端只校验这个 token 存在就放行。结果刷票者直接看源码发现 token 是随机生成的于是模拟请求批量提交数据库瞬间被灌了上万条。这个教训说明所有防刷规则必须放在服务端并且不能信任任何来自客户端传上来的所谓“加密参数”。前端唯一能做的事是提升正常用户的操作门槛比如人机交互、按钮置灰、遮罩层但后端必须自己做全套校验。源代码层面要防的从来不是“用浏览器的人”而是“直接打接口的脚本”。3. 用 Python/Flask 实现一个带防刷的投票后端关键代码与参数说明3.1 环境与最小工程结构这里用 Flask 加 Redis 做一套最小可跑的防刷投票后端。Redis 用来存限流计数、去重标记和票数缓存MySQL 只负责最终落库。为什么用 Redis因为防刷逻辑需要高频读写和原子操作Redis 的INCR、SETNX、Sorted Set都比在数据库里做SELECT再UPDATE更合适而且能设置过期时间自动清理。工程结构保持简单vote_system/ ├── app.py ├── rule_engine.py ├── config.py ├── requirements.txt └── schema.sql依赖就三件套flask、redis、requests。生产环境建议再挂 gunicorn 做多进程但下面的代码单进程即可演示完整逻辑。3.2 投票接口的完整防刷实现第一步是定义基础工具函数用来取 IP 和计算设备指纹import hashlib import time import uuid from flask import Flask, request, jsonify import redis app Flask(__name__) # 假设 redis 已经启动监听默认端口 r redis.Redis(host127.0.0.1, port6379, db0, decode_responsesTrue) def get_ip(): 从请求头或直连地址获取客户端 IP xff request.headers.get(X-Forwarded-For) if xff: # 只取第一个 IP生产环境必须由 nginx 过滤可信来源 return xff.split(,)[0].strip() or request.remote_addr return request.remote_addr or unknown def get_device_fp(): 根据请求头生成一个简易设备指纹 raw (request.headers.get(User-Agent, ) or ) | \ (request.headers.get(Accept-Language, ) or ) return hashlib.sha256(raw.encode(utf-8)).hexdigest()[:16]逻辑说明get_ip里优先读X-Forwarded-For因为在真实部署中 Flask 直接面对的是 nginx远程地址是 nginx 的内网IP。但要注意X-Forwarded-For可以被客户端伪造所以只信任由 nginx 重写的头否则防刷直接失效。get_device_fp用 User-Agent 和 Accept-Language 做哈希这只是一个基础指纹能挡住“用同一个脚本工具随机UA”的初阶刷票者挡不住故意伪造指纹的专业脚本。但在匿名投票场景里它有成本极低的优势。接下来是三层限流函数def check_rate_limit(ip, device_fp, user_id): 返回 (是否放行, 提示信息) now int(time.time()) window 10 # 时间窗口10秒 limit 3 # 窗口内最多允许 3 次 # key 使用 IP 设备指纹避免同一出口IP下多用户互相干扰 key frate:{ip}:{device_fp} pipe r.pipeline() pipe.zremrangebyscore(key, 0, now - window) # 清理窗口外的旧记录 member f{now}:{uuid.uuid4().hex} pipe.zadd(key, {member: now}) pipe.zcard(key) pipe.expire(key, window * 2) _, _, count, _ pipe.execute() if count limit: return False, 操作太快请稍后再试 if user_id: # 登录用户维度每天最多 10 票 day_key fuser:{user_id}:{time.strftime(%Y-%m-%d)} day_count r.incr(day_key) if day_count 1: r.expire(day_key, 86400) if day_count 10: return False, 今日投票次数已达上限 return True, 逻辑说明限流用 Redis 的 Sorted Set 存储每个请求的时间戳记录。每次请求先删掉窗口外的过期记录再插入当前记录然后数集合里还剩多少条。如果超过阈值就拒绝。用uuid.uuid4拼到 member 里是为了避免同一秒内多个请求使用相同 member 导致集合里只保留一条记录那样计数会严重偏低。这个坑很多人容易踩直接用时间戳当 member一秒内的并发请求会被 Redis 当作同一个成员。参数说明window10秒、limit3是一个比较保守的“真人手速”阈值。真人连续点投票10秒内超过3次已经是异常操作了。如果活动是粉丝投票真实用户可能更积极可以放宽到window10, limit5。user_id等于 0 时跳过用户维度限制因为匿名用户没有唯一标识。然后是投票接口app.route(/api/vote, methods[POST]) def vote(): ip get_ip() device_fp get_device_fp() # 简化示例从 Authorization 头里解析用户ID auth_header request.headers.get(Authorization, ) user_id parse_token(auth_header) # 假设已实现 if not user_id: return jsonify({code: 401, msg: 请先登录}), 401 ok, msg check_rate_limit(ip, device_fp, user_id) if not ok: return jsonify({code: 429, msg: msg}), 429 data request.get_json(forceTrue) or {} activity_id data.get(activity_id) candidate_id data.get(candidate_id) if not candidate_id or not activity_id: return jsonify({code: 400, msg: 参数缺失}), 400 # 业务校验活动时间、候选人是否存在略 # 用 Redis SETNX 保证幂等同一用户同一活动只允许一次 dedup_key fvote:{activity_id}:{user_id} if not r.set(dedup_key, 1, nxTrue, ex86400): return jsonify({code: 409, msg: 您已投过票}), 409 # 写入 MySQL表里有 uk_voter_activity 唯一键做兜底 # insert_vote(activity_id, candidate_id, user_id, ip, device_fp) # 票数先写 Redis后续异步同步到数据库 r.incr(fcand_count:{candidate_id}) return jsonify({code: 0, msg: 投票成功})逻辑说明限流通过后才进入业务逻辑。SETNX是原子操作多个并发请求同时执行r.set时只有一个能拿到True其余返回False这就保证了同一用户同一活动不会在并发下重复投票。ex86400把这个去重 key 的过期时间设为一天实际活动一般不超过一天如果是长期活动可以设为活动截止时间戳减去当前时间的差值。注意Redis 去重 key 不能替代数据库唯一键只能作为第一道闸门。因为 Redis 内存里的 key 可能因为重启而丢失数据库唯一键才是最终防线。正确的落库做法是捕获IntegrityError如果插入时唯一键冲突就回滚并返回“已投票”。我这里没贴完整 SQL 操作但生产代码必须带上。3.3 按 IP 设备指纹 用户态三层限流的参数设置代码里的三个限流维度不是都要同时开启而是要有侧重。下表是我在项目里常用的初始参数维度Redis Key 模式窗口阈值适用场景纯 IPrate:ip:{ip}10 秒5同一 IP 高频访问但不适合 NAT 出口IP 设备指纹rate:{ip}:{fp}10 秒3默认推荐误杀率低登录用户user:{uid}:{date}24 小时10已登录用户的总投票次数上限活动全局activity:{id}:global_rate5 秒200保护后端防止爆量参数怎么调我一般这样活动刚开始时用宽松阈值先观察一两个小时如果票数曲线明显有“脉冲式增长”说明脚本在试水把窗口缩小或阈值降半如果出现正常用户投诉被限制就把 IP 维度关掉只保留 IP指纹维度。核心原则是“宁愿放宽最初几小时也不要一上来就误杀真实用户”。4. 投票系统防刷票的避坑指南5 个最常见的翻车点4.1 现象IP 限流误杀同一公司用户一个投票活动上线后大量员工在同一办公网投票出口 IP 只有一个。因为rate:ip:{ip}这层限流设置的是 10 秒 5 次办公室几十个人同时点投票直接触发 429。用户反馈“我一票都投不了”。原因很简单IP 是多个自然人共享的不能把 IP 当作“单个人”的身份。解决方法是把限流 key 改成 IP 设备指纹组合这样每个真实用户即使共用一个 IP都有自己的指纹。如果登录系统的话优先对已登录用户走user_id维度IP 限流只作为第二层。另外在判断用户是否为正常操作时可以看一眼同一 IP 的指纹种类数量如果 1 个 IP 对应几十种不同的 UA那是正常群体如果几百个请求都只有 1 种 UA那就是脚本。4.2 现象Redis 里计数过期导致票数丢失有次活动我直接用 Redis 的INCR记录候选人票数没做持久化。运维半夜重启了一下 Redis 容器所有票数清零。更麻烦的是重启后用户去投票会再次成功因为去重 key 也没了等于是同一个用户重新投了一遍数据库里插入了两条记录Redis 里的票数从零开始涨。这就是典型的缓存计数和数据库状态不一致。解决方法是Redis 里的票数只作为热数据展示每次投票后写 MySQL 表或者用事务把“插入投票记录”和“更新候选人票数字段”作为原子操作。具体做法是在 MySQL 里执行UPDATE candidate SET vote_count vote_count 1 WHERE id ?这个语句自带行锁不会丢。Redis 也可以留着做读缓存但就算 Redis 全丢也能从数据库恢复。不要把所有鸡蛋放在 Redis 一个篮子里。4.3 现象并发请求绕过校验有开发者写防刷逻辑是“先查 Redis 是否有投票记录没有再插入”。在低并发下没问题一旦用脚本开 50 个线程同时提交多个请求都查不到“已投票”于是全部进入插入逻辑造成重复投票。这个问题的本质是“检查-执行”两步不是原子的。解决方法是像我前面代码里那样把去重做成一个原子操作用SETNX设一个标志位谁抢到就是谁的同时数据库保留唯一键插入时捕获唯一键冲突。这两层任何一个生效都能挡住并发穿透。我在项目里遇到过明明 Redis 已经 SETNX 成功了但数据库插入还是冲突的情况那多半是用户在某次请求里先操作了别的事务把标志位清了所以规则要一致去重标志位只能在一个事务流程里创建不能在别处随意删除。4.4 现象验证码被 OCR 打穿为了防刷票我在校验流程里加了图形验证码。结果刷票者接了个开源 OCR 模型识别率居然有 80%脚本降低了请求频率绕过验证码照样刷。问题在于普通四位数图形验证码对现代 OCR 来说成本已经很低而且现在很多打码平台能实时真人识别一张几分钱。解决方法是不要依赖单一的图形验证码。更好的策略是“渐进式挑战”正常投票流程不弹验证码只有触发了 IP 或设备限流规则的用户才需要验证码验证码形式用滑块、点选文字、滑动拼图这类需要人机交互的或者接行为风控 SDK。同时把验证码校验放在频率限制之后这样大部分正常用户永远不需要碰验证码脚本则需要每票都过验证码成本大幅上升。4.5 现象日志/审计缺失导致无法复盘被刷之后最惨的不是票数异常而是你不知道攻击是怎么进来的。有一次活动结束我拿到一个异常的票数清单想分析刷票特征却发现投票表里只有 user_id 和 candidate_id没有 IP、没有设备指纹、没有请求时间分布。结果只能从数据库的回档日志里猜非常被动。从那以后我要求投票接口必须打审计日志至少包含IP、设备指纹、本次请求是否命中限流、是否命中验证码、最终是否投票成功、响应状态码。这些日志存到独立的日志文件或者日志表不参与业务逻辑。有了这些数据你可以回放攻击过程也能用它来验证新规则会不会误伤正常用户。5. 把防刷规则做成可配置的风控引擎规则引擎与动态调整5.1 规则模型阈值、窗口、动作写死限流参数的代码上线后每次调参都要改代码重新发布这在活动运营中是灾难。更合理的做法是把防刷规则做成配置运维和运营可以直接改 JSON不需要动代码。规则模型其实很简单就是三个要素加一个动作匹配维度按 IP、设备指纹、用户 ID或者这几者的组合时间窗口比如 10 秒、1 分钟、1 天阈值窗口内最大允许次数动作放行、拒绝、跳验证码。举个例子一套规则配置文件可以这样{ rules: [ { name: ip_device_10s_3, scope: ipdevice, window: 10, threshold: 3, action: verify }, { name: user_day_10, scope: user, window: 86400, threshold: 10, action: deny } ] }这里action: verify的意思是触发规则后不直接拒绝而是要求弹验证码。因为有些正常用户手速确实快我们先给他一个验证码能过就可以继续投。action: deny则直接返回 429。这样规则从“非黑即白”变成了“分级处置”误杀率能降低不少。5.2 一个支持 JSON 配置的限流中间件我可以把前面的check_rate_limit泛化成一个规则引擎。先定义规则加载和计数逻辑import json import time import uuid class RuleEngine: def __init__(self, redis_conn, config_path): self.r redis_conn with open(config_path, r, encodingutf-8) as f: self.rules json.load(f)[rules] def _build_key(self, scope, ctx): if scope ip: return frate:ip:{ctx[ip]} if scope ipdevice: return frate:{ctx[ip]}:{ctx[device_fp]} if scope user: return frate:user:{ctx[user_id]} raise ValueError(funknown scope: {scope}) def check(self, ctx): for rule in self.rules: key self._build_key(rule[scope], ctx) window rule[window] threshold rule[threshold] now int(time.time()) pipe self.r.pipeline() pipe.zremrangebyscore(key, 0, now - window) pipe.zadd(key, {f{now}:{uuid.uuid4().hex}: now}) pipe.zcard(key) pipe.expire(key, max(window * 2, 60)) _, _, count, _ pipe.execute() if count threshold: return rule[action] return allow逻辑说明RuleEngine把每条规则都套到当前请求上只要其中一条超过阈值就返回这条规则指定的动作。_build_key按作用域生成不同的 Redis key互不干扰。这里保留了ipdevice作为默认组合user维度只对登录用户有效。然后 Flask 接口只需要这样调用engine RuleEngine(r, rules.json) def before_vote(ctx): action engine.check(ctx) if action deny: return False, 请求过于频繁 if action verify: return False, 请完成验证码 return True, 参数说明规则文件里的window单位是秒threshold是窗口内最大请求数。我建议每个规则加上enabled字段方便灰度开关。上线初期可以把action设成audit只记录不拦截看正常流量分布再逐步开启验证码或拒绝。5.3 数据回放与规则调参怎么验证规则有效规则引擎配置好了不能拍脑袋觉得“应该没问题”。我会用历史日志做回放验证。具体做法是从之前的投票日志里抽出一段时间的正常请求把它重新跑一遍规则引擎看有多少正常请求会被误判再抽出一段被明确刷票的攻击流量看规则能不能拦住。回放时只需要把日志里的 IP、设备指纹、user_id 和时间戳构造成请求上下文喂给engine.check统计每个动作的命中比例。如果正常流量被deny的比例超过 1%说明参数太严需要放宽阈值如果攻击流量命中deny的比例低于 90%说明规则漏了不少需要再加维度或降阈值。这一步非常重要。很多时候你调参凭感觉结果上线后要么拦掉了真人要么放走了机器人。回放数据能让你在活动开始前就对规则效果有把握。调参的时候我会先改 JSON 里的阈值然后重新跑回放脚本直到两个指标都达标才发版。6. 最后的落地建议先从这4个动作开始再逐步加码如果你的投票系统已经写好了但还没做任何防刷别急着把风控引擎搓出来。先做这几件事成本最低、见效最快第一后端必须记录每个投票请求的 IP 和设备指纹哪怕现在不用也要先存下来。第二给投票表加上(voter_id, activity_id)唯一约束并用SETNX做接口幂等这样重复提交和并发刷票从根源上被拦住。第三在投票接口加一个最简单的 IP 加设备指纹维度限流10 秒内超过 3 次就弹验证码或直接拒绝。第四所有拒绝请求打审计日志方便你事后知道规则有没有误杀。这四个动作做完你的投票系统已经能挡住八成以上的脚本刷票。再往后才需要配置化风控引擎、行为验证码、设备指纹升级这些高级玩法。不要一开始就去追求“无感验证”“设备唯一标识”这类复杂方案它们要么接入成本高要么有隐私合规风险而你的活动可能只需要撑过一个周末。最后说一个我自己的习惯每次投票活动上线我都会先跑一轮压测用脚本模拟 100 个并发请求打到投票接口确认唯一键和限流生效再在监控面板上盯着票数增长曲线。如果票数曲线是平滑上升可能是正常的如果呈阶梯式跳涨基本就是刷票。我翻过几次车都是因为只写了代码没做验证。希望帮到你至少别让刷票的人抢走本该属于真实用户的票。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进