☰
从零重构微信投票系统:数据模型、防刷体系与高并发架构实战
2026/10/13 17:13:03 网站建设 项目流程

做投票系统这事,听起来好像很简单:一个页面,几个选项,点一下提交,完事。可真要把一套投票系统做到既能扛住公众号几万人同时参与、又能灵活切换多种投票玩法、还得在活动期间顶住各种恶意刷票冲击的时候,你会发现,这个“小玩意”其实一点都不小。

去年年初我开始重构手上的微信投票源码系统,目标很直接:把原来只能做单选排名的那套老代码,升级成一个支持多场景、多规则、多终端的全能投票引擎。当时和某机构负责活动运营的朋友聊需求,他列了一串让我头皮发麻的功能点:多选限投、权重计票、实时榜单、按区域分组、防重复投票、活动模板、结果导出……甚至还有“能不能让不同渠道进来的用户看到不同选项顺序”这种细节要求。当时的我满口答应,心理其实在打鼓。

这篇文章就是围绕这次升级过程,把我设计数据模型、集成微信授权、搭防刷体系、处理高并发、扩展玩法这些环节的完整思路和踩过的坑记下来。标题上虽然写着“全网独家”,但代码从来不是靠口号变好的,都是一版一版磨出来的。

1. 重写之前,先搞清楚“投票系统到底在卖什么”

接手老代码的第一件事,不是急着改功能,而是把原来那套投票系统的运行逻辑理了一遍。老系统是好几年前随手写的,结构上就是“活动—选项—票数”三板斧,能用,但完全谈不上“全能”。

1.1 用户要的从来不是投票,是活动效果

和那位运营朋友深聊之后,我得出的结论是:客户要的不是“能投票”这个动作,而是投票背后的活动效果。具体拆开就是三件事。

第一,参与门槛要低。用户从微信公众号里点个菜单、扫个二维码就能进页面,最好连注册都不用,微信授权后直接投。

第二,规则要灵活。有的活动要每人每天能投三次,有的要一次性投五个选项,有的明面上说是“公开投票”但其实运营方早就内定了权重分配,还有的想搞抽奖式投票——投完一票自动送一次抽奖机会。这些需求千奇百怪,但底层都是同一套投票引擎,只是参数不同。

第三,数据要可信。活动结束后运营方要能拿出干净的报表,告诉合作伙伴“这次投票有多少真实参与、每个选项多少票、增长曲线是否健康”。如果后台数据充满水分,活动做得再热闹也没有长期价值。

1.2 老系统的三个致命短板

老代码第一个短板是只能单选。数据库里vote_record表就是每次插入一条用户ID加选项ID,连“多选”都只能在业务层循环插入,没有批次概念,更谈不上“限选X项”。

第二个短板是完全没有防刷。谁都能调接口,同一个微信号可以反复换一个假ID投,IP限制也没做。当时有一次活动被脚本刷了三万票,运营方完全没察觉,直到看数据曲线才发现异常。这属于事故级问题。

第三个短板是扩展性差。页面写死了一套模板,想换主题风格就得改PHP文件;想给某个活动开一个“实时票数滚动播报”功能,几乎要动核心表结构。每次需求变更都得动刀,代码越改越乱。

1.3 重构目标:不是修修补补,而是重写引擎

和老系统对比之后,我决定不修了。保留原有数据的迁移方案,但核心代码全部重写。重写之前先定目标列表,每一条都写清楚验收标准。

  • 投票模型必须支持:单选、多选、限选、权重投票、匿名投票、实时榜。
  • 微信集成必须支持:公众号菜单、扫码、微信内H5分享页,授权后拿到用户唯一标识。
  • 防刷体系按层次拆成:身份层、频率层、行为层、运营复核层。
  • 高并发能力承诺:单活动10万级真实用户、5000并发峰值不宕机、榜单秒级更新。
  • 运营端要能配置活动,不写代码就完成新玩法上线。

这套目标列表看着简单,真正落地时每一步都有不少坑。下面按模块拆开讲。

2. 投票业务的数据模型:表结构、状态机和边界条件

