SpringBoot调查问卷系统实战:从数据库设计到Docker部署全解析
发布时间:2026/10/9 8:36:27 锦皓数字建站

拿SpringBoot做一套调查问卷系统听起来是标准的CRUD模板题真正动手做才发现坑全藏在“问卷”这两个字里题型五花八门、答卷防重、统计分析、定时回收哪一环单拎出来都够写一篇长文。这篇文章我想从一个已经落地的SpringBoot调查问卷系统出发把从需求拆解到部署上线的完整链路捋一遍适合准备用SpringBoot从零搭建问卷、表单类系统的开发者也适合已经做了一半、想看看别人怎么处理防重复提交、统计和部署这些坑的朋友。1. 为什么是SpringBoot先盘一份被低估的需求清单1.1 问卷系统到底在做哪些事很多人对问卷系统的第一印象是“不就是维护几个表和两个页面”实际上问卷类业务的核心复杂度在于状态和数据的组合。管理端至少要支持问卷模板的创建与编辑题型覆盖单选、多选、填空、评分、矩阵、排序问卷的发布、回收、归档对已回收答卷的明细查看、按条件筛选、Excel导出按题目做汇总统计和交叉分析。用户端则要面对问卷填写、防重复提交、在时间窗口内作答、填写中途断网后的恢复、提交后回执展示等。这还没算权限管理。管理员、编辑员、普通用户看到的问卷范围不一样问卷本身还有分类、复制、版本更新这类在需求评审里经常被一笔带过、写代码时却绕不开的琐碎功能。把这些需求摊开你会发现问题主要集中在三块第一问卷模板的数据结构如何设计才能应对多样题型和后续修改第二答卷数据怎么存储才能兼顾明细查询和统计性能第三一套可靠的状态机如何管理从草稿到归档的整个生命周期。这三块恰好都是SpringBoot生态里很成熟、但没人替你封装好的领域逻辑。1.2 我用的技术栈与选型理由我的方案是前后端分离后端SpringBoot前端Vue3加Element Plus数据库MySQL 8.0缓存和分布式锁用Redis持久层用MyBatis-Plus。整套配置如下组件版本用途选型理由SpringBoot2.7.x主框架稳定、文档多、依赖兼容性强MyBatis-Plus3.5.xORM动态条件查询写起来效率高MySQL8.0主数据库问卷系统的数据量用MySQL足够Redis6.x缓存、分布式锁防重复提交、缓存问卷模板Vue3 Element Plus3.x管理后台和用户端组件全、表格表单效率高很多新人上来就追SpringBoot 3.x我的建议是除非你已经很熟悉JDK17的模块化生态否则2.7.x在问卷调查这类中小型系统里是更稳的选择。3.x把javax包迁移到了jakarta很多老依赖、老教程、老配置片段直接失效排查成本远高于它带来的性能收益。持久层为什么选MyBatis-Plus而不是Spring Data JPA这也是实际对比后的结论问卷系统的统计查询基本都是动态条件按题型、选项、时间范围、答题人属性各种组合过滤MyBatis-Plus的QueryWrapper和自定义XML能把这类组合查询写得很直接JPA在这类场景下虽然也能做但动态规格的代码量和调试成本都要高一些。2. 数据库建模一对多关系的边界与冗余字段的取舍2.1 五张核心表的设计思路问卷系统的数据模型核心是“问卷—题目—选项—答卷”这条链。我这里没有引入过多抽象就是五张表再加一张可选分类表字段设计务必让后续统计查询简单直接。questionnaire问卷主表存标题、描述、状态、开始时间、结束时间、创建人、访问码。question题目表存所属问卷、题型、题干、是否必答、排序号。question_option选项表存题目ID、选项文本、排序号、附加分值。answer_record答卷主表存问卷ID、答题人标识、开始时间、提交时间、IP、耗时。answer_detail答卷明细表存每条答卷的每道题作答内容。题目表里有个关键字段只用了int类型标识0代表单选、1代表多选、2代表填空、3代表评分、4代表矩阵而不是直接存字符串。后续所有枚举转换都在后端统一管理避免前端传值或Excel导出时出现“答案类型对不上”的脏数据。选项表里我额外加了一个score字段用于打分题和加权统计这个字段一开始没设计后来补上去之后为了历史数据兼容费了不少劲。answer_detail表是这套模型里最容易写错的地方。我的做法是一个答卷中的一道题对应一行记录单选和多选统一把选项ID存进option_ids字段多个选项用英文逗号分隔填空或文本题存text_answer字段。这样批量插入很快行数也少统计时配合FIND_IN_SET或LIKE处理多选在小数据量下完全够用。2.2 为什么不用JSON一把梭存问卷有一种很诱人的设计把整个问卷模板包括题目、选项、甚至逻辑跳转全部存成一个JSON字段答卷也整体存成JSON。开发时确实爽增删题目不用动表结构改版也简单。但它有两个致命问题。第一是统计性能要分析某道单选各选项的占比规范化存储直接GROUP BY就能秒出JSON存储则要把所有答卷拉到应用内存里解析一遍问卷数据量上万之后这个操作会让接口卡到怀疑人生。第二是修改成本如果题目结构变化JSON里旧数据和新数据完全不在一个schema里代码里到处都要写兼容分支。我的建议是如果这套系统只做“收集”而不做“分析”JSON方案完全可行但只要涉及按题目、选项维度的统计就老老实实拆表。规范化之后的查询、联表、加索引、写汇总表都有明确路径团队协作时也让后续接手的人少踩坑。2.3 被很多人忽略的字段细节这套模型里我有四个字段是强制约定的version做乐观锁deleted做逻辑删除create_time和update_time用MyBatis-Plus的自动填充统一搞定。问卷状态流转和答卷提交涉及并发更新乐观锁字段是必需品逻辑删除则保证管理后台能恢复误删的问卷。自动填充这块定义一个MetaObjectHandler实现类Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }实体类里对应字段加上TableField(fill FieldFill.INSERT)和TableField(fill FieldFill.INSERT_UPDATE)之后所有插入和更新操作都不用手动维护时间。这个细节看起来小但问卷榜单页、统计报表页几乎都要按时间排序时间字段的可靠性直接影响所有下游功能。还有排序号sort_order虽然可以用question表的主键递增来凑合但问卷编辑页里“上移、下移、拖拽排序”是标配功能没有单独的排序字段做起来会非常别扭。我所有排序字段都是int类型预留了步长100避免频繁重排时大量更新语句。3. 核心接口链路从创建问卷到回收答卷3.1 问卷发布的状态机与题目锁定逻辑问卷有个生命周期我定义成四个状态0草稿、1已发布、2已回收、3已归档。状态流转只有三条合法路径草稿到发布、发布到回收、回收到归档。任何跨状态跳跃都在Service层拦掉。状态机的实现看起来简单真正容易出错的是“发布后题目锁定”的规则。我的处理是问卷处于草稿状态时可以编辑题目和选项一旦发布原题内容就不允许修改了只允许停用某道题或增加新题目。这是为了保证统计口径稳定——如果已经有1000份答卷你中途改了单选题的选项文本那这1000份数据在新旧口径下都无法对齐。发布接口的伪逻辑大致是Transactional public Long publish(Long id) { SurveyQuestionnaire questionnaire getById(id); if (questionnaire.getStatus() ! STATUS_DRAFT) { throw new BusinessException(只有草稿状态才能发布); } if (questionnaire.getQuestionCount() null || questionnaire.getQuestionCount() 1) { throw new BusinessException(问卷至少要包含一道题目); } if (questionnaire.getEndTime().isBefore(LocalDateTime.now())) { throw new BusinessException(结束时间必须晚于当前时间); } // 生成访问码设置发布时间 questionnaire.setStatus(STATUS_PUBLISHED); questionnaire.setAccessCode(generateAccessCode()); updateById(questionnaire); // 清理缓存中的旧模板 redisTemplate.delete(survey:template: id); return id; }发布之后用户端拿到的是带访问码的短链接后端根据访问码找到问卷并返回完整模板。访问码我用6位随机字符串冲突概率很低加上唯一索引兜底生成时一旦碰撞就重新生成。3.2 答卷提交防重复、并发与事务一致性答卷提交是整个系统里并发压力最集中的地方。我的实现思路分四步校验问卷状态、校验时间窗口、防重复提交、批量插入。防重复提交是这里最关键的一步。登录用户可以直接用userId做标识匿名问卷则对IP加UserAgent做哈希来标识。我用Redis分布式锁加数据库唯一索引双保险核心代码如下public SubmitResult submit(SurveySubmitDTO dto) { String lockKey survey:submit: dto.getQuestionnaireId() : dto.getUserKey(); boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(30)); if (!locked) { throw new BusinessException(正在提交中请勿重复操作); } try { SurveyQuestionnaire questionnaire getPublishedQuestionnaire(dto.getQuestionnaireId()); if (questionnaire.getStatus() ! STATUS_PUBLISHED) { throw new BusinessException(问卷不在可填写状态); } LocalDateTime now LocalDateTime.now(); if (now.isBefore(questionnaire.getStartTime()) || now.isAfter(questionnaire.getEndTime())) { throw new BusinessException(不在问卷开放时间范围内); } // 数据库唯一索引再次兜底 saveAnswerRecordAndDetails(dto); } finally { stringRedisTemplate.delete(lockKey); } return SubmitResult.success(); }数据库层面answer_record表建一个(questionnaire_id, user_key)的唯一索引。Redis锁防的是瞬时并发唯一索引防的是极端情况下锁过期后仍然重复插入。两道防线缺一不可。这里还有个事务边界的教训saveAnswerRecordAndDetails方法里不要调用任何外部接口更不要做文本分词这类耗时操作。我踩过一次坑在事务里做了敏感词检查调用单次耗时多了几百毫秒高峰期拖垮了数据库连接池。正确做法是先把答卷完整落库再异步处理其他逻辑。批量插入明细时我用MyBatis-Plus的批量插入每批200条左右。一道问卷动辄三四十题一次提交可能要插几十行明细使用分批插入能显著降低大事务带来的锁竞争。3.3 答卷分页查询与Excel导出管理后台的答卷列表最常用的场景是按问卷ID、提交时间段、答题人关键字进行分页筛选。这部分的查询条件组合非常灵活MyBatis-Plus的QueryWrapper能省很多事LambdaQueryWrapperAnswerRecord wrapper new LambdaQueryWrapper(); wrapper.eq(AnswerRecord::getQuestionnaireId, questionnaireId) .eq(StringUtils.hasText(userKey), AnswerRecord::getUserKey, userKey) .between(startTime ! null endTime ! null, AnswerRecord::getSubmitTime, startTime, endTime) .orderByDesc(AnswerRecord::getSubmitTime);导出Excel我用的EasyExcel这里最需要注意的是“流式导出”。我早期用一次把全部数据查出再写入Excel的做法五万份答卷直接内存溢出。改成EasyExcel的WriteHandler之后数据是游标式一行行写到文件的内存占用几乎不变。导出文件名里带上问卷标题和日期方便运营侧存档。4. 统计分析与定时任务让问卷数据沉淀出价值4.1 单选多选统计的SQL与“写时统计”策略统计模块是问卷系统区别于普通表单系统的核心。单选统计非常简单一张SQL就能算完SELECT qd.id AS question_id, qd.option_ids, COUNT(*) AS answer_count FROM answer_detail qd WHERE qd.questionnaire_id #{questionnaireId} AND qd.question_type 0 GROUP BY qd.id, qd.option_ids;多选统计因为option_ids存储的是逗号分隔字符串直接GROUP BY会按整个组合分组不符合“每个选项独立计数”的需求。我的做法是先查明细再用FIND_IN_SET按选项拆开计数或者更稳妥的方法是在应用层解析一遍。数据量小时完全没问题但如果问卷量级到了一定程度实时解析就会拖慢统计接口。我最终采用的是“写时统计”策略在答卷提交事务成功之后额外把这些题目的答案同步写进一张answer_option_stat统计表每个选项一张汇总行。统计接口直接读这张表毫秒级返回。代价是存储空间多一点换来的是首页看板、实时报表的流畅体验。这套思路在数据量上来之后会让你省掉大量重构成本。4.2 交叉分析与文本题的关键词处理交叉分析是问卷运营里很有用的功能比如“不同部门的同事在选择某项福利时有没有明显差异”。实现思路是分组维度用SQL的CASE WHEN配合GROUP BY把用户属性部门、职级、城市拆成桶再对每个桶内的选项计数做对比。这里的前提是答卷记录和用户属性表能关联上所以answer_record表里我额外冗余了一个user_group字段来自用户主表避免统计时频繁连表。文本题的自动分析是我后来加的功能用到了hanlp分词库。在SpringBoot里接入很简单引入依赖后初始化一个全局的词法分析器然后对填答题的文本做关键词提取把高频词汇总成词频表管理后台就能看到“用户反馈里出现最多的词是哪个”。分词器的初始化要放在启动阶段不要每次请求都new一个否则加载词库的时间会让你怀疑人生。这里顺便记录一下我的分词应用方式写一个async方法问卷提交后异步处理文本题答案把关键词、词频、关联问卷ID写入text_keyword表。这样既不影响主链路提交速度也能让运营侧在几分钟内看到最新的词云数据。4.3 SpringBoot定时任务的三个落地场景定时任务这块我用的是SpringBoot自带的EnableScheduling加Scheduled不需要引入额外框架。具体应用了三个场景。第一个是每日凌晨汇总前一天的答卷统计数据到汇总表运营早上打开后台就能看到昨天的回收量和各题型完成率。第二个是巡检问卷有效期把已经超过endTime的问卷从“已发布”自动改为“已回收”免得人工漏操作。第三个是每周给管理员生成一次简单的数据简报邮件主题、回收量、平均填写时长都在邮件正文里用的是Spring的邮件封装。定时任务代码示例Component public class SurveyScheduleTask { Scheduled(cron ${survey.stats.daily-cron:0 0 1 * * ?}) public void dailySummary() { ListQuestionnaire needSummary questionnaireMapper.selectNeedSummary(); for (Questionnaire q : needSummary) { summaryService.buildDailySummary(q.getId()); } } Scheduled(cron ${survey.stats.check-cron:0 */30 * * * ?}) public void autoRecycle() { questionnaireMapper.updateExpiredStatus(); } }cron表达式我建议都放在配置文件里用占位符提供默认值这样部署到不同环境时可以随时调整而又不用改代码。这里有一个多实例部署才会踩到的坑定时任务在多个服务实例上会重复执行。比如两个后端实例同一分钟内两个实例都会执行autoRecycle导致状态更新重复跑。最简单的解决方案是加一个Redis分布式锁执行任务前先尝试占锁拿不到锁的实例直接跳过。这个方案比引入ShedLock更轻因为我们的Redis本来就在用。5. Docker化部署与性能优化细节5.1 用Docker打包SpringBoot服务的完整流程部署这块我先把SpringBoot服务打成Docker镜像再在服务器上用容器方式运行。多阶段构建的Dockerfile如下FROM maven:3.8.6-openjdk-8 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests FROM openjdk:8-jre-alpine WORKDIR /app COPY --frombuild /app/target/survey-system.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]多阶段构建的好处是最终镜像只保留运行环境不会带着整个Maven仓库镜像体积少了好几百兆。国内构建镜像时有个非常实际的问题从官方源拉取基础镜像和依赖特别慢我配置了阿里云的镜像加速器并在Maven的settings.xml里配置了国内仓库镜像整体构建时间从十几分钟降到两分钟以内这个优化谁做谁知道。如果服务器装的是宝塔面板Docker部署也很方便把Dockerfile和项目源码传到服务器在宝塔的Docker管理器里构建镜像再把宿主机的8080端口映射到容器的8080一个命令就能跑起来。数据库和Redis直接用宝塔面板自带的MySQL和Redis服务容器内通过宿主机内网IP访问。5.2 高频接口的缓存与索引优化问卷模板属于典型的“读多写少”数据我把模板详情缓存到了Rediskey就是问卷ID发布和回收时主动删除缓存。这样用户端每次打开问卷都直接走缓存QPS轻松上一两百也没压力。数据库索引方面三个索引是必建的answer_record表的(questionnaire_id, user_key)唯一索引、answer_detail表的(questionnaire_id, question_id)联合索引、question表的(questionnaire_id, sort_order)索引。没有这些索引答卷列表、统计汇总、题目顺序查询在数据量上去后都会全表扫描。列表页的分页还有一个深分页优化技巧管理后台查看答卷时翻到第100页以后MySQL的OFFSET会越来越慢。我的解决办法是改成游标式分页前端传最后一行的idSQL用WHERE id #{lastId} ORDER BY id DESC LIMIT 20翻页性能基本恒定。性能优化最能让你直观感受到差距的还是统计模块。最初版本统计大题量问卷时要实时扫answer_detail表一万份答卷要两秒多改为写时统计后接口响应直接压在五十毫秒以内这一步算是整套系统里投入产出比最高的优化之一。如果以后问卷提交量继续暴涨我还有两个扩展方向一是接入消息队列做异步削峰把提交流量先打到队列里再稳定落库二是把海量答卷明细定期同步到分析型存储交给后续的数据分析组件去处理。中小型系统没必要一开始就上这套先把单库单表的优化做到位更实际。6. 真实踩坑记录版本、事务、并发与格式问题6.1 SpringBoot版本太高带来的连锁反应这个坑我印象极深。最开始我图新鲜用了SpringBoot 3.2结果一连串问题直接教我做人原有的javax.servlet全部变成jakarta.servlet很多老工具类的import路径全部失效SpringFox的Swagger不兼容接口文档从零开始换配置部分第三方分页插件、代码生成器也停更了。后来我查了一圈发现问卷调查这类系统根本没有必要抢新版本稳定才是第一诉求。我最后锁定了SpringBoot 2.7.x所有依赖都换成2.x兼容版本花了小半天把所有import和配置调通。写这套系统的朋友如果在选型阶段我的建议是先确认你的核心第三方依赖是否已经适配了SpringBoot 3.x再去决定用哪个版本不要为了“新”而新。6.2 事务与锁的两个经典陷阱第一个陷阱是Transactional自调用失效。我在写问卷复制功能时在同一个类的内部方法里调用了一个带事务注解的方法结果事务完全没有生效部分数据插入成功、部分失败还很难复现。原因是Spring的声明式事务基于代理对象内部自调用走的是this而不是代理注解被绕过。解决办法很直接把需要事务的内部逻辑拆到另一个Service里或者手动注入自身引用后再调用。第二个陷阱是大事务拖垮性能。答卷提交时我一度把文本分词、问卷指标更新、Excel导出准备都放在同一个事务里高峰期数据库连接池被长时间占满。后面把所有非核心操作全部移到事务外事务内只做校验、落库、更新关键状态接口耗时立刻降下来。6.3 时区、序列化与参数校验的边角问题时区问题是最不起眼但最容易出错的。MySQL连接串里如果没有加serverTimezone参数LocalDateTime类型会出现8小时的时差。我统一在JDBC配置里指定了serverTimezoneAsia/Shanghai并且规定所有时间字段在Java侧统一用LocalDateTime一律不转字符串这之后时区问题彻底消失。LocalDateTime在JSON序列化时也翻过车。SpringBoot默认的Jackson如果没配JavaTimeModule返回给前端的日期格式要么是一串数字要么直接序列化报错。我的解决方案是全局配置一个ObjectMapper统一日期格式为“yyyy-MM-dd HH:mm:ss”前端展示时什么都不用处理。参数校验方面我用的是spring-boot-starter-validation的Validated加自定义注解。比较实用的一个规则是创建问卷时如果设计了必答题但用户提交的答卷对应选项为空需要有清晰的中文业务提示不要直接抛500。所以我在全局异常处理器里统一捕获MethodArgumentNotValidException和BusinessException返回统一的响应结构这样前后端联调时不会被一堆乱七八糟的异常信息搞崩心态。做完整套系统回头再看问卷调查类项目最难的地方从来不是代码量而是状态管理和数据口径的严谨性。最值得多花时间设计的是统计模型和防重复机制这俩决定了系统在数据量上来之后还能不能稳定服务。如果你也要做类似的表单、投票、评测系统我建议先把这几类题目的数据通路想清楚再写代码不要急着画页面。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。