尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Spring Boot + MyBatis 在线投票系统设计与实战

发布时间:2026/9/26 4:54:50

资讯中心
01
ARTICLE

Spring Boot + MyBatis 在线投票系统设计与实战

Spring Boot + MyBatis 在线投票系统设计与实战
先说明一下这个项目是我前阵子给一个内部社区做的在线投票系统技术栈就是标题里写的 Spring Boot MyBatis没有引入重型的微服务框架也没有上复杂的中间件。做完之后我发现这套组合做在线投票这类业务其实挺典型的——业务逻辑本身不复杂但并发、缓存、数据一致性、防刷这些点都需要认真处理。这篇文章就把整个系统的设计思路、表结构、核心代码、配置细节以及我实际踩过的坑都整理出来给正在做类似项目、或者准备用 Spring Boot MyBatis 练手的朋友一个参考。为什么选在线投票作为项目载体因为它麻雀虽小五脏俱全有基础 CRUD、有统计聚合、有并发写、有缓存读稍微扩展一下还能遇到分页查询、分布式锁、防重复提交这些经典问题。你把这个系统的难点啃下来市面上大部分管理类后端项目的核心套路也就掌握了。所以无论你是刚学完 Spring Boot 基础、想找个综合项目练手的新手还是要快速交付一个类似投票、问卷、评选类需求的后端开发这篇文章里沉淀的东西都可以直接抄作业。1. 场景梳理与方案选型1.1 在线投票核心需求拆解先别急着写代码我们得把投票系统的需求掰开揉碎看清楚。一个最小可用的在线投票系统至少要包含以下几个角色和流程管理员创建投票活动配置投票标题、选项列表、起止时间普通用户浏览活动详情对某个选项投票系统实时展示票数和排名活动结束后榜单锁定。如果把需求再往真实场景靠拢一点还需要考虑同一个用户不能对同一个选项重复投票不同投票活动的规则可能不一样比如有的按 IP 限制有的按用户 ID 限制热门活动会出现瞬时高并发管理端需要分页查看投票记录。我做这个项目时给自己划定了三条边界你也可以参考这个思路来定需求范围。第一不做复杂的权限系统管理员和普通用户通过简单的角色字段区分安全框架暂不引入 Shiro 或 Spring Security避免项目骨架被认证授权逻辑占满。第二不做实时推送投票结果页面用轮询配合缓存过期来刷新够用即可。第三不做多活部署单应用 MySQL Redis 的经典组合先把正确性和稳定性做扎实。边界划定之后技术选型就顺理成章了。Spring Boot 负责把整个应用的配置、启动、依赖管理收拾得服服帖帖MyBatis 负责数据访问层的 SQL 控制。为什么不用 JPA投票系统里有大量的动态 SQL 和复杂聚合统计比如按活动分组统计票数、按时间区间筛选记录MyBatis 的 XML 映射在这种场景下优势更明显SQL 是开发人员自己控制的性能和行为都可预期。1.2 Spring Boot 与 MyBatis 组合的适配点Spring Boot 这块我们不做过多赘述它的核心价值就是自动配置和生态整合。你引入一个spring-boot-starter-web内嵌 Tomcat 就起来了引入spring-boot-starter-jdbc配合 Druid 或 HikariCP数据库连接池就绪。省去了一堆 XML 配置的时间这对中小型项目来说太友好了。MyBatis 的适配点就需要好好说说了。第一SQL 与代码分离。投票系统的数据操作不是简单的单表 CRUD尤其是结果统计会涉及 GROUP BY、子查询、甚至多表联查。如果把 SQL 散落在注解里后期维护会很痛苦。我把所有复杂 SQL 都放到 Mapper XML 文件中注解只处理最简单的单表操作这个习惯帮我省了很多排查问题的时间。第二动态 SQL 极其灵活。投票活动的筛选条件是可变的比如管理端要支持按状态、按创建时间范围、按关键词查询用where、if标签组合就能优雅解决而不需要拼接字符串。第三一级缓存和二级缓存给了我们优化空间。虽然 MyBatis 的缓存机制有过争议但用好了配合 Spring 的事务边界很多读多写少的场景可以直接受益这个我们后面详细说。1.3 选型过程中的几个关键取舍做这个项目时我反复权衡了几个点这里给各位一个透明的交代。第一个是 ORM 框架选 MyBatis 还是 MyBatis-Plus。如果你只是做单表 CRUDMyBatis-Plus 的 BaseMapper 确实香得不行几乎不用写 SQL。但投票系统一旦进入统计报表和复杂查询阶段MyBatis-Plus 的 LambdaQueryWrapper 写起来并不比 XML 简单而且有时为了走它的封装反而要绕路。我的最终方案是两种都用了BaseMapper 管简单的单表操作XML 管复杂查询。实际开发中很多团队也是这么干的灵活且高效。第二个是缓存选 Caffeine本地缓存还是 Redis。对于投票这种读多写少的场景本地缓存已经能解决 80% 的热点问题。但考虑到后续可能要部署多实例以及投票计数这种需要全局一致的数据我还是引入了 Redis。本地缓存用于存活动基本信息这类几乎不变的数据Redis 用于存实时票数和用户投票记录各司其职。第三个是数据库选 MySQL 还是 PostgreSQL。这个我没犹豫MySQL 的生态太成熟了网上随便一搜就是踩坑解决方案团队招人也容易。PostgreSQL 虽然功能更强但对于投票这种简单业务没有压倒性优势没必要提高团队的认知成本。2. 数据模型设计与 MyBatis 映射细节2.1 数据库表结构设计表结构设计是整个系统的地基我的设计思路是“活动-选项-投票记录”三张核心表外加一个用户表。这里直接给出建表 SQL你跟着建就能跑起来。CREATE TABLE vote_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(64) NOT NULL COMMENT 用户名, password VARCHAR(128) NOT NULL COMMENT 密码, BCrypt加密, role TINYINT NOT NULL DEFAULT 0 COMMENT 角色: 0-普通用户, 1-管理员, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE vote_activity ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 活动ID, title VARCHAR(128) NOT NULL COMMENT 活动标题, description TEXT COMMENT 活动描述, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态: 0-未开始, 1-进行中, 2-已结束, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, max_vote_per_user INT NOT NULL DEFAULT 1 COMMENT 每人可投最大票数, create_by BIGINT NOT NULL COMMENT 创建人ID, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_time (status, start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票活动表; CREATE TABLE vote_option ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 选项ID, activity_id BIGINT NOT NULL COMMENT 活动ID, option_name VARCHAR(128) NOT NULL COMMENT 选项名称, option_desc VARCHAR(255) DEFAULT NULL COMMENT 选项描述, sort_order INT NOT NULL DEFAULT 0 COMMENT 排序号, vote_count INT NOT NULL DEFAULT 0 COMMENT 冗余票数字段, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_activity_id (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票选项表; CREATE TABLE vote_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 记录ID, activity_id BIGINT NOT NULL COMMENT 活动ID, option_id BIGINT NOT NULL COMMENT 选项ID, user_id BIGINT NOT NULL COMMENT 投票用户ID, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_option (user_id, option_id), KEY idx_activity_option (activity_id, option_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票记录表;这里我要重点解释几个设计上的小心机。vote_option表里的vote_count是一个冗余字段它就是选项当前的票数。为什么不直接每次COUNT(*)因为在线投票的特点是读榜次数远大于写入次数如果每次看榜单都去vote_record表做聚合统计数据量大了之后 MySQL 的压力会非常大。冗余一个计数字段在投票事务里加一读取时直接查出来这是典型的空间换时间。vote_record表的唯一索引uk_user_option是防重复投票的数据库级兜底。很多时候应用的校验是有漏洞的但唯一索引没有侥幸两条相同user_id option_id的记录插入第二条直接报错。这个设计在并发场景下比先查询再插入的“检查再执行”模式安全得多。2.2 MyBatis 映射文件与动态 SQL 编写有了表结构接下来看 MyBatis 怎么和这些表打交道。我先给实体类再给 Mapper 接口最后给 XML 映射三者一一对应。Data public class VoteActivity { private Long id; private String title; private String description; private Integer status; private LocalDateTime startTime; private LocalDateTime endTime; private Integer maxVotePerUser; private Long createBy; private LocalDateTime createTime; } Data public class VoteRecord { private Long id; private Long activityId; private Long optionId; private Long userId; private LocalDateTime createTime; }Mapper 接口我习惯分为两层基础单表操作继承 MyBatis-Plus 的 BaseMapper如果引入了复杂查询自己写方法。如果你不想引入 MyBatis-Plus那所有方法都自己写也不复杂。public interface VoteRecordMapper { int insertRecord(VoteRecord record); int countByUserAndOption(Param(userId) Long userId, Param(activityId) Long activityId, Param(optionId) Long optionId); ListOptionVoteCountVO countGroupByOption(Param(activityId) Long activityId); int deleteByActivityId(Param(activityId) Long activityId); }对应的 XML 文件如下?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.vote.mapper.VoteRecordMapper insert idinsertRecord parameterTypecom.example.vote.entity.VoteRecord useGeneratedKeystrue keyPropertyid INSERT INTO vote_record (activity_id, option_id, user_id, create_time) VALUES (#{activityId}, #{optionId}, #{userId}, NOW()) /insert select idcountByUserAndOption resultTypeint SELECT COUNT(1) FROM vote_record WHERE user_id #{userId} AND activity_id #{activityId} AND option_id #{optionId} /select select idcountGroupByOption resultTypecom.example.vote.vo.OptionVoteCountVO SELECT option_id, COUNT(1) AS total_count FROM vote_record WHERE activity_id #{activityId} GROUP BY option_id /select delete iddeleteByActivityId DELETE FROM vote_record WHERE activity_id #{activityId} /delete /mapper这里的几个要点我实际开发时摔过跟头写出来提醒你。第一useGeneratedKeystrue必须配合keyProperty使用否则你 insert 完拿不到自增主键后续要用记录 ID 做关联操作时会懵。第二多参数方法不要只写参数名实体类最好加Param注解否则 MyBatis 会报Parameter xxx not found默认参数名机制非常不靠谱。第三GROUP BY的查询结果如果和 VO 字段不一致可以在 SELECT 里用 AS 重命名MyBatis 会按列名自动映射到 VO 属性前提是数据库字段下划线和实体驼峰对应的配置要打开。2.3 缓存使用边界一级缓存、二级缓存与第三方缓存MyBatis 的缓存机制是面试常客也是实际开发容易出事的地方。先说结论**我项目里用了一级缓存关闭了二级缓存热点数据一律走 Redis。**理由下面捋清楚。一级缓存是 SqlSession 级别的默认开启你在同一个 SqlSession 里执行两次相同的查询第二次直接命中缓存不再查数据库。但 Spring 管理下每次数据库操作往往独立开启和关闭 SqlSession跨方法的一级缓存共享是有限制的除非你用Transactional让多个 Mapper 操作共享同一个 SqlSession。一级缓存的好处是不需要配置天然防抖坏处是重复查询时拿到的数据可能不是最新的如果你在一个事务里先查出老数据另一个线程改了库你的事务内再次查询还是老结果。二级缓存是 Mapper 级别的横跨 SqlSession。我选择关闭它的原因有三个。一是分布式环境下多实例之间的二级缓存各自为政缓存一不一致完全不可控属于典型的“省了小钱赔了大钱”。二是二级缓存粒度太粗一个 Mapper 的更新操作会清空整个 Mapper 的所有缓存结果命中率实际不高。三是投票数据有强一致性的要求票数差一票都容易被用户投诉完全没有必要冒这个风险。如果你非要用二级缓存记得在 XML 里加cache配置并且只给那些几乎只读的表用。我在项目里真正用的缓存策略是这样的活动基本信息标题、起止时间、描述用 Redis 存key 为vote:activity:{id}读取时先查 Redis没有则回源数据库并回填设置 10 分钟过期实时票数用 Redis 的 String 类型key 为vote:count:{activityId}:{optionId}投票时用INCR命令原子自增前端隔几秒拉一次用户是否已投过票用 Redis 的 Set 结构key 为vote:user:{activityId}每次投票往里SADD用户 ID查询用SISMEMBER比关系型数据库查索引快得多。这套组合的性价比非常高而且代码实现都不复杂。2.4 分页插件集成与使用避雷指南投票系统的管理端查询投票记录、活动列表都离不开分页。我用的是 PageHelper和 MyBatis 是黄金搭档。配置方式很简单引入 starter 后在 application.yml 里加几行pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSqlreasonable: true这个参数特别重要它的作用是当你传页码超出总页数时自动归位到首页或末页不会直接报错或者返回空页。还有support-methods-arguments: true允许通过 Mapper 方法参数直接传入 Page 对象不用在代码里手动调用PageHelper.startPage。PageHelper 的经典用法是这样的public PageInfoVoteRecord getRecords(Long activityId, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListVoteRecord records voteRecordMapper.selectByActivityId(activityId); return new PageInfo(records); }这里有一个非常隐蔽的坑PageHelper 的 ThreadLocal 机制。PageHelper.startPage()会把分页参数绑定到当前线程下一个执行的 MyBatis 查询会消费它。如果你 startPage 之后紧接着执行了另一条查询语句比如查了配置、查了缓存以外的表分页参数会被那个查询消费掉你本意要分页的查询反而全量返回了。为了避免这个坑我的习惯是所有分页查询方法内部立即执行查询并返回结果绝对不在 startPage 和查询之间夹带其他逻辑也不把 startPage 和查询拆到两个方法里。另一个坑是分页插件和自定义 SQL 的冲突。如果你的 SQL 里有LEFT JOINPageHelper 的 count 查询自动生成的语句是SELECT COUNT(1) FROM (你的SQL) tmp这个在大多数情况下没问题。但如果 SQL 里有DISTINCT、GROUP BY、或者UNION生成的 count 可能会统计出错误的总条数。解决办法是手动写 count 查询PageHelper 支持在 Mapper 方法旁边写一个方法名_count的同参数方法它会优先使用你的手写 count。分页实际测试下来百万级别的 vote_record 表查询速度完全可接受因为分页 SQL 本身走了索引加上合理的 where 条件一次查询稳定在几十毫秒级别。如果你以后数据量大了可以考虑在 activity_id 上再叠加分区不过这些是后话不要在项目初期过度设计。3. 核心流程实现与接口落地3.1 投票主流程的完整实现投票这个动作的端到端链路是前端点选选项 - 请求后端 - 校验活动状态 - 校验用户是否已投 - 记录投票 - 更新计数。这个链路里有三个环节容易出并发问题我们必须用代码和数据库机制双重卡死。先看控制层代码我一直习惯 Controller 尽量薄只做参数接收和结果封装PostMapping(/vote) public ResultVoid vote(RequestBody VoteRequest request) { Long userId currentUserId(); voteService.vote(userId, request.getActivityId(), request.getOptionId()); return Result.success(); }核心逻辑写在 Service 层Transactional public void vote(Long userId, Long activityId, Long optionId) { // 1. 活动状态校验 VoteActivity activity getActivityFromCache(activityId); if (activity null) { throw new BusinessException(活动不存在); } LocalDateTime now LocalDateTime.now(); if (now.isBefore(activity.getStartTime()) || now.isAfter(activity.getEndTime())) { throw new BusinessException(活动不在投票时间内); } // 2. 业务层防重校验 if (recordMapper.countByUserAndOption(userId, activityId, optionId) 0) { throw new BusinessException(你已经投过该选项); } // 3. 写入投票记录 VoteRecord record new VoteRecord(); record.setUserId(userId); record.setActivityId(activityId); record.setOptionId(optionId); record.setCreateTime(LocalDateTime.now()); try { recordMapper.insertRecord(record); } catch (DuplicateKeyException e) { throw new BusinessException(你已经投过该选项请勿重复投票); } // 4. 更新冗余计数字段 optionMapper.increaseVoteCount(activityId, optionId); // 5. 更新 Redis 计数缓存 String countKey buildCountKey(activityId, optionId); redisTemplate.opsForValue().increment(countKey); }这段代码里有几个细节你可能觉得多余但都是血泪教训。先校验后插入只能解决顺序执行下的重复问题解决不了并发的重复问题所以我在插入后必须捕获DuplicateKeyException这是数据库唯一索引兜底后的最后一道防线。你以为查了一次countByUserAndOption就万事大吉了两个线程同时查到 0然后同时插入没有唯一索引的话就会出现两条记录唯一索引则会让其中一个线程插入失败异常类型是DuplicateKeyException一定要捕获并转成业务友好的提示。Transactional注解保证了投票记录写入和票数更新要么同时成功要么同时失败不会出现“记录写进去了但票数没加”这种数据不一致。这里提醒一点事务只保护数据库操作Redis 的自增操作不受事务控制所以我把 Redis 更新放在数据库操作之后。极端情况下如果 Redis 更新失败下次用户读取票数时会通过缓存重建逻辑从数据库恢复不会造成永久性错误。3.2 防重复投票的多层防线防重复是投票系统的灵魂我做了一个三层防线体系你参考的时候可以根据自己的需求裁剪。第一层是前端限制用户点击投票后按钮置灰禁止二次点击。这层没有任何安全性纯粹为了用户体验防手抖不防恶意攻击。第二层是后端业务校验也就是上面代码里的countByUserAndOption这层能拦截大部分正常用户的重复操作。但这层在分布式场景下有竞态条件两个并发请求可能同时通过校验。第三层是数据库唯一索引这是最可靠的一层没有任何并发能绕过它。只要插入了重复记录数据库直接拒绝我们再捕获异常给出友好提示。如果你做的投票对防刷要求更严格比如同一个用户对同一个活动只能投一票而不是对每个选项只能投一票那唯一索引应该建在(user_id, activity_id)上而不是(user_id, option_id)上。这里一定要想清楚业务规则再建索引我见过不少项目上线后才发现索引建错了改起来又费劲又容易出故障。如果要进一步防机器刷票可以引入验证码或 IP 限流。验证码我这里没做因为内部系统用户量小信任度较高。IP 限流的话可以用 Spring Boot 内置的拦截器也可以在网关层做逻辑很简单同一个 IP 每分钟最多投 N 次超限返回错误码。这个对投票这类低频率操作特别有效。3.3 结果统计与榜单实时展示榜单展示是投票系统的门面用户投完票第一件事就是看当前的排名。我的实现思路是榜单数据从 Redis 直接读兜底从 MySQL 查。Redis 里我用 ZSet 存储每个活动的票数排行key 为vote:rank:{activityId}投票时执行redisTemplate.opsForZSet().incrementScore(vote:rank: activityId, String.valueOf(optionId), 1);然后查询榜单时SetZSetOperations.TypedTupleString tuples redisTemplate.opsForZSet().reverseRangeWithScores(vote:rank: activityId, 0, 9);ZSet 的天然有序特性让排行前十的查询成了一行代码的事而且时间复杂度是 O(log(N) M)性能极好。这里我选择存 optionId 而不是 optionName因为选项名称可能被修改存 ID 之后联表查名称更安全。展示时再批量查 option 信息组装成前端需要的 VO。如果 Redis 里的数据因为某些原因丢失了怎么办我给榜单写了一个重建方法从vote_record表按 activityId 分组统计票数然后重新灌入 Redis。这个操作在活动发布时执行一次如果发现 Redis 中对应 key 不存在也可以触发重建。生产上你可以写个定时任务兜底每小时检查一次 Redis key 是否存在不存在则重建。3.4 高并发场景下的限流与降级策略在线投票活动有时候会有爆发式的流量比如评选大赛最后一小时的冲刺。这种情况下如果所有请求都直接打到 MySQL连接池很快就会耗尽整个系统瞬间雪崩。这里就像很多基础设施一样需要提前做限流。我的方案比较简单基于 Spring Boot 内置的拦截器 令牌桶算法实现了一个轻量限流器。核心思路是每个活动每秒最多允许处理多少投票请求超过的部分直接返回“操作太过频繁请稍后再试”。Component public class VoteRateLimiter { private final RateLimiter limiter RateLimiter.create(200); public boolean tryAcquire() { return limiter.tryAcquire(Duration.ofMillis(100)); } }这里用的是 Google 的 Guava RateLimiter当然你也可以用 Resilience4j 或者 Sentinel找自己喜欢的方式就行。限流阈值怎么定主要看数据库的支撑能力。就在 200 QPS 阈值下MySQL 是完全能扛住的前提是你要给 Mapper 建好索引。降级策略方面我的经验是当 Redis 不可用时系统自动降级为直查 MySQL虽然慢一点但保证核心功能可用当 MySQL 连接池被打满时拒绝新投票请求但读榜接口仍然可用因为榜单走的是 Redis。你要理解一点任何系统的保护都比崩溃强宁可拒掉一小部分请求也不能让整个服务不可用。4. 常见问题排查与实战笔记4.1 慢 SQL 排查MyBatis 执行更新为什么会慢有次测试反馈投票速度变慢我排查后发现是optionMapper.increaseVoteCount这个更新语句的问题。原 SQL 是这样写的UPDATE vote_option SET vote_count vote_count 1 WHERE id #{optionId}看起来毫无问题但执行计划显示WHERE id #{optionId}没有走主键而是全表扫描。什么原因表的主键是id但我在实体类里将activityId也建了索引而 MyBatis 的#{}参数类型推断有时会异常。实际上问题不在 SQL 本身而在于这个表和活跃的投票活动数量。当某个热门活动的投票操作同时进行时vote_option表的行锁竞争会变得极其激烈。同一活动的所有投票请求都在抢同一行数据别的活动也在抢自己的行锁等待时间自然上去了。我的解决办法是把计数字段的更新挪到了 Redis 上数据库的vote_count改为异步批量更新或者干脆不用这个字段只在活动结束时做一次全量统计。线上验证下来Redis 自增的 TPS 轻松过万MySQL 的压力骤降。如果你遇到类似“更新执行慢”的情况排查顺序我建议是先看 SQL 执行计划有没有走索引再查是不是有锁等待最后看是不是数据库连接池被打满。慢不代表语句本身有问题很可能是环境问题。4.2 缓存与数据库一致性问题缓存系统最经典的问题之一就是“更新了数据库缓存还是旧值”。投票系统的场景里用户投完票后页面上显示的票数可能还是自己投票前的数字体验极差。我采用的策略是Cache Aside Pattern旁路缓存先更新数据库再删除或更新缓存。具体到票数这个字段我选择更新缓存而不是删除缓存因为票数自增的语义用 Redis 的INCR操作最合适它在 Redis 内部是原子的不需要读取旧值再计算。但这里有个小坑如果“更新数据库成功但更新 Redis 失败”怎么办我在前面提到了补偿机制这里详细说一下。给 Redis 里的每个票数 key 设置一个生存时间比如 30 分钟。时间一到key 自动过期下次读取时发现没有缓存就会回源数据库重新计算票数并回填。这样即使 Redis 更新失败最多只是缓存里的数据旧一点不会永久错下去。用过期时间兜底比任何复杂的消息队列方案都省心。4.3 分页查询时统计结果不对这个问题我是在管理端做投票记录导出时遇到的。管理员在管理端翻页查看投票记录分页参数一切正常但某一页的数据数量比预想的少有时候还会出现空页。排查下来发现是 PageHelper 和 count 语句的兼容性问题。我当时的 SQL 带了一个LEFT JOIN关联查询用户名称PageHelper 自动生成的 count 变成了SELECT COUNT(1) FROM ... LEFT JOIN ...这本身没问题。问题出在LEFT JOIN存在一对多关系时主表一条记录对应了子表多条记录count 就会翻倍。比如一条投票记录对应两条审核记录数据总量就变成了真实的两倍导致最后一页出现空白。解决办法是给该查询手写 count 语句。PageHelper 有一个约定当 Mapper 方法名为selectVoteRecordPage时你在同一个 Mapper 里写一个selectVoteRecordPage_COUNT方法注意命名规范PageHelper 会自动识别并优先使用这个 count 方法。我在 count 方法里只查主表SELECT COUNT(1) FROM vote_record WHERE activity_id #{activityId}这样就保证了 count 的准确性。分页插件这类工具用好了效率翻倍用不好就是隐形炸弹关键是你必须理解它自动执行的逻辑。4.4 日期时间与时区映射的坑Spring Boot 项目里 LocalDateTime 和 MySQL DATETIME 的交互默认情况下是能正常工作的但有时会遇到程序读取的时间比数据库时间多了 8 小时或者少了 8 小时的问题。这个坑很隐蔽因为开发环境往往不报错只是时间不对等发现了已经产生了脏数据。我排查这个问题时定位到的根源是 JDBC 驱动连接串里时区参数不对。在 application.yml 里如果你的连接串是url: jdbc:mysql://localhost:3306/vote?useUnicodetruecharacterEncodingutf8MySQL 服务端默认时区是系统时区JDBC 驱动会用自己所在 JVM 的时区去解析两个时区不一致就出现偏移。我的解决方式是连接串里显式指定url: jdbc:mysql://localhost:3306/vote?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai同时数据库里统一用 DATETIME 类型Java 实体里统一用 LocalDateTime不要混用 java.util.Date 和 Timestamp不然各种隐式转换会让人崩溃。一个团队写代码得把这些基础规则定清楚否则项目越大越乱。4.5 MyBatis 面试热点问题的工程化回答做个投票系统其实相当于把 MyBatis 的很多高频面试考点过了一遍。这里记录几个常见问题的工程化回答说不定对准备面试的朋友有用。第一个问题MyBatis 中#{}和${}的区别。工程上的答案是#{}是预编译参数会生成占位符?可以有效防止 SQL 注入${}是字符串拼接直接把内容拼进 SQL有注入风险。我写分页时如果用了${}那一定是参数被我硬编码控制住了。强烈建议在所有用户输入场景都使用#{}。第二个问题MyBatis 的一级缓存和二级缓存。一级缓存是 SqlSession 级别自动开启事务范围内有效二级缓存是 Mapper 级别需要手动开启跨 SqlSession 生效但多实例不一致我上线时默认关闭。你可以这样回答面试官顺便补充一句实际项目里热点数据我会用 Redis 而不是 MyBatis 自带的缓存因为可控性更好。第三个问题MyBatis 的 Mapper 接口和 XML 是怎么关联的。答案是namespace 必须等于接口全限定名方法名等于 XML 中的语句 id参数通过方法参数和Param传递返回类型通过 resultType 或 resultMap 映射。我项目里的经验是写完 XML 后立即用 IDE 的插件去校验避免低级错误。这三个问题的回答如果配合你实际项目中的案例来阐述面试官的认可度会高得多泛泛背诵答案和真实踩过坑的理解差距是一耳朵就能听出来的。写在最后的操作体会文章写到这里最后分享两个个人体会比较深的东西。第一在线投票系统虽然看起来小但它是一个极好的后端练习项目。它让我在一套完整工程里同时接触到了数据库索引设计、事务边界、缓存策略、并发控制、防重幂等、分页插件、限流降级这些后端高频技术点。你把这个系统从零写完再带着问题去阅读 Spring Boot 和 MyBatis 的源码理解深度会完全不一样。第二任何技术方案都要以业务为核心约束。比如缓存一致性再复杂的方案都不如给 key 设置一个合适的过期时间来得省心比如防重复投票再花哨的代码逻辑都不如一条数据库唯一索引来得可靠。做技术选型时永远要先问自己这个方案解决了我什么问题又在将来可能埋下什么雷把这两点想明白你的代码才会越来越有质感。如果你也正准备动手写一个投票系统或者正在纠结要不要用 Spring Boot MyBatis我建议你别犹豫直接开干。把表建好接口定义好再一步步填充业务逻辑你会在过程中收获比任何教程都多的经验。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。