投票系统乍一看是简单业务,但当你把“多种投票玩法”叠加进去的时候,表结构就不是一张vote_record能搞定的了。我重构之后的模型,核心是四张表加若干辅助表。

2.1 核心表结构设计

第一张是活动表vote_activity,存活动基本信息:活动标题、开始时间、结束时间、参与身份限制类型(公众号用户/手机号/白名单)、投票类型配置(JSON字段存规则)、是否需要显示实时榜单、是否开启匿名模式等。

第二张是投票项表vote_option,存每个活动的候选选项,包括选项名称、描述、展示图片、所属分组、排序权重、是否置顶。

第三张是投票记录表vote_record,这张表最关键。每个字段分别是:自增ID、活动ID、参与人唯一标识(微信用户用openid)、投票批次号、投的选项ID、得分、投出时间、渠道标记、IP地址、User-Agent指纹、设备指纹。

第四张是活动配置表vote_config,存“每个用户能投几票”“每天能投几票”“是否允许投同一项”这类限制规则。

这里有个容易踩坑的设计点:多选投票怎么存。我见过很多人把用户一次选的五个选项拆成五条记录,这样存没有问题,但如果你要支持“每人限投一次,一次最多选五项”,就必须引入一个batch_no批次号。用户在同一个提交动作里选的五个选项共享同一个批次号,后续查重和统计都围绕这个批次来,而不是逐条判断。

2.2 如何从数据库层面杜绝重复投票

重复投票是投票系统世界里最基础也最致命的问题。我见过不少团队只在业务代码里判断“这个用户有没有投过”,一旦架构上走缓存,先读缓存后读库,就会出现两个人同时提交、两边都判断成“未投过”的情况。

我在重构时采用了“唯一索引兜底+业务预判”的双保险策略。在vote_record表里,对活动ID、参与人唯一标识、批次号做了一个联合唯一索引。这样即使业务层判断漏了,数据库也会在最后一道关卡拦下重复写入。注意这里不能用openid做唯一,因为有的活动允许多次投票,只限制每日次数,所以唯一索引必须带上活动规则相关的维度。

同时,业务层在写入前先查Redis里的计数器,如果发现该用户今天的票数已经达到上限,直接拒绝。如果真的因为缓存延迟或者并发穿透导致数据库层面撞了唯一索引,就捕获异常降级,返回一个统一的“您已参与过本活动”提示。

2.3 状态机:让活动从草稿到结算都有章可循

老系统里活动只有“开启/关闭”两个状态,非常粗放。重构后我把活动生命周期拆成了5个状态:

  • draft草稿:运营在后台配置规则、添加选项,此时对外不可见。
  • pending待开始:活动页面可以预览,但不能投票。
  • running进行中:接收投票,实时榜单开启。
  • paused暂停:运营可临时暂停投票,比如发现刷票行为要处理时。
  • finished已结束:投票通道关闭,数据封存,进入结算和导出阶段。

每个状态之间的切换都有操作日志,并且部分切换需要二次确认。比如从“进行中”切换到“已结束”,系统会弹窗提示“结束后榜单将锁定,是否确认”,避免运营手滑提前关掉活动。

在实际操作中,状态机还有一个隐藏功能:当活动处于finished状态后,任何对活动规则配置的修改都会被锁定。这样做是为了保证最终结算数据可审计,避免运营中途改规则导致数据无法解释。

3. 微信生态集成:授权、会话与H5页面的常见坑

微信投票系统最大的特点,就是入口几乎都在微信内部。公众号菜单、图文消息、朋友圈二维码、小程序内嵌H5,全都走微信那一套。这一节讲的坑,可能救命。

3.1 授权流程:静默授权和主动授权必须分开

我第一次做微信授权时犯过一个大错:一进页面就调wx.authorize弹窗让用户点确认,结果转化率掉了一大截。用户打开投票页面本来只是想看看,结果先被弹窗吓跑了。

后来我把授权拆成两种。

