导读:做一个评选投票或问卷 H5,上线后最常见的翻车不是页面崩,而是结果不可信:有人用脚本几分钟刷出几百票,两个候选人票数对不上,后台删掉刷票后总数又变了。单纯在网关加 IP 限流挡不住真人拉票和换设备刷量。本文给一套业务侧的正确性设计:以唯一约束保证一人一票、按身份强度分级限流、高并发下计票不错不乱、异常票先进隔离区复核而不是直接删除,让最终票数可解释、可复核、可回放。
网关限流、验证码、设备指纹能挡住一部分机器流量,但它们解决的是"请求别太多",不是"这一票该不该算"。投票业务真正要回答三个问题:
IP 会变(公司、校园共用出口 IP)、设备可以伪造、验证码可以打码。所以防刷要分层,而把"一人一票"的正确性压在数据库约束和业务规则上,网关只做第一道削峰。
不管前面加多少校验,最后落库必须有一条硬约束兜底:在"活动 + 投票对象 + 投票人唯一标识"上建唯一索引。任何重复提交,数据库这一层都只会进一条。
-- 投票人标识按活动配置选取:登录用户用 user_id,弱登录用设备/授权 unionid
CREATE TABLE vote_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
activity_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
voter_key VARCHAR(64) NOT NULL, -- user_id / unionid / 设备指纹,按身份策略定
id_level TINYINT NOT NULL, -- 身份强度:3 登录 2 授权 1 设备 0 匿名
status VARCHAR(12) NOT NULL DEFAULT 'valid', -- valid/quarantine/canceled
ip VARCHAR(45),
created_at DATETIME NOT NULL,
UNIQUE KEY uk_one_vote (activity_id, option_id, voter_key),
KEY idx_status (activity_id, status)
);
-- 每个候选的计票表,只统计 valid 票
CREATE TABLE vote_count (
activity_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
valid_cnt INT NOT NULL DEFAULT 0,
PRIMARY KEY (activity_id, option_id)
);写入用"插入投票记录 + 计票 +1"放在同一事务,重复插入捕获唯一键冲突后幂等返回,不重复计票。
def cast_vote(activity_id, option_id, voter):
try:
with tx() as conn:
conn.execute("INSERT INTO vote_record(activity_id,option_id,voter_key,"
"id_level,status,ip,created_at) VALUES(%s,%s,%s,%s,'valid',%s,NOW())",
activity_id, option_id, voter.key, voter.level, voter.ip)
conn.execute("UPDATE vote_count SET valid_cnt=valid_cnt+1 "
"WHERE activity_id=%s AND option_id=%s", activity_id, option_id)
return "ok"
except DuplicateKey:
return "already_voted" # 一人一票,重复请求幂等很多活动为了降低参与门槛不强制登录,这时"一个人"有强弱之分,要分级而不是一刀切:
强身份:已登录账号(user_id)或微信授权 unionid,一人一票最可靠; 弱身份:未授权,用设备指纹 + 本地随机标识,允许一票但标记为低可信度; 匿名:连设备标识都拿不到时,只允许在强验证码 + 严格频控下投票,且默认进隔离区复核。
弱身份和匿名票先收、标记、不一定立刻计入:它们正常落库,但 id_level 低,计票时可按活动配置选择"实时计入但加权展示"或"先进隔离、复核后计入",避免门槛过高劝退真实用户,也不让机器票直接冲榜。
热门投票瞬间上万 QPS,直接每次 UPDATE vote_count +1 会把行锁打热。常见做法是缓存原子计票抗量、异步批量落库,再用投票明细做对账兜底。
-- 缓存内对单个候选原子自增,配合活动维度的频控键
local cnt = redis.call('INCR', KEYS[1])
local ip_cnt = redis.call('INCR', KEYS[2])
if ip_cnt == 1 then redis.call('EXPIRE', KEYS[2], ARGV[1]) end
return {cnt, ip_cnt}但缓存计数只是加速,权威票数永远以 vote_record 明细为准:定时任务按 status='valid' 重算每个候选的总数,与缓存/计票表比对,出现漂移就以明细修正。这样即使缓存重启、消息丢一条,最终结果也能从明细完整重放,不会出现"票数和实际投票人数对不上"。
直接删除可疑票有两个问题:误判了无法恢复,删除瞬间总数跳变容易引发争议。更稳妥的是引入隔离状态,把"计票"和"判定"解耦:
quarantine,不计入 valid_cnt;canceled,误判则恢复为 valid 并补计票。def is_suspicious(voter, recent):
if recent.ip_rate(voter.ip, window=60) > LIMIT_PER_MIN: return True
if recent.device_rate(voter.device_fp, window=300) > LIMIT_DEVICE: return True
if voter.level <= 1 and recent.new_account_ratio() > 0.8: return True
return False
def settle_quarantine(rec, decision):
if decision == "confirm_cheat":
update_status(rec.id, "canceled")
elif decision == "restore":
with tx() as c:
updated = c.execute("UPDATE vote_record SET status='valid' "
"WHERE id=%s AND status='quarantine'", rec.id)
if updated:
c.execute("UPDATE vote_count SET valid_cnt=valid_cnt+1 "
"WHERE activity_id=%s AND option_id=%s", rec.activity_id, rec.option_id)对外展示可以同时给出"实时票数(含待核)"和"有效票数(已核)"两个口径,活动结束以有效票为准,全过程有记录、可申诉。
投票、问卷这类活动建议沉淀一套通用"投票中心":活动配置里声明身份策略(是否强制登录)、频控阈值、可疑规则与计票口径;接入方只负责页面和候选管理。明细、计票、隔离三类表分离,实时数走缓存、权威数走明细重算,复核动作全部留痕。活动结束后冻结计票表并归档明细,既方便复盘真实拉新效果,也能在结果被质疑时逐条回放。
A:以登录账号或授权标识作为投票人主键,在"活动+候选+投票人"上建唯一索引,重复提交被数据库幂等挡掉;未强制登录时用设备标识做弱身份并降低其计票可信度,IP 只用于频控不作为唯一身份。
A:不建议直接删除,应把命中异常规则的票置为隔离状态、暂不计入有效票,再经规则或人工复核确认后作废或恢复,避免误判无法挽回,也让最终票数可解释、可申诉。
A:实时数可用缓存原子自增抗并发,但权威票数以投票明细为准,定时按有效票明细重算并与计票表对账修正,即使缓存重启或消息丢失也能完整重放结果。
以上为投票、问卷类轻应用的防刷与计票正确性实践,频控阈值和可疑规则需按活动热度与参与门槛调整。核心是把"请求削峰"交给网关、把"一人一票"交给唯一约束、把"结果可信"交给明细重算与隔离复核,票数才经得起复盘和质疑。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。