☰
Spring Boot + MyBatis 在线投票系统设计与实战
2026/9/26 4:54:49 网站建设 项目流程

先说明一下:这个项目是我前阵子给一个内部社区做的在线投票系统,技术栈就是标题里写的 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。如果你只是做单表 CRUD,MyBatis-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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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); List<OptionVoteCountVO> countGroupByOption(@Param("activityId") Long activityId); int deleteByActivityId(@Param("activityId") Long activityId); }

对应的 XML 文件如下:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.vote.mapper.VoteRecordMapper"> <insert id="insertRecord" parameterType="com.example.vote.entity.VoteRecord" useGeneratedKeys="true" keyProperty="id"> INSERT INTO vote_record (activity_id, option_id, user_id, create_time) VALUES (#{activityId}, #{optionId}, #{userId}, NOW()) </insert> <select id="countByUserAndOption" resultType="int"> SELECT COUNT(1) FROM vote_record WHERE user_id = #{userId} AND activity_id = #{activityId} AND option_id = #{optionId} </select> <select id="countGroupByOption" resultType="com.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 id="deleteByActivityId"> DELETE FROM vote_record WHERE activity_id = #{activityId} </delete> </mapper>

这里的几个要点我实际开发时摔过跟头,写出来提醒你。第一,useGeneratedKeys="true"必须配合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: count=countSql

reasonable: true这个参数特别重要,它的作用是当你传页码超出总页数时自动归位到首页或末页,不会直接报错或者返回空页。还有support-methods-arguments: true,允许通过 Mapper 方法参数直接传入 Page 对象,不用在代码里手动调用PageHelper.startPage。

PageHelper 的经典用法是这样的:

public PageInfo<VoteRecord> getRecords(Long activityId, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); List<VoteRecord> records = voteRecordMapper.selectByActivityId(activityId); return new PageInfo<>(records); }

这里有一个非常隐蔽的坑:PageHelper 的 ThreadLocal 机制。PageHelper.startPage()会把分页参数绑定到当前线程,下一个执行的 MyBatis 查询会消费它。如果你 startPage 之后紧接着执行了另一条查询语句,比如查了配置、查了缓存以外的表,分页参数会被那个查询消费掉,你本意要分页的查询反而全量返回了。为了避免这个坑,我的习惯是所有分页查询方法内部立即执行查询并返回结果,绝对不在 startPage 和查询之间夹带其他逻辑,也不把 startPage 和查询拆到两个方法里。

另一个坑是分页插件和自定义 SQL 的冲突。如果你的 SQL 里有LEFT JOIN,PageHelper 的 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 Result<Void> 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);

然后查询榜单时:

Set<ZSetOperations.TypedTuple<String>> 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?useUnicode=true&characterEncoding=utf8

MySQL 服务端默认时区是系统时区,JDBC 驱动会用自己所在 JVM 的时区去解析,两个时区不一致就出现偏移。我的解决方式是连接串里显式指定:

url: jdbc:mysql://localhost:3306/vote?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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,我建议你别犹豫,直接开干。把表建好,接口定义好,再一步步填充业务逻辑,你会在过程中收获比任何教程都多的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询