静默授权(snsapi_base):用户在微信内打开页面时,后台直接通过redirect拿到code,再换openid。这一步用户无感知,足够覆盖投票主流程。因为投票系统只需要识别用户唯一身份,不需要头像昵称等资料,所以snsapi_base完全够用。

主动授权(snsapi_userinfo):只有当用户要参与抽奖、要领取奖品、要查看自己排名这类场景,才引导用户主动授权,获取头像和昵称展示在前端。

这个改动直接让投票参与率提升了将近三成。记住一个原则:授权是为了满足业务闭环,不是为收集用户信息。

3.2 code一次性使用和缓存问题

微信网页授权里,code只能使用一次,而且有效期只有五分钟。我见过太多人把code当成登录凭证存在Cookie里,结果第二次请求时code已经失效,接口直接报错。

正确做法是:后端拿code换openid之后,把openid和会话状态写进服务端Session或Redis,持续时间为数小时甚至数天,前端保持这个会话状态访问接口即可。code本身用完就丢,不要缓存。

一个值得注意的细节:同一个微信用户如果先通过公众号菜单A进入,又通过二维码B进入,openid是相同的,但code每次都不同。这意味着同一用户在短时间内可能产生多个会话,不必担心,只要判断openid是同一个即可识别为同一人。

3.3 回调域名配置的连锁问题

微信授权回调域名必须在公众号后台配置,但这里有个坑:H5页面如果部署在多个域名下,公众号后台的回调域名只能配一个域名下的路径。

我做了一个通用解决方案:统一走一个/wechat/oauth/redirect的网关路径,前端在跳转时把自己的redirect_url参数拼到回调地址后面,后端完成授权后根据参数原路跳回。这样就绕开了微信后台只能配一个域名的限制。

另外,本地开发时微信回调域名根本不可能配置到localhost,我只好在测试环境部署一个专用调试页面,把所有授权逻辑集中到后端接口上,本地开发模拟微信登录时直接打桩返回测试openid。这个方案一开始花了一点时间搭,后面每个功能迭代都省了大把时间。

3.4 分享和菜单入口的渠道追踪

很多投票活动会通过公众号菜单、朋友圈链接、微信群转发三种渠道传播。我在设计时就加了渠道追踪字段channel,生成菜单链接时带上channel=menu,二维码带上channel=qrcode,分享出去时带上channel=share。

这个字段平时没啥存在感,但在结算复盘的时候价值巨大:可以清楚看到哪个渠道带来的参与人数最多、哪个渠道的用户投票后续行为更好。也给防刷系统提供了维度——如果某个渠道的刷票行为比例异常高,可以直接提高该渠道的风控等级。

4. 防刷体系:不是堆功能,而是分层防御

投票系统的卫生问题就是刷票。“刷票”这个词听上去很边缘,但真遇到活动有奖金、有奖品的时候,刷票手段的丰富程度能让你大开眼界。

4.1 常见刷票手段

我在运营合作过程中见过的刷票手段大致分几种。

  • 手动切换微信小号投票:一个真人用多个微信号轮流投,每次都是真实请求,技术层面很难拦截。
  • 脚本模拟请求:直接调用投票接口,伪造参数和请求头。
  • 代理IP池+批量账号:用境内外代理IP加批量注册的账号,模拟真人用户群体行为。
  • 薅羊毛工具:很多专门的刷票软件能做随机延时、随机User-Agent、随机选项等。

面对这些手段,单一防线根本不够。我设计的是四层递进式防御,每一层都有明确的职责。

4.2 第一层:身份层

身份层的目标是“确认这是一个可识别的人”。在微信生态里,第一道身份就是openid。每个微信号对应一个唯一openid,这是最基础的不可绕过标识。

但这层有个短板:老版本公众号的投票页面可以随便伪造openid,只要请求参数里带上一个假字符串就能通过。所以我在后端严格判断:所有openid必须是通过微信授权接口由服务端主动获取的,前端传上来的openid一律不信任。

除了openid,我还会给每个浏览器会话生成一个device_fingerprint指纹,用前端采集一些信息(UA、屏幕分辨率、时区、canvas指纹等)拼接哈希,存到Cookie里。指纹不能单独说明问题,但它作为后续行为层判断的特征之一是够用的。

