做投票系统这事听起来好像很简单一个页面几个选项点一下提交完事。可真要把一套投票系统做到既能扛住公众号几万人同时参与、又能灵活切换多种投票玩法、还得在活动期间顶住各种恶意刷票冲击的时候你会发现这个“小玩意”其实一点都不小。去年年初我开始重构手上的微信投票源码系统目标很直接把原来只能做单选排名的那套老代码升级成一个支持多场景、多规则、多终端的全能投票引擎。当时和某机构负责活动运营的朋友聊需求他列了一串让我头皮发麻的功能点多选限投、权重计票、实时榜单、按区域分组、防重复投票、活动模板、结果导出……甚至还有“能不能让不同渠道进来的用户看到不同选项顺序”这种细节要求。当时的我满口答应心理其实在打鼓。这篇文章就是围绕这次升级过程把我设计数据模型、集成微信授权、搭防刷体系、处理高并发、扩展玩法这些环节的完整思路和踩过的坑记下来。标题上虽然写着“全网独家”但代码从来不是靠口号变好的都是一版一版磨出来的。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生成菜单链接时带上channelmenu二维码带上channelqrcode分享出去时带上channelshare。这个字段平时没啥存在感但在结算复盘的时候价值巨大可以清楚看到哪个渠道带来的参与人数最多、哪个渠道的用户投票后续行为更好。也给防刷系统提供了维度——如果某个渠道的刷票行为比例异常高可以直接提高该渠道的风控等级。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_idcreate_time的联合索引导致全表扫描。那次事故让我养成了一个习惯任何表超过百万行之后核心查询必须压一遍EXPLAIN查看执行计划。大表上的索引设计不能靠临时补必须在做数据模型的时候就把高频查询路径考虑清楚。7.4 测试环境微信域名的尴尬本地开发时微信授权回调域名不可能指向localhost导致本地功能调试极其痛苦。我后来建了个独立的测试公众号把测试域名的回调配置到测试号后台并且在后端增加一个“模拟微信登录”的开发入口本地联调时直接走模拟逻辑。这个做法的额外好处是自动化测试也能跑通授权链路不用每轮都真拿微信扫码。7.5 时区问题活动截止时间差了一小时上线后有一场活动凌晨零点结束结果不是过了零点结束而是拖到了凌晨一点才自动结束。排查后发现是后端服务器时区设置成了UTC而活动截止时间按北京时间存储。后来我在所有时间处理层统一用带时区的DateTime格式数据库字段也统一为DATETIME加显式时区声明再没出过这种问题。说到这里这套全能投票系统已经不再是我最初接手时的那个小玩具了。它有了清晰的数据模型有了能应对真实流量的架构有了相对严谨的防刷体系也真正做到了用配置解锁多种投票玩法。如果你也在做类似系统或者准备重构一个老化的业务模块希望这些踩坑记录能帮你少走几步弯路。最后再说一句投票系统的核心永远不是“能投就行”而是让运营方拿到一份经得起推敲、对得起参与者的真实结果。