前阵子帮客户上线一场内部评选活动开票时间一到几千人同时点投票后台一张收录票数的表瞬间被行锁和死锁日志刷屏。后来我把整套投票计数迁到了Redis用ZSet做排行榜、Set做去重、Lua脚本保证一票一投压测下来单实例读写稳定再往后又补了主从和集群扩展。这篇就把设计思路、数据结构和踩坑过程完整记录下来给正准备用Redis做投票或类似计数系统的朋友一个可以直接参考的落地版本。1. 为什么投票系统的核心逻辑要放Redis1.1 投票业务的两个天然特征投票系统看起来简单实际业务特征非常鲜明平时读多写少可一旦到了开票时间写请求会瞬间集中爆发。这种平时闲、开票秒杀的形态跟很多秒杀系统的流量模型几乎一样。另一个容易被忽略的特征是实时排名。投票活动运营方最喜欢看的就是当前票数排名而且往往要实时展示。如果这一层直接落在关系型数据库上每次刷新榜单都去做order by数据量一旦过万数据库的压力会直线上升。Redis的有序集合结构天然支持按分数倒序取区间实时排名这件事基本就是为ZSet量身定做的场景。1.2 直接写数据库会踩到什么坑先说一个最常见的实现方式创建一张vote表每个候选人一行用户点投票就执行一条update最后页面展示select sum或者order by。这个方法在小流量下没问题但并发一起来立刻露馅。表面上只有一个候选人实际上同一张表里的同一行记录在并发情况下会成为争抢热点。MySQL行锁会让所有到达的update请求排队连接一多连接池先被打满接着是CPU飙升最后整个服务不可用。我那次遇到的内网评选活动其实就是这种写法。活动刚开放监控图表里连接数和锁等待时间同时暴涨业务方反馈点投票按钮没反应。当时我临时把update改成异步队列才勉强稳住了场面但这也是在给后面做Redis方案争取时间。所以投票系统的核心计数从一开始就应该放到Redis里。它的incrby、zincrby这类原子操作不需要加锁再加上单线程事件循环的特性天然能扛住同一时刻的大量写请求。2. 投票系统的数据结构选型2.1 ZSet的score就是票数先天适合排行榜我设计的核心结构是一张投票排行榜。每个候选人作为ZSet里的member票数作为score投票动作直接执行ZINCRBY加了1票score自然加1排名也跟着更新。# 给候选人candidate_1001加1票 ZINCRBY poll:20250120:leaderboard 1 candidate_1001 # 查询当前榜单前10 ZREVRANGE poll:20250120:leaderboard 0 9 WITHSCORES # 查询某个候选人当前票数 ZSCORE poll:20250120:leaderboard candidate_1001这里有个设计细节值得说明为什么不用一个普通的String类型存所有票数因为投票系统几乎都要看排名而String结构无法直接排序。如果硬要用String你还得在应用层倒出来再排序既浪费网络开销又拉长了响应时间。ZSet把存票数和排序合并成了一次操作一个命令拿到最终结果这是数据结构选型对业务逻辑的直接影响。2.2 Set去重与Hash存明细光有票数还不够投票系统必须防重复。同一场活动同一个用户不能重复投这个判断我用Set结构来承接。# 用户user_1001投票给candidate_1001 SADD poll:20250120:voted_users user_1001但这里马上会遇到一个实际的业务决策有的活动是一人一票有的活动是一人每天一票还有的是一人能投多个候选人但每个候选人只能投一票。我在做的时候采用了一种组合思路Set负责存已经投过票的用户ID只要SISMEMBER返回1就说明这个用户在这一轮中已经参与过投票。如果业务规则是每人可以投三位候选人那就不能只用Set存用户ID还要带上候选人维度比如Set的member设计成user_1001:candidate_1001。Hash结构在这个场景里用来存审计明细。虽然ZSet的score已经是票数但业务后台经常会查某个用户什么时候投的票某个候选人的票都来自哪些用户这些信息ZSet存不了。我的做法是再维护一个Hash字段是用户ID值是投票时间戳。HSET poll:20250120:vote_detail user_1001:候选candidate_1001 1737349200这样设计Redis内部每类数据只干一件事ZSet管排名Set管去重Hash管审计。互不干扰后续出了问题也能快速定位是哪一层的责任。2.3 内存容量估算数据结构定下来后下一步一定是算内存。很多团队Redis的内存配了1G结果活动刚跑一半就满这往往是没提前估算。我根据实际场景给出一组参考数字一场10万人的活动每人限投1票候选人30个。ZSet一个member约50字节user或者candidate的ID如果太长会更多30个候选人几乎可以忽略。Set10万个用户ID每个按40~50字节估算大约占用4~5MB。Hash如果存10万条明细每条包含field和时间戳也会占用一定内存大约10~15MB。所以10万人的活动核心Redis内存占用基本在20MB以内非常划算。如果是千万级用户也不会超过几百MB但这时候就要考虑给key设置合理的过期时间以及后续的集群扩展。3. 用Lua脚本锁死一票一投的原子性3.1 先查再写的三个错误窗口很多开发第一次写投票逻辑时都会这样做查Redis判断用户是否已经投过票如果没投过执行ZINCRBY加票把用户写入Set这套流程在高并发下有一个致命问题两个请求同时进入第1步都发现用户没投过票然后同时走完第2步这个用户就能投两票。这个检查再更新的窗口期在任何编程语言和中间件里都存在只是时间长短不同。有人会说加个分布式锁不就行了。但分布式锁在投票这种高频小事务场景里并不合适。锁的获取和释放本身有网络开销万一锁超时或者持有锁的节点宕机还要处理锁失效的边界问题。为了一次加1的操作引入这么多复杂性不划算。3.2 一段Lua脚本把去重、计数、排名一次做完Redis的Lua脚本能力是解决这类问题的利器。它保证了脚本里的所有操作在同一个执行线程中顺序执行中间不会有其他命令插入天然原子。我写的核心脚本如下-- KEYS[1]: 记录已投票用户的Set -- KEYS[2]: 候选人票数排行ZSet -- KEYS[3]: 投票审计Hash -- ARGV[1]: 用户ID -- ARGV[2]: 候选人ID -- ARGV[3]: 当前时间戳 if redis.call(SISMEMBER, KEYS[1], ARGV[1]) 1 then return 0 end redis.call(SADD, KEYS[1], ARGV[1]) redis.call(ZINCRBY, KEYS[2], 1, ARGV[2]) redis.call(HSET, KEYS[3], ARGV[1], ARGV[3]) return 1这段脚本的执行结果只有两种0代表用户已经投过票1代表投票成功。整个投票动作到了Redis这里就变成了一个不可分割的命令。脚本执行时三个key必须是同一个pollId下的key这样能保证相同业务的查询落到同一个逻辑分组里。比如EVAL ... 3 poll:20250120:voted_users poll:20250120:leaderboard poll:20250120:vote_detail user_1001 candidate_1001 17373492003.3 工程调用的细节在Java端我用Spring Boot的RedisTemplate调用Lua脚本。要注意的一点是不要每次执行都发送完整脚本内容而是用SCRIPT LOAD先把脚本缓存到Redis拿到SHA后通过EVALSHA执行。这样能减少每次请求的网络传输对压测数据的影响非常明显。private static final DefaultRedisScriptLong VOTE_SCRIPT new DefaultRedisScript(); static { VOTE_SCRIPT.setLocation(new ClassPathResource(lua/vote.lua)); VOTE_SCRIPT.setResultType(Long.class); } public long vote(String pollId, String userId, String candidateId) { ListString keys List.of( poll: pollId :voted_users, poll: pollId :leaderboard, poll: pollId :vote_detail ); String[] args {userId, candidateId, String.valueOf(System.currentTimeMillis() / 1000)}; return redisTemplate.execute(VOTE_SCRIPT, keys, args); }实际压测时单条Lua脚本的P99延迟在0.3ms左右瓶颈几乎都在网络和序列化上脚本本身消耗非常小。等到并发上到2000 QPS命令执行依旧稳定。4. 实时榜单与三层防刷4.1 榜单分页与实时刷新实现投票活动页的榜单不是一次性加载全部数据而是分页展示。ZSet的ZREVRANGE命令天然支持按排名区间取数据配合WITHSCORES一次就能拿到排名、候选人ID和票数。# 第一页第1到第20名 ZREVRANGE poll:20250120:leaderboard 0 19 WITHSCORES # 某候选人当前排名从0开始 ZREVRANK poll:20250120:leaderboard candidate_1001前端实时性要求高的场景我通常不推荐用轮询去刷排行榜而是在Redis里维护一份最近变动的候选人列表通过SSE或者WebSocket把变化推送出去。但大多数活动场景下5秒轮询一次就够了因为榜单是给人看的人感知不到3秒和5秒的差异。4.2 三层防刷用户维度、IP维度、行为维度投票系统最大的敌人是刷票。只做用户去重远远不够攻击者可以注册大量小号反复投所以必须叠加多层防护。第一层是用户维度这就是Lua脚本里SISMEMBER的作用。一个人投过一次第二次直接返回0这是底线。第二层是IP维度用时间窗口限流。可以对同一个IP的投票请求做频率统计比如每分钟同一个IP最多允许投30票超过就拒绝。实现方式是INCR加EXPIREINCR rate:ip:1.2.3.4 EXPIRE rate:ip:1.2.3.4 60如果INCR返回的数字超过阈值说明这个IP在短时间内发起了大量投票请求直接拦截掉。第三层是行为维度包括投票间隔、账号注册时长、是否需要验证码等。这一层不太适合在Redis里硬编码而是由上游服务判断再把结果作为是否执行Lua脚本的依据。Redis负责的是最终一致性而不是业务风控。4.3 投票期结束的清理与回收投票活动有明确的起止时间活动结束后这些临时数据不该长期占着内存。我的做法是给每个key设置与活动剩余时间一致的TTL。比如活动还剩30天投票key创建时就设置EXPIRE 30天。活动结束后Redis会自动回收数据。这里有一个坑很多人会在活动结束后手动删除key结果忘了还有备份的Hash和Set导致内存泄漏。更稳妥的做法是给所有相关key统一设置TTL避免人工干预。如果活动结束后还需要保存最终结果我会先走一遍结算流程把数据写入持久化存储然后再让Redis里的key自然过期。5. 缓存与数据库的最终一致性落库补偿与对账5.1 Redis不该是唯一存储的原因有一段时间我曾经想过既然Redis这么能扛是不是所有数据都可以放在Redis里数据库都不用了后来被分布式系统的现实教育了。Redis是内存数据库内存终归是昂贵且有限的资源。数据量一大成本会明显上升。另外Redis如果发生故障虽然主从可以恢复但RDB持久化是定期快照AOF也有几秒的数据窗口无法做到数据库级别的绝对可靠。投票数据是业务资产最终必须落库。Redis在这套方案里的角色是挡住并发流量和提供实时排行而不是替代数据库。5.2 定时快照落库与补偿我的落库策略是定时扫描而不是每条投票都同步写库。原因很简单投票请求的高峰期如果每一条都走异步队列去写MySQL队列很容易积压反而给数据库带来不必要的压力。具体做法是后台维护一个定时任务每隔5分钟把当前活动的ZSet榜单全部扫描一遍和数据库里的票数做对比把差异写入MySQL。ZRANGE poll:20250120:leaderboard 0 -1 WITHSCORES拿到全量数据后在数据库中执行批量更新。这里的关键是记录上一次同步的时间戳或者保存一份上次同步的快照。比较前后两次快照的差异能知道这段时间内新增了哪几票从而增量更新数据库。这里有个细节要注意快照对比只能保证最终票数一致如果投票有复杂的审计要求那还是得依赖Hash里存的明细。纯计数类的投票用快照对比就够了。5.3 投票结束后的结算与对账活动结束后我会跑一个对账任务。对账的逻辑很简单以Redis的ZSet为准逐项对比Hash里的审计明细和数据库里的记录找出差异并修正。最常见的差异有两个来源定时任务期间发生的那批投票还没落库这是正常现象补跑一次同步即可。数据库更新失败比如MySQL瞬间宕机或连接超时这种需要从审计Hash里重新补偿。好的做法是在投票期间不断维护一条最后更新时间的标记出现问题后能明确知道从哪里开始补不需要全量对账。6. 从单机到集群的扩展实践6.1 高可用选型主从、哨兵、Cluster活动流量不大时单机Redis完全够用。但一旦投票系统是给全网用户使用的单点故障就不可接受了。我的演进路线是先从单机切成主从加哨兵保证Redis宕机时能自动切换主节点客户端不用改代码。当数据量再增长或者写并发已经超过单实例能力上限时再引入Redis Cluster分片。三者的选择逻辑可以归纳为一张表部署方式解决的问题使用场景单机基本读写开发测试、小活动主从哨兵高可用中小型活动单实例性能够Cluster横向扩展大型活动数据量大、写并发高哨兵模式下Redis的主节点只有一个写操作仍然集中在单节点上。所以如果写并发特别高哨兵无法根本解决最终还是得上Cluster。6.2 集群分片模式下的key设计坑Redis Cluster会把key分散到16384个哈希槽里不同key可能落在不同节点上。之前那套Lua脚本里同时操作三个key如果这三个key分散到不同节点脚本会直接报错。解决办法是使用哈希标签hash tag。Redis支持用花括号{}圈定key中参与哈希计算的部分比如把三个key统一设计成poll:{12345}:voted_users poll:{12345}:leaderboard poll:{12345}:vote_detail这样它们都会以12345作为哈希依据落到同一个节点Lua脚本才能正常执行。这个细节如果在开发环境用单机Redis是完全测不出来的只有真正部署到Cluster上才会暴露。我们在联调环境因为这个问题踩过一次当时脚本报的是CROSSSLOT错误排查了半个小时才反应过来。6.3 我的压测结果与真实体感最后分享一组实际压测数据供大家参考。我用的测试环境是4核8G的云主机单实例Redis通过Java客户端10个线程并发打同一场投票的Lua脚本。整体写入QPS稳定在5000以上P99延迟1ms以内。多线程场景下因为Redis单线程处理命令Lua脚本的原子性得到充分保障没有出现重复计票的情况。切换到集群模式后把三个核心key通过hash tag放到同一分片QPS能水平扩展到8000以上瓶颈转移到了客户端的连接池和网络带宽上。如果你也用Redis做投票系统建议先压验一次自己的网络模型很多时候Redis还没到极限反而是客户端的连接数被打满了。我个人的体会是投票系统这套设计逻辑其实可以平移到很多计数类场景商品的点赞、文章的踩、直播间的热度值、排行榜积分等等。数据结构选对原子操作做好再补上扩展方案大部分计数业务都能稳定扛住。