4.3 第二层:频率层

频率层的目标是“正常人不会在几秒内连续投很多票”。

我在Redis里维护了一个滑动窗口计数器,对每个openid、每个IP、每个设备指纹分别统计过去60秒的投票次数。如果超过阀值,直接返回“操作太频繁,请稍后再试”。

这个阀值不是拍脑袋定的。我根据正常人类操作习惯做了统计:从打开页面到看清楚选项到点投票,最快也要3秒以上。所以我设置的阀值是“60秒内最多10票,连续5分钟最多40票”。超过就触发验证码,而不是直接拒绝。触发验证码一是可以拦截一部分纯脚本;二是给正常用户一个自证机会,体验损失相对低。

这里有个很容易被忽略的细节:Redis计数器不能只按openid统计,因为同一台电脑开着无痕窗口刷新页面会生成多个不同标识。所以我把IP限流和openid限流同时启用,取两者中较早触发的作为判据。

4.4 第三层:行为层

行为层是相对高级的一层,目标是“识别看起来像人的行为”。我会记录用户从进入页面到投票之间的时间间隔、鼠标轨迹(可以不是全程,但要有关键节点)、页面停留深度等。

道理其实很简单:真人用户进入页面会先看看选项,鼠标在页面移动,再点投票按钮。而脚本请求基本上不进页面,直接调接口。我通过前端SDK收集行为数据并上报,后端把这些数据作为投票请求的附加参数,做一次轻量评分。评分低于阈值的请求会进入人工复核队列,但不会直接拒绝——避免误伤真人。

这里面度把握很重要。评分模型跑了一段时间后,我发现有些老人或者网络慢的用户行为特征也像“脚本”:进页面后很久不动,突然点击投票。所以行为层的定位永远是“降低风险”而不是“绝对拦截”。

4.5 第四层:运营复核层

最后一层是给运营人员的。后台提供一个“实时风控面板”,列出当前活动里标记为异常但未被自动拦截的投票记录,支持按IP、按设备指纹、按行为评分排序。运营可以一键作废某批票数,也可以把某个设备指纹或IP加入黑名单。

我另外做了一个“票数曲线异常检测”小工具:系统每隔5分钟记录一次各选项的票数,如果某个选项的票数在短时间内出现陡增曲线,并且增长速度远超平均值,会自动给运营发送告警通知。这不算多高深的技术,但特别实用。

5. 高并发下的投票链路:不把鸡蛋放在一个数据库里

投票活动最怕什么?怕流量一秒涌进来,然后系统毫无征兆地挂了。我之前见过一个投票活动刚在公众号头条发布,三分钟内五千人同时打开页面,然后数据库连接数直接打满,页面白屏,运营在后台疯狂发消息问我怎么回事。

5.1 前端静态化与CDN

投票页面本身不复杂,所以我把页面整体做成静态页面,部署到CDN。用户打开页面时,页面壳子和所有静态资源都从CDN加载,根本不碰自己的服务器。只有真正提交投票的时候,请求才会到达后端接口。

这个设计直接让页面打开能力提升了几个量级。静态页面上的活动数据,比如选项列表,通过一个异步接口获取,接口本身再做缓存。

5.2 Redis缓存:计数和榜单的主力

投票是一个典型的“读多写少”场景。大家看榜单的次数远超投票次数,所以绝对不能让每个看榜单的人都打到数据库。

我的方案是:票数计数和榜单都维护在Redis里。每一次有效投票后,INCR对应选项的票数,并且更新一个有序集合(ZSET),成员是选项ID,分数是票数。排行榜直接读这个ZSET,秒级返回Top N。

这里要注意的一个细节:Redis数据不是持久化的最终真相,数据库才是。Redis里的计数允许短暂延迟,但不能丢。我每晚凌晨做一次Redis到数据库的对账,把活动期间的数据校准一次,确保不会出现缓存丢失导致榜单出错。

5.3 异步落库:投票接口不直接写数据库

