资讯详情 B/S网络投票系统实战:Spring Boot+MySQL防刷与唯一索引设计
📅 2026/10/21 4:28:47
简介浏览器/服务器架构的网络投票系统源码面向中小型组织或开发者用于在线发起投票、收集结果并完成统计支持并发数在100以内可在合理范围内保证投票流程流畅。资源共56个文件以26个ASP动态页面为核心配合GIF/JPG界面图片、CSS样式表、JS脚本及MDB数据库构成完整的前后台运行环境压缩包仅116KB轻量小巧便于快速下载与部署。后台功能覆盖投票主题创建、选项增删改、系统配置调整以及管理员登录鉴权另附安装方法、功能简介等文本说明从文件结构可看出包含投票添加、编辑、选项管理和后台登录等独立模块为二次开发提供清晰参考。已有197人浏览学习适合学习经典ASP开发模式、理解浏览器与服务器数据交互流程或需要搭建轻量级投票系统的开发者参考。1. B/S网络投票系统是什么从一次活动评选的翻车说起网络投票系统这几年在课程设计和企业内部活动里几乎成了标配但多数人第一次接触它不是因为想学技术而是被纸质统计逼到绝路。某家公司年度评优第一年用纸质票加人工统计票数怎么都对不上账负责人熬到深夜。第二年换成浏览器访问的B/S网络投票系统员工打开链接就能投后台实时出结果防重复、防刷票账目清清楚楚。这套系统说白了就是浏览器/服务器架构——前端负责展示选项和提交动作后端负责鉴权、记票和统计数据库负责落账。它适合高校课设、企业评优、社区评选这类限时、限人、可统计的公开投票场景。下面按实际拆这类资源的经验把架构、防刷和踩坑讲透。2. 技术选型与工程结构Spring Boot MySQL以及为什么劝你别上微服务2.1 技术栈的选择边界B/S投票系统市面上有各种变体纯Servlet加JSP的老方案、PHP一页流、Node.js快跑流还有把前端后端拆成两个工程再上网关的大炮打蚊子方案。我的建议很直接后端用Spring Boot前端用模板渲染加少量原生JavaScript数据库用MySQL不要上前后端分离也不要碰微服务。为什么这么选先说Spring Boot内置Tomcat打成一个jar包扔到服务器上就能跑这对课程设计和中小型业务是最低成本的部署路径。Spring MVC把路由、参数绑定、JSON序列化全部内置你只需要关注Controller、Service、Mapper三层心智负担小得多。数据库用MySQL还有一个现实考虑这份资源的建表SQL、备份脚本、部署文档都是按MySQL写的换库意味着所有语法和驱动都要重来一遍。PostgreSQL的功能确实更强但在这个场景里用到的行级锁、唯一索引、日期函数MySQL全都具备SQLite更轻但并发写能力弱投票场景写入频繁不适合。再说为什么劝你别上微服务。投票系统本质上是读多写少的单体应用高峰期也就几百到几千并发单体模式用一个MySQL实例加个Redis缓存就能扛住。微服务引入的服务注册、配置中心、分布式事务任何一个都会让你在调试阶段多花三倍时间而且课设答辩时被问到为什么拆这么细很难讲清楚业务上的必要性。前端用Thymeleaf模板把页面直接渲染出来投票列表、结果图表用原生JS加ECharts就够了。不搞Vue/React前后端分离意味着少维护一套部署、少处理跨域、少写一套鉴权适配对这份资源的使用者来说多出来的复杂度远大于收益。2.2 包结构与三层分层源码的包结构我习惯按功能模块组织而不是按纯技术层堆目录这样后期加多主题投票功能时不会把代码改乱src/main/java/com/vote/ ├── controller/ │ ├── AuthController.java │ ├── VoteController.java │ └── AdminController.java ├── service/ │ ├── VoteService.java │ ├── AuthService.java │ └── StatService.java ├── mapper/ │ ├── VoteRecordMapper.java │ ├── VoteSubjectMapper.java │ └── VoteOptionMapper.java ├── entity/ │ ├── VoteSubject.java │ ├── VoteOption.java │ └── VoteRecord.java ├── common/ │ ├── Result.java │ ├── GlobalExceptionHandler.java │ └── IpUtils.java └── config/ └── AuthInterceptor.java这个分层的边界就三句话Controller只做收参和吐结果不写业务判断Service承载所有规则比如投票时间校验、重复校验、票数落账Mapper只对应SQL不出现业务逻辑。很多新手喜欢在Controller里写一大坨if判断最后Service层是空的接口一多就难维护。我接手过的某个课设项目把所有逻辑堆在Controller里连数据库连接都写在方法内演示时一张票投了三次才暴露问题——重复校验和插入不是原子的中间隔了几行耗时日志并发双击就穿透了。这套包结构在只有一个投票主题的场景里看起来有一点重但三个Controller对应的是三种完全不同的调用方普通用户端、后台管理端、统计展示端。分开之后权限控制才好做比如给AdminController单独加管理员角色校验给统计接口加只读权限不会被一个入口的拦截器搅在一起。2.3 接口约定与统一响应前后端不管是否分离接口约定要先定死。我用一套极简约定方法路径作用是否需要登录POST/api/auth/login登录拿token否GET/api/subject/detail获取投票主题与选项是POST/api/vote/submit提交投票是GET/api/stat/detail获取实时统计是POST/api/admin/create管理员创建主题管理员所有接口返回统一结构ResultTpublic class ResultT { private int code; // 0表示成功非0为业务错误码 private String msg; // 错误描述成功时固定为ok private T data; // 业务数据 public static T ResultT success(T data) { ResultT r new Result(); r.code 0; r.msg ok; r.data data; return r; } public static T ResultT error(int code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } }这里的code不是HTTP状态码而是业务错误码比如1001表示投票未开始1002表示投票已结束1003表示重复投票1004表示登录态失效。HTTP状态码只有几位承载不了重复投票和投票已结束这种细粒度差异前端拿到code直接弹对应提示不用去解析msg字符串。登录用token模式登录成功后签发token存在Redis里有效期设为活动时长前端每次请求带在Header里。token校验写在拦截器里public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); String key vote:token: token; if (token null || !redisTemplate.hasKey(key)) { writeError(response, 1004, 登录态失效); return false; } request.setAttribute(userId, redisTemplate.opsForValue().get(key)); return true; } }拦截器的逻辑是先从Header拿token拼接Redis的key并判断是否存在同时把userId放进request属性后续Controller直接取值不用再查一次Redis。注意hasKey判断语义更清晰但如果接口QPS很高改为直接get判空可以省一次Redis往返两种写法都可以按对性能的敏感度取舍。3. 数据库设计与防刷底层五张表 唯一索引兜底3.1 核心表结构设计投票系统的数据库模型核心不是一张票表而是多个相互关联的表。这里最核心的是投票主题表、投票选项表、投票记录表三张。先看投票主题表CREATE TABLE vote_subject ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 投票主题ID, title VARCHAR(100) NOT NULL COMMENT 投票标题, description TEXT COMMENT 投票说明, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, max_select INT DEFAULT 1 COMMENT 每人最多可选几项单选为1, status TINYINT DEFAULT 1 COMMENT 0草稿 1发布 2结束, KEY idx_time (start_time, end_time), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票主题表;然后是选项表CREATE TABLE vote_option ( id BIGINT AUTO_INCREMENT PRIMARY KEY, subject_id BIGINT NOT NULL COMMENT 所属主题ID, option_name VARCHAR(200) NOT NULL, sort_order INT DEFAULT 0 COMMENT 展示顺序越小越靠前, KEY idx_subject (subject_id), CONSTRAINT fk_option_subject FOREIGN KEY (subject_id) REFERENCES vote_subject (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票选项表;最后是投票记录表这是整个系统的防刷核心CREATE TABLE vote_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, subject_id BIGINT NOT NULL, option_id BIGINT NOT NULL, user_id BIGINT NOT NULL, ip VARCHAR(64) DEFAULT NULL COMMENT 投票来源IP, device_id VARCHAR(64) DEFAULT NULL COMMENT 设备指纹, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_subject (user_id, subject_id), KEY idx_subject_option (subject_id, option_id), KEY idx_ip (ip) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票记录表;几个关键设计说明。第一max_select字段决定单选还是多选很多票数对不上的翻车案例就是对它没做参数校验。第二ID用BIGINT自增主键不用UUID因为记录表插入频繁UUID主键在InnoDB里会导致页分裂写入性能明显下降。第三字符集在建表时就定成utf8mb4不要留到建库之后才想起来——选项名称里混入生僻字或Emoji时utf8mb3会直接报错。提示字符集在建库时定成utf8mb4如果表建好之后才改生产环境要跑ALTER TABLE期间会锁全表数据量大时风险很高。3.2 唯一约束与并发控制重复投票是投票系统里最隐蔽的Bug很多初版实现只在应用层做一次查一下有没有投过的判断。问题是并发场景下两个请求同时通过未投票检查然后各自插入一条记录应用层的查询根本防不住。数据库层的唯一索引才是兜底方案——uk_user_subject在数据库层面保证同一个用户对同一个主题只能有一条记录即使应用层漏了第二次插入也会被数据库拒绝并抛DuplicateKeyException。有了唯一索引应用层只需捕获这个异常再转成您已参与过投票的提示逻辑反而更干净。这里有个必看的陷阱InnoDB下唯一索引冲突后的插入会等待行锁极端情况下会拉长响应时间。所以代码里要配合事务和死锁重试下面第4章会展开。并发场景下另一个风险是更新选项票数的丢更新问题。如果先SELECT vote_count FROM vote_option WHERE id?再把值加1写回去两个并发请求就会互相覆盖。正确姿势是直接用一条原子UPDATE完成计数UPDATE vote_option SET vote_count vote_count 1 WHERE id #{optionId}这个语句由存储引擎保证原子性不需要额外加锁。很多人习惯先读后写是受了ORM思维影响在这个场景里直接写SQL反而更稳。3.3 防刷策略的分层防刷不是一个单点技术能解决的我的习惯是分四层验证码层、IP限制层、用户登录层、数据库唯一索引层。验证码层防最粗粒度的脚本刷票。投票前必须完成算术验证码比如随机生成两个两位数让用户算加法。算术验证码生成成本低OCR反而不容易读准正确答案只存Redis并设置2分钟过期校验一次就删除防止重复重放。这里有个容易忽略的细节验证码生成的接口也要限流否则别人可以并发请求刷验证码打爆Redis内存。IP限制层针对未登录或匿名投票场景。注意一个很坑的细节投票系统往往部署在公司内网或校园网NAT后面的所有用户共享同一个出口IP。如果把IP限流做成一个IP只能投一票一个办公室的人会互相把对方挡在外面。正确做法是一个IP在一个时间窗口内允许N个不同用户投票比如5分钟内允许10个不同user_id超出才拦截。实现上可以用Redis维护一个IP维度的集合value存user_id用EXPIRE设置窗口投票前检查集合数量是否超限。用户登录层解决一人一票的语义问题。用user_id结合唯一索引才是防重复投票的真正闸门。匿名投票系统难防刷本质是找不到稳定的唯一身份锚点所以凡是业务上允许我都建议加上手机号或学工号登录。数据库唯一索引层是最后一道闸门解决的是并发绕路的问题——有人直接抓接口绕过前端连续POST一百次只要user_id相同唯一索引能拦下绝大多数。4. 核心链路实现鉴权、投票、统计的代码手把手4.1 登录鉴权怎么做登录接口用Spring Boot加Redis做tokenRedis自带过期时间token失效后直接删除不用维护额外的session存储。代码如下Service public class AuthService { Autowired private UserMapper userMapper; Autowired private StringRedisTemplate redisTemplate; private static final String TOKEN_PREFIX vote:token:; public String login(String account, String password) throws Exception { // 1. 先做基础参数校验防止空指针和超长字符串 if (account null || account.isBlank() || password null || password.isBlank()) { throw new BizException(1000, 账号或密码不能为空); } // 2. 查用户表比对密码哈希 User user userMapper.selectByAccount(account); if (user null) { throw new BizException(1001, 账号不存在); } // 这里用BCrypt校验不要存明文密码 if (!BCrypt.checkpw(password, user.getPasswordHash())) { throw new BizException(1002, 密码错误); } // 3. 生成token并写入Redis有效期设为活动时长 String token UUID.randomUUID().toString().replace(-, ); long expireSeconds voteConfig.getDurationHours() * 3600L; redisTemplate.opsForValue().set(TOKEN_PREFIX token, String.valueOf(user.getId()), expireSeconds, TimeUnit.SECONDS); return token; } }代码里三处需要按项目调整。第一密码存的是BCrypt哈希而不是明文表结构中password_hash字段长度要留到60位以上否则保存时报Data too long。第二Redis的key加了前缀vote:token:后续要用SCAN命令清理登录态时不会误删其他业务数据。第三过期时间直接用活动时长活动结束后登录态自动失效不用跑定时任务刷新。4.2 投票提交核心代码投票提交是整个系统最敏感的一段Service层代码Transactional(rollbackFor Exception.class) public void submitVote(Long userId, Long subjectId, ListLong optionIds) { // 1. 校验投票主题是否存在并处于可投票时间段 VoteSubject subject subjectMapper.selectById(subjectId); if (subject null) { throw new BizException(2001, 投票主题不存在); } LocalDateTime now LocalDateTime.now(); if (now.isBefore(subject.getStartTime())) { throw new BizException(2002, 投票尚未开始); } if (now.isAfter(subject.getEndTime())) { throw new BizException(2003, 投票已结束); } // 2. 校验选项数不能超过max_select防止一次提交几十个选项 if (optionIds null || optionIds.isEmpty()) { throw new BizException(2004, 请至少选择一个选项); } if (optionIds.size() subject.getMaxSelect()) { throw new BizException(2005, 最多只能选择 subject.getMaxSelect() 项); } // 3. 应用层查重防御性判断并发兜底交给数据库唯一索引 int existCount voteRecordMapper.countByUserSubject(userId, subjectId); if (existCount 0) { throw new BizException(2006, 您已参与过投票); } // 4. 插入记录并原子更新选项票数 for (Long optionId : optionIds) { VoteRecord record new VoteRecord(); record.setUserId(userId); record.setSubjectId(subjectId); record.setOptionId(optionId); voteRecordMapper.insert(record); voteOptionMapper.increaseVoteCount(optionId); } }关键在第四步插入记录和更新票数在同一个事务里完成。单选项投票用一条INSERT加一条UPDATE多选时循环处理任何一条失败整个事务回滚不会出现记录插了但票数没加的脏数据。那并发双击怎么防两个请求都通过了第三步的查重然后同时进入第四步。此时数据库的uk_user_subject唯一索引会让其中一条插入失败抛出DuplicateKeyException事务回滚。Controller层要捕获这个异常并转成2006错误码返回给前端不要让它变成500。注意Transactional注解一定要指定rollbackFor Exception.class否则抛出自定义业务异常时事务默认不会回滚会出现记录没插但票数已加一半的错误。第三步的查重和第四步的插入之间存在一个时间窗口想彻底消除只能靠数据库唯一索引。如果建表时没建唯一索引这段代码不管怎么写都防不住并发重复。4.3 统计接口的SQL与缓存统计接口是高频读接口投票一旦开放刷新频次会非常高。逻辑分两步先从数据库查原始聚合结果再把结果缓存到Redis并设置5秒过期数据库压力至少降一个数量级。SELECT o.id AS option_id, o.option_name, COUNT(v.id) AS vote_count FROM vote_option o LEFT JOIN vote_record v ON v.option_id o.id WHERE o.subject_id #{subjectId} GROUP BY o.id, o.option_name ORDER BY vote_count DESC, o.sort_order ASC这里用LEFT JOIN而不是INNER JOIN目的是把票数为0的选项也显示出来。注意GROUP BY后面必须把SELECT里出现的非聚合列全部列全否则在MySQL的ONLY_FULL_GROUP_BY模式下会直接报错这是低版本MySQL经常翻车的点。ORDER BY有个坑先按vote_count降序再按sort_order升序。如果只按票数排序票数相同的选项每次刷新顺序都在跳演示时观感很差。缓存直接用String存JSONkey为vote:stat:subjectId过期5秒。为什么不设更长因为投票结果需要实时感5秒延迟观众感知不到数据库压力已经被削平。等人气上来再考虑定时任务每10秒刷一次统计而不是每次请求都打数据库。5. 踩坑记录网络投票系统最容易翻车的五个地方5.1 同一局域网的所有用户被IP限流拦死现象公司内网里第一个投票的人正常第二个同事一投就被提示操作太频繁然后全办公室都投不了了。原因IP限流做成了单IP单票。公司网络走NAT出口所有人对外都是同一个IP第一个投完就把整个出口IP封了。解决把IP限流改成时间窗口加数量阈值比如同一个IP在5分钟内允许来自最多10个不同user_id的投票请求超出才拦截。Redis里用ZSet维护IP维度的集合value存user_id每次投票前清掉过期元素再检查数量。5.2 并发双击时一个用户投出两票现象演示现场有人快速双击投票按钮后台记录里同一个用户出现两条记录票数虚增。原因前端按钮禁用了但后端只做了先查后插的应用层判断两个并发请求同时通过检查同时插入数据库没有唯一索引兜底。解决前端禁用按钮只是给正常人用的真正防住必须靠vote_record表上的(user_id, subject_id)唯一索引Controller捕获DuplicateKeyException转成友好提示。那次翻车之后我每建一张能落账的表都会先想一遍同一个请求来两遍数据库会怎么处理。5.3 多选投票的总票数和参与人数对不上现象活动规则是每人可投3项统计结果总票数是900但参与人数只有300对不上账。原因统计接口直接用COUNT(vote_record.id)算总数。多选场景下每个用户会产生多条记录总数算出来的是票项数而不是人数。解决固定用两种口径对账统计参与人数用COUNT(DISTINCT user_id)统计票项总数用COUNT(id)两个数分别展示并在前端标注清楚。如果业务方坚持只展示总票数也要在说明文案里写明每人可选多项否则会被质疑注水。5.4 缓存过期瞬间被刷票现象投票高峰期突然连续进来几十个重复投票请求日志里全是您已参与过投票。原因把用户已投票标记存在Redis里并设置了较短的过期时间。标记过期后用户又能重新进入投票流程如果数据库唯一索引没建或者校验不严格就真的投进去了。解决缓存只能当快速拦截层判定最终能不能投必须走数据库唯一索引。缓存过期只影响性能不影响正确性。类似的坑还有验证码验证码答案可以带过期时间但投票资格不能带——这是两个语义完全不同的东西别写在一个工具类里。5.5 投票高峰期接口变慢、数据库CPU飙高现象活动开始前半小时涌入大量请求接口响应从几十毫秒变成几秒数据库CPU接近100%。原因慢日志查出来是热门的统计SQL和查重查询在跑全表扫描。选项表、记录表上的索引不匹配查询条件。解决给常用过滤条件建联合索引特别是vote_record表的(user_id, subject_id)唯一索引本身就是查重的索引统计SQL对应的LEFT JOIN条件加(subject_id, option_id)索引让查询能走覆盖索引。统计接口再套一层Redis缓存设置5秒过期。Tomcat最大线程数从默认200调小到100配合数据库连接池上限宁可让请求排队也不要让数据库被短连接淹没。6. 上线前的最后一件事压测与调优的两个实用动作6.1 JMeter压测脚本与指标解读先做一个最简单的压测用JMeter建一个线程组模拟50个用户同时登录并投票每线程循环10次观察接口的响应时间分布和错误率。重点看两个指标TP95和错误率TP95超过500毫秒就说明有瓶颈需要排查。压测时要做的第一个动作是关掉Redis缓存再压一次。如果关闭缓存后接口性能大幅下降说明你压到的其实是缓存命中的结果数据库的真实水位还没暴露生产环境一旦缓存抖动后果就是接口超时。6.2 慢查询日志与索引诊断压测完打开MySQL慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log_file /var/log/mysql/slow.log;把超过1秒的SQL全部捞出来用EXPLAIN看执行计划重点看type字段是不是ALL或index。这两个值表示全表扫描需要针对WHERE和JOIN条件建联合索引。真实项目里我遇到过一条统计SQL因为索引缺失压测时执行了3.2秒加完索引后直接降到40毫秒投入产出比极高。最后说一个习惯从那以后我每上线一个B/S系统都会强制走一遍压测 → 慢日志 → 索引诊断 → 缓存核对四步流程。这次拆网络投票系统最大的收获不是代码本身而是明白了防刷必须靠数据库唯一索引这种事实约束而不是靠应用层逻辑去堵。源码、建表SQL和部署文档都在资源包里按第4章的接口清单就能跑通全流程。希望帮到你。本文还有配套的精品资源点击获取