资讯动态

Spring Boot在线投票系统:从领域建模到高并发缓存与幂等设计

发布时间:2026/9/15 19:17:43 来源:尧图企业网站定制
简介基于SpringBoot的在线投票系统完整项目面向正在学习Java Web开发的初中级开发者精准覆盖登录注册、忘记密码、首页统计展示、实时投票、比赛排行榜、参赛作品投票、个人信息与密码修改等常见业务场景。系统采用SpringBoot SpringSecurity Thymeleaf Bootstrap Mybatis/MybatisPlus技术栈运行于JDK1.8和MySQL5.7环境代码分层清晰适合作为毕业设计或企业级投票功能的基础版本。资源包共775个文件约9.86MB包含344个JavaScript脚本、120个XML配置、55个Java源码和55个class编译文件同时有22个HTML页面、32个CSS样式及2个SQL数据库脚本前端资源与后台逻辑一目了然。目前已有175人浏览学习压缩包内附完整源码和数据库初始化文件既能直接运行调试也可通过源码梳理SpringSecurity认证授权、Mybatis持久化映射及Thymeleaf页面渲染的完整实现思路对掌握SpringBoot整合开发非常有帮助。1. 为什么「Spring Boot 在线投票系统」是练手与交付两不误的项目一次投票看起来只是「选一个选项、点一下提交」可一旦要求多起来事情就开始变味同一个用户能不能重复投、投票中的实时票数会不会被高并发打爆、排行榜要不要做到秒级刷新、投票结束后数据能不能追溯。这些点恰好覆盖了 Spring Boot 开发里最核心的几条主线领域建模、接口幂等、并发控制、缓存与数据一致性。对正在学 Java 和后端的人来说这是一个能在几天内走完「设计、编码、联调、压测」全流程的完整标本对要快速交付内部投票、活动评选、课程评教的团队来说把边界划清楚、把选型做保守它又是一个能直接上线的轻量系统。下文按「模型 → 写路径 → 读路径 → 调优验证」展开整套方案在 Spring Boot 3.x 和 2.7 上都适用只依赖 Spring Web、Spring Data JPA、Redis 三个基础组件。2. 投票领域建模Spring Boot 在线投票系统的实体关系与状态流转2.1 最小可用的三张表投票、选项、投票记录在线投票系统的核心不是「投票」这个动作而是「一场投票活动」和它下面的「多个选项」。绝大多数需要返工的设计都是因为把「选项」直接写成了投票表的一个字段导致后续扩展选项、统计票数时被迫改表。常见做法是三张表投票活动表vote_topic、投票选项表vote_option、投票流水表vote_record。以下是建表 SQL字段按最常用的情况设置CREATE TABLE vote_topic ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(64) NOT NULL COMMENT 投票标题, description VARCHAR(255) NULL COMMENT 投票说明, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1投票中 2已结束, start_time DATETIME NULL COMMENT 开始时间, end_time DATETIME NULL COMMENT 结束时间, allow_multi TINYINT NOT NULL DEFAULT 0 COMMENT 0单选 1多选, max_options INT NOT NULL DEFAULT 1 COMMENT 多选时最多选几项, created_by BIGINT NOT NULL COMMENT 创建人ID, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票活动表; CREATE TABLE vote_option ( id BIGINT AUTO_INCREMENT PRIMARY KEY, topic_id BIGINT NOT NULL COMMENT 所属投票ID, option_text VARCHAR(128) NOT NULL COMMENT 选项内容, sort_order INT NOT NULL DEFAULT 0 COMMENT 展示顺序, vote_count INT NOT NULL DEFAULT 0 COMMENT 冗余票数用于列表展示, UNIQUE KEY uk_topic_option (topic_id, option_text) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票选项表; CREATE TABLE vote_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, topic_id BIGINT NOT NULL COMMENT 投票ID, option_id BIGINT NOT NULL COMMENT 选项ID, user_id BIGINT NOT NULL COMMENT 投票人ID, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_topic (topic_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票流水表;这里的核心约束是vote_record上的联合唯一键uk_user_topic (topic_id, user_id)。它是「一个用户在一场投票里只能投一次」的数据库级兜底不依赖应用层判断是否漏了并发。另一个细节是vote_option里冗余了vote_count这会在第 3 章专门讲更新策略如果在实现时为了省事不建这张表查询「每个选项的票数」就得在vote_record上做全表GROUP BY数据量大了以后连管理后台都会卡。2.2 投票状态机把「草稿、进行中、已结束」变成代码约束状态字段如果只是随手写个Integer status那么「已结束的投票还能不能投」「草稿状态能不能看到排行」这类问题就会散落在各个 Service 方法里每个方法都各自if一遍迟早会漏。更稳的做法是先在代码里定义一个状态枚举把允许的流转路径收拢起来。public enum VoteStatus { DRAFT(0, 草稿), RUNNING(1, 投票中), FINISHED(2, 已结束); private final int code; private final String desc; VoteStatus(int code, String desc) { this.code code; this.desc desc; } public boolean canVote() { return this RUNNING; } public boolean canEdit() { return this DRAFT; } }枚举里加canVote()和canEdit()是为了让「某个状态下能不能做某个操作」变成一个可以直接调用的语义方法而不是在每个 service 里再写if (status 1)。状态流转我一般保持在 service 层控制草稿可以编辑选项点击发布后变为「投票中」到达end_time或管理员手动截止后变为「已结束」。不要在 controller 里直接 setStatus否则状态会被绕过校验。2.3 JPA 实体映射避免懒加载和 N1 查询的写法用 Spring Data JPA 做映射时最容易出问题的是「查询投票列表后遍历每个投票去查它的选项」也就是 N1 查询。这里的实体关系建议这样设计Entity Table(name vote_topic) public class VoteTopic { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 64) private String title; Column(nullable false) private Integer status; Column(name start_time) private LocalDateTime startTime; Column(name end_time) private LocalDateTime endTime; Column(name allow_multi, nullable false) private Boolean allowMulti; Column(name max_options, nullable false) private Integer maxOptions; // 这里不直接使用 OneToMany避免懒加载导致序列化异常 Transient private ListVoteOption options; public boolean isRunning() { return this.status ! null this.status VoteStatus.RUNNING.getCode(); } }为什么不直接用OneToMany因为投票场景里选项列表往往是跟随投票一起返回给前端的用懒加载会在 JSON 序列化时触发LazyInitializationException用fetch FetchType.EAGER又会在列表页造成多余的 join 和重复数据加载。常见做法是在 repository 里用EntityGraph或直接写 JPQL 的join fetch一次性把选项查出来。vote_count字段放在VoteOption实体里对应冗余票数日常的列表和排行榜查询只读这个冗余字段不实时去统计流水表。3. 投票写路径Spring Boot 投票接口的防重、事务与计数一致性3.1 一次投票请求要满足的三个约束投票系统的写路径不是「insert 一条流水update 一个计数」这么简单。一个真实环境下的投票请求必须同时满足同一用户在同一场投票中最多只能投一次幂等、每次投票都会同步影响选项的票数原子性、投票结束后继续请求会被拒绝状态校验。这三个约束任何一个靠单个接口里的 if 判断都无法在并发下成立。先看一个不推荐的实现// 错误示例先查再插存在竞态条件 public void vote(Long topicId, Long optionId, Long userId) { if (voteRecordRepository.existsByTopicIdAndUserId(topicId, userId)) { throw new BusinessException(您已投过票); } voteRecordRepository.save(new VoteRecord(topicId, optionId, userId)); voteOptionRepository.incrementVoteCount(optionId); }这段代码在并发场景下会出两个问题两个请求同时通过existsByTopicIdAndUserId的检查然后各自插入一条流水唯一键uk_user_topic会让其中一条插入失败抛异常第二条是incrementVoteCount如果放在插入成功之后、事务提交之前那么数据库的锁与事务隔离级别会导致计数的更新时机不可控。正确做法是「先扣唯一键约束再更新计数最后统一提交」。3.2 用数据库唯一键做幂等用 UPDATE 语句做原子计数投票流水表上的uk_user_topic是防重的第一道防线。插入时如果捕获到DuplicateKeyException就直接判定为重复投票这比「先 select 再 insert」的检查方式在并发下可靠得多。计数更新则应该用一条受影响的 UPDATE 语句完成Modifying Query(UPDATE VoteOption o SET o.voteCount o.voteCount 1 WHERE o.id :optionId) int increaseVoteCount(Param(optionId) Long optionId);increaseVoteCount返回的int是受影响的行数。如果返回 0说明这个选项在数据库里已经被删除或者 ID 不存在此时整个事务应该回滚。这里没有使用「先读取 voteCount再 1 写回」的乐观锁方式是因为对于单纯的自增计数UPDATE ... SET vote_count vote_count 1本身是原子操作不需要引入Version字段重试机制代码更短也更直观。3.3 完整的事务边界与 Redis 限流前置把投票动作放进一个事务方法里事务边界要覆盖「插入流水」和「更新计数」这两个操作Service public class VoteService { Transactional(rollbackFor Exception.class) public VoteResult vote(Long topicId, Long optionId, Long userId) { // 1. 状态与时间校验 VoteTopic topic voteTopicRepository.findById(topicId) .orElseThrow(() - new BusinessException(投票不存在)); if (!topic.isRunning()) { throw new BusinessException(投票不在进行中); } if (LocalDateTime.now().isAfter(topic.getEndTime())) { throw new BusinessException(投票已截止); } // 2. 插入流水利用唯一键防重 try { voteRecordRepository.save(new VoteRecord(topicId, optionId, userId)); } catch (DuplicateKeyException e) { throw new BusinessException(您已参与过本次投票); } // 3. 原子更新选项计数 int updated voteOptionRepository.increaseVoteCount(optionId); if (updated 0) { throw new BusinessException(投票选项不存在); } return VoteResult.success(optionId); } }这段代码的逻辑顺序是先校验投票状态保证「已结束的投票投不了」再插入流水让数据库唯一键挡住重复投票最后更新计数。事务注解Transactional保证了如果第 3 步失败比如选项不存在第 2 步插入的流水也会一起回滚不会出现「有投票流水但没有加票」的脏数据。Transactional有个注意点它只对RuntimeException和Error默认回滚BusinessException如果不继承RuntimeException必须显式声明rollbackFor Exception.class否则投票时会留下流水但计数没变。这也是教程里经常被忽略的细节建议主键用数据库自增业务异常统一使用一个继承RuntimeException的自定义异常这样事务配置是默认回滚的。Redis 限流适合放在 Service 之外、Controller 层或网关层做只针对「同一用户短时间内高频投票」的场景和流水表唯一键不冲突// 用 Redis SETNX 做 5 秒内不允许重复提交 Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(vote:submit: userId : topicId, 1, Duration.ofSeconds(5)); if (Boolean.FALSE.equals(first)) { throw new BusinessException(操作太频繁请稍后再试); }setIfAbsent相当于 SETNX 命令key 存在时不会覆盖所以第二次请求会直接返回 false。这个限流只是一个前置保护真正的幂等还是靠数据库唯一键兜底两者职责不同Redis 拦的是「快速重复点击」数据库拦的是「任何形式的重复」。下面是几种防重方案的适用场景对比实际项目里选型可以按这个表格来决定方案并发可靠性额外依赖适用场景先查后插低竞态窗口大无仅用于并发量极低的内部工具数据库唯一键 捕获异常高数据库保证无数据最终一致性要求高推荐Redis SETNX 锁 数据库唯一键高且能挡热点Redis面向公众或高并发场景分布式锁Redisson高但偏重Redis/ ZooKeeper多实例部署且需要锁住复杂逻辑4. 读路径优化Spring Boot 投票系统的排行榜与缓存策略投票场景的典型特征就是读多写少列表页、详情页、排行榜会被反复访问。如果每次请求都去查vote_record表统计票数数据库的COUNT GROUP BY在几万条流水时可能还能撑住到了几十万条就开始拖慢其他查询了。第 2 章把vote_count冗余到vote_option表的目的就在这里排行榜只查选项表的vote_count排序不碰流水表。4.1 用 Spring Cache 做投票详情页的缓存投票详情页的响应包含了标题、选项列表和每个选项的实时票数。这个数据在投票进行中变化会很频繁直接缓存整页数据会导致票数延迟很大。更稳的做法是「低频率全量缓存」和「每次投票后主动更新缓存」结合主动更新的代码放在投票事务提交之后Transactional public VoteResult vote(Long topicId, Long optionId, Long userId) { // ...前面第 3.3 节的校验与写入逻辑 cacheManager.getCache(voteOptionCount).evictIfPresent(optionId); cacheManager.getCache(voteTopicDetail).evictIfPresent(topicId); return VoteResult.success(optionId); } Cacheable(cacheNames voteTopicDetail, key #topicId) public VoteTopicDetail getTopicDetail(Long topicId) { VoteTopic topic voteTopicRepository.findById(topicId).orElseThrow(); ListVoteOption options voteOptionRepository.findByTopicIdOrderBySortOrder(topicId); return VoteTopicDetail.of(topic, options); }这里用Cacheable缓存详情在投票成功后把相关缓存 key 清掉下次查询时再重新加载。evictIfPresent相比evict更温和缓存不存在时不会抛异常。要注意的是缓存的更新时机必须落在事务提交之后如果evict在事务内部执行事务尚未提交时缓存被清除其他线程查询的空窗期里可能会把旧数据重新写回缓存即缓存击穿的一种。4.2 排行榜的缓存预热与定时回写排行榜适合单独用一个 Redis 的 ZSet 存储每个选项的vote_count作为 score投票成功后直接ZADD更新。这样页面每次拉取 Top 榜都走 Redis不碰数据库// 投票成功后更新 Redis 排行 stringRedisTemplate.opsForZSet().add( vote:rank: topicId, String.valueOf(optionId), newVoteCount ); // 读取 Top10按票数倒序返回选项ID列表 SetString topOptionIds stringRedisTemplate.opsForZSet() .reverseRange(vote:rank: topicId, 0, 9);读取排行时拿到的只是「选项 ID 列表」还需要根据 ID 批量查出选项文本和图片来填充列表。这个批量查询要注意避免 N1findAllById在 JPA 里是单条 IN 查询效率没问题。reverseRange的0, 9是下标范围返回的就是票数最高的前 10 条记录这个是 Redis 内部的跳表实现时间复杂度是对数级的数据量再大也不需要担心。Redis 里的 score 和数据库的vote_count可能存在短暂不一致比如 Redis 写入失败但数据库事务成功了。常见做法是写一个定时任务做补偿每 5 分钟从数据库把vote_count重新同步到 RedisScheduled(fixedDelay 300000) public void syncVoteCountToRedis() { ListVoteOption options voteOptionRepository.findAll(); for (VoteOption option : options) { stringRedisTemplate.opsForZSet().add( vote:rank: option.getTopicId(), String.valueOf(option.getId()), option.getVoteCount() ); } }定时回写会让缓存短暂落后最多 5 分钟但对于投票排行榜这种对一致性要求不苛刻的读场景这个延迟完全可接受。真正不能错的是数据库里的vote_countRedis 只负责扛读流量默认数据允许丢失和重建。这里要特别声明一个边界Redis 缓存的是「票数排行」不是「投票资格」投票资格的判定始终落到数据库的唯一键上所以缓存清不清都不影响防重。5. 状态机与后台操作Spring Boot 投票的发布、截止与数据导出前三章解决了「怎么让一次投票投得对」这一章补上「怎么让投票活动被管理起来」。在真实项目里投票系统通常还要有创建投票、编辑选项、发布、截止、导出结果这些后台操作。这些操作的共同点是它们都会改变投票的状态或产生一批输出数据。5.1 发布和截止的边界状态发布操作要把投票从草稿变为进行中同时设置开始时间和结束时间。这里要注意两个边界一是投票的start_time可以晚于当前时间表示「定时发布」但status一旦变为「投票中」前端就应该立刻展示入口二是截止操作不能只改状态还要同步清理 Redis 里的排行缓存和限制入口。Transactional public void publish(Long topicId, LocalDateTime startTime, LocalDateTime endTime) { VoteTopic topic voteTopicRepository.findById(topicId).orElseThrow(); if (!topic.isDraft()) { throw new BusinessException(只能发布草稿状态的投票); } if (startTime.isAfter(endTime)) { throw new BusinessException(开始时间不能晚于结束时间); } topic.setStatus(VoteStatus.RUNNING.getCode()); topic.setStartTime(startTime); topic.setEndTime(endTime); } Transactional public void finish(Long topicId) { VoteTopic topic voteTopicRepository.findById(topicId).orElseThrow(); topic.setStatus(VoteStatus.FINISHED.getCode()); stringRedisTemplate.delete(vote:rank: topicId); }publish里的校验保证了状态从草稿到运行中的合法性finish里的delete是及时释放 Redis 里的排行数据避免已结束的投票还占着内存。投票本身不允许从头到尾「修改状态字段」之外的任何操作后再被发布所以这两个方法都没有提供「被结束的投票重新开启」的接口把不可逆性交给业务约定。5.2 投票结果的导出与校验投票结束后运营通常需要导出一份「选项 票数 支持率」的明细。导出可以直接在 Service 里生成 CSV也可以导出为 Excel。CSV 的好处是无需额外依赖且 MongoDB 场景下也能直接导入public byte[] exportVoteResult(Long topicId) { VoteTopic topic voteTopicRepository.findById(topicId).orElseThrow(); ListVoteOption options voteOptionRepository.findByTopicIdOrderByVoteCountDesc(topicId); int total options.stream().mapToInt(VoteOption::getVoteCount).sum(); StringBuilder sb new StringBuilder(); sb.append(选项,票数,支持率\n); for (VoteOption option : options) { double rate total 0 ? 0.0 : option.getVoteCount() * 100.0 / total; sb.append(option.getOptionText()).append(,) .append(option.getVoteCount()).append(,) .append(String.format(%.2f%%, rate)).append(\n); } return sb.toString().getBytes(StandardCharsets.UTF_8); }支持率用「票数 / 总票数」即可不需要额外查询流水表。如果要对数据做一次完整性校验可以在导出前执行一条 SQL 验证vote_option.vote_count的总和与vote_record的行数一致不一致时说明存在漏更新或重复流水此时导出接口应返回错误。下面这条 SQL 可以在管理后台的「校验」按钮里用SELECT o.topic_id, SUM(o.vote_count) AS option_total, (SELECT COUNT(*) FROM vote_record r WHERE r.topic_id o.topic_id) AS record_total FROM vote_option o GROUP BY o.topic_id HAVING option_total ! record_total;查询结果返回任意一行就说明某场投票的计数与流水不一致。这种不一致通常来自事务手动提交没回滚、或者并发下计数更新丢失第 3.3 节的事务边界和唯一键就是为了从源头上避免它。6. 上线前的自检清单与高并发验证技巧投票系统最怕的不是功能缺失而是「平时没事一上线并发一起来就丢票数或者能投两次」。下面几个验证手段能在发布前把这类问题暴露出来建议在本地或测试环境按顺序过一遍。6.1 用 ab 模拟并发投票观察丢票与重复流水Apache Bench 是验证投票接口最直接的压测工具不要求搭建 JMeter 环境。先准备一个可重复执行的脚本重点看两个指标接口返回的 2xx 比例以及vote_record表里有没有出现重复的用户投票记录。# 先登录拿 token或者直接用一个临时关闭登录校验的测试接口 ab -n 5000 -c 200 -T application/json \ -p /tmp/vote.json \ -H Authorization: Bearer token \ https://staging.example.com/api/vote/1/option/3-n 5000表示总请求数 5000-c 200表示并发连接数 200-p指定 POST 的 JSON 文件。压测完成后在数据库里执行一次「同一用户同一场投票的流水数」校验SELECT topic_id, user_id, COUNT(*) AS cnt FROM vote_record GROUP BY topic_id, user_id HAVING cnt 1;如果查询结果为空说明防重生效如果有数据优先检查第 3.3 节的唯一键uk_user_topic是否真的建上了而不是只靠应用层的 exists 判断。压测时注意关闭 Spring Boot 的开发模式spring.jpa.show-sql否则日志输出会掩盖真实性能瓶颈。6.2 精心准备几个边界数据来验证状态机除了压测还要用几个边界数据验证状态机和接口逻辑未到开始时间投票、投票结束后投票、删除选项后投票、重复投票、多选超过max_options。推荐写成SpringBootTest的集成测试而不是手工点接口。下面是一个精简的测试方法覆盖「投票结束后投不了票」这个规则Test void voteAfterDeadline_shouldReject() { VoteTopic topic createFinishedTopic(); assertThatThrownBy(() - voteService.vote(topic.getId(), optionId, userId)) .isInstanceOf(BusinessException.class) .hasMessageContaining(已截止); }这里的createFinishedTopic()直接构造一个 status 为 2 的投票实体并保存不需要依赖完整的发布会流程。这个测试的价值在于把前面服务里的状态判断锁死在回归测试里以后谁改了投票逻辑跑一遍测试就能发现有没有破坏截止规则。6.3 最后的检查技巧慢查询日志与缓存命中率上线前最后一步打开 MySQL 的慢查询日志slow_query_log压测时如果发现大量vote_record的插入查询超过 100ms优先检查是不是没用批量插入如果排行榜查询超过了 50ms看看是否走了vote_option的全表排序而没有建索引。Redis 方面通过INFO stats输出里的keyspace_hits和keyspace_misses可以算出缓存命中率命中率低于 90% 时多半是详情页的缓存 key 设计不合理比如把用户 ID 放进了 key导致每个用户查一次都会生成一份新缓存。调整成按topicId维度缓存再配合投票后主动失效命中率就会明显上来。压测时把ab的-k参数去掉跑一轮再对比开启 HTTP Keep-Alive 的结果两者差距过大说明服务端的连接参数还需要调。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价