投票接口如果同步写数据库,在峰值时会拖垮数据库。我的做法是:接口在Redis里把票数加好之后,把投票记录投递到消息队列,后端Worker轮询队列,批量把记录写入数据库。

可能有人担心:如果队列积压,用户看到的票数和数据库记录差得太多怎么办?我的处理是,用户在前端看到的榜单来自Redis,答案是一致的。而数据库只负责最终结算和审计,不需要展示给所有人,所以有几十秒的延迟完全可以接受。

为了确保不丢数据,我在消息队列里加了重试和死信队列机制。Worker消费失败会自动重试,超过五次进入死信队列,运营后台会看到告警。

5.4 限流和降级:峰值时的保命手段

即使有了缓存和异步队列,也要防着极端流量打爆入口。我参考了网关限流中常用的令牌桶算法:每个IP每秒最多放行多少请求,每个openid每分钟最多放行多少请求。超出的直接返回“系统繁忙,请稍后再试”,但不影响页面打开,只会影响接口提交。

降级策略也很重要。如果Redis挂了,投票接口会切到“只读模式”,也就是页面还能看,榜单还能看,但是投票按钮置灰,同时后台发告警。这个取舍很关键——宁可暂时不能投票,也不能让整个系统崩掉。

5.5 数据一致性的最终兜底方案

上面这套架构,不可避免会出现Redis和数据库票数不完全一致的情况。我的最终兜底方案是一套对账脚本:活动结束后,从数据库读原始投票记录重新统计每个选项票数,和Redis里记录的最终票数对比。一旦发现差异,以数据库为准,修正Redis。结算报表也统一从数据库统计产生,不以Redis为准。

6. 多样投票玩法:让“解锁”不是改需求而是改配置

标题里说“解锁多样投票”,这个“解锁”在我这里不是指某种破解技巧,而是指系统可以复用的玩法扩展能力。我重构时把投票玩法抽象成一组可配置规则,运营不需要写代码就能搭出新活动。

6.1 投票类型配置化:从流程到规则

我在vote_config表里用JSON存储一类规则,结构大致是:

  • vote_type:single(单投)、multi(多选)、weight(权重票)。
  • max_choices: 多选时最多选几个,默认1。
  • max_times_per_day: 每个用户每天最多投几票。
  • max_times_total: 整个活动周期内最多投几票。
  • allow_same_option: 是否允许重复投同一个选项。
  • anonymous: 投票是否匿名展示。
  • show_rank: 是否展示实时榜。

运营人员在后台配置活动时,只需要填这些参数,前端会自动根据规则渲染不同的交互。多选模式下,选项前面会变成复选框,并实时提示“还能选X项”;权重模式下会额外弹出打分滑杆;匿名模式下榜单不显示投票人头像和昵称。

这套配置化做出来之后,后续新玩法基本都不需要动核心代码。比如后来运营朋友说“我想做一个按区域分开投票的活动”,我只需要在活动表里加一个region字段,选项按区域分组展示,规则上限定用户只能投自己所在区域内的选项。

6.2 权重票和公开票怎么共存

有些活动表面上是公开投票,实际上主办方会给某些选手额外加权分。这种需求曾经差点让我和运营吵起来,但冷静想想也能理解——可能不是所有投票都是真正“一人一票”,比如专家评审和大众评审权重不同。

我最终把权重规则做成透明化的:普通用户投票每票计1分,评审专家通过专用入口投票,每票计3分,并且权重倍率在活动规则公告里写明。系统在后台记录每条投票的来源渠道(普通/评审),结算时按权重换算总得分,而不是直接篡改票数记录。这样既满足了多方需求,也保证了数据可解释性。

6.3 活动模板:让上线速度从两天变成两小时

我还内置了一套活动模板库。比如“最佳人气奖”模板、“摄影比赛投票”模板、“萌娃评选”模板、“年度人物评选”模板。每个模板预置了对应的页面布局、颜色主题、选项卡片样式和规则默认值。运营选中模板后,只需要上传活动图、填写标题、导入选项,一键发布。

