简介:浏览器/服务器架构的网络投票系统源码,面向中小型组织或开发者,用于在线发起投票、收集结果并完成统计,支持并发数在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管理员创建主题管理员所有接口返回统一结构Result<T>:public class Result<T> { private int code; // 0表示成功,非0为业务错误码 private String msg; // 错误描述,成功时固定为"ok" private T data; // 业务数据 public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 0; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> error(int code, String msg) { Result<T> 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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做token,Redis自带过期时间,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, List<Long> 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存JSON,key为"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章的接口清单就能跑通全流程。希望帮到你。本文还有配套的精品资源,点击获取
简介:浏览器/服务器架构的网络投票系统源码,面向中小型组织或开发者,用于在线发起投票、收集结果并完成统计,支持并发数在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管理员创建主题管理员所有接口返回统一结构Result<T>:public class Result<T> { private int code; // 0表示成功,非0为业务错误码 private String msg; // 错误描述,成功时固定为"ok" private T data; // 业务数据 public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 0; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> error(int code, String msg) { Result<T> 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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做token,Redis自带过期时间,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, List<Long> 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存JSON,key为"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章的接口清单就能跑通全流程。希望帮到你。本文还有配套的精品资源,点击获取