这个想法来源于我早期给某个机构做活动时,对方要求“下周一必须上线”,我连续熬夜改页面的惨痛经历。活动模板化之后,新的活动搭建时间从原来的一到两天缩短到两小时左右,而且页面品位比原来手搓的更稳定。

6.4 数据报表:结算时的门面担当

投票活动结束时,运营最需要一张漂亮可信的报表。我在后台做了一份多维度报表,包含:

  • 总参与人数、总票数、有效票占比;
  • 每个选项获得的票数、权重分、票数趋势折线;
  • 分时段的投票热度分布(几点钟投的人最多);
  • 渠道来源分布(菜单进入、扫码、分享、跳转);
  • 被风控系统拦截的异常请求数量和影响范围。

报表支持导出Excel和PDF。运营朋友第一次看到导出的图表时,感叹“这比我以前手动复制数据做表强太多了”。

7. 在这轮升级里,我最终踩实了的那些坑

技术方案讲完,最后说说这次重构过程中真实踩过的坑。有些坑是写代码之前根本预想不到的,分享出来希望大家别重复交学费。

7.1 微信code重复使用的坑

我在上线测试时遇到一个诡异的现象:部分用户投票后刷新页面,会偶发出现“授权失败,请重新进入页面”的报错。查了很久发现原因在浏览器预加载机制上:部分浏览器会对页面链接做预取(prefetch),导致后端同一个code被请求了两次。第二次请求时,微信那边已经把这个code标记为已使用,返回错误。

解决方案是在后端拿code换openid时做一次幂等缓存,以code为key,把换到的openid缓存几分钟。同一个code第二次来,直接返回缓存结果,不去微信那边重复请求。

7.2 缓存与数据库计数器不一致

我第一次从Redis迁移回数据库对账时,发现大量活动票数差异。原因很简单:我在Redis里INCR加票,但异步队列落库时出现部分消息重复消费,或者消费失败没有重试到位。

最后我重新设计了对账和补偿机制:每条投票记录落库前先在数据库本地做去重判断,用批次号加唯一索引控制。同时,在活动结束后不直接信任Redis的最终值,而是从数据库重新统计。这套机制上线之后,再也没出现过结算数据对不上的情况。

7.3 慢查询:一个被忽略的索引导致接口雪崩

投票高峰期,我收到告警说一个关键接口RT飙升。排查发现是一条SELECT * FROM vote_record WHERE activity_id = ? ORDER BY create_time DESC LIMIT 100的榜单查询,因为vote_record表涨到了千万级,又没有activity_id+create_time的联合索引,导致全表扫描。

那次事故让我养成了一个习惯:任何表超过百万行之后,核心查询必须压一遍EXPLAIN查看执行计划。大表上的索引设计不能靠临时补,必须在做数据模型的时候就把高频查询路径考虑清楚。

7.4 测试环境微信域名的尴尬

本地开发时,微信授权回调域名不可能指向localhost,导致本地功能调试极其痛苦。我后来建了个独立的测试公众号,把测试域名的回调配置到测试号后台,并且在后端增加一个“模拟微信登录”的开发入口,本地联调时直接走模拟逻辑。这个做法的额外好处是,自动化测试也能跑通授权链路,不用每轮都真拿微信扫码。

7.5 时区问题:活动截止时间差了一小时

上线后有一场活动凌晨零点结束,结果不是过了零点结束,而是拖到了凌晨一点才自动结束。排查后发现是后端服务器时区设置成了UTC,而活动截止时间按北京时间存储。后来我在所有时间处理层统一用带时区的DateTime格式,数据库字段也统一为DATETIME加显式时区声明,再没出过这种问题。

说到这里,这套全能投票系统已经不再是我最初接手时的那个小玩具了。它有了清晰的数据模型,有了能应对真实流量的架构,有了相对严谨的防刷体系,也真正做到了用配置解锁多种投票玩法。如果你也在做类似系统,或者准备重构一个老化的业务模块,希望这些踩坑记录能帮你少走几步弯路。最后再说一句:投票系统的核心永远不是“能投就行”,而是让运营方拿到一份经得起推敲、对得起参与者的真实结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询