问卷系统设计之道:数据模型、状态机与并发控制的工程实践
简介SurveyKing问卷系统设计源码是一套基于Java的开源问卷平台面向需要搭建在线调研、信息收集与自定义问卷的开发者和企业可解决快速设计复杂问卷并保障数据安全的问题。源码按前后端模块化组织后端以336个Java源文件实现问卷逻辑、题型配置与数据管理前端用95个JavaScript文件与57个CSS文件承载交互与样式并配有大量图片、图标、字体以及SQL、Gradle、Dockerfile等构建部署资源。压缩包共802个文件约46.63MB目录涵盖client、server、docs、scripts等模块结构清晰便于按需阅读和二次开发。平台内置丰富的逻辑设置支持单选、多选、填空等多种题型用户可灵活定义选项与答题规则适用于市场调查、教育反馈、活动报名等场景。该源码包提供完整的前后端源码、数据库脚本与部署配置既能学习Java后端分层、前端数据通信、问卷逻辑判断与自定义题型等关键思路也能借助安装指南快速完成本地部署目前已有402人浏览学习适合具备一定Java基础、希望搭建或改造问卷系统的开发人员参考。1. SurveyKing 问卷系统设计源码看起来是 CRUD坑全在流程控制里网上能搜到的 SurveyKing 问卷系统源码形态并不统一抛开项目名差异落到工程上基本都是同一套骨架Java 关系型数据库 问卷、题目、答卷三类核心实体加上一套从草稿到发布再到关闭的状态机。表面看这就是个 CRUD 系统很多同学拿它练手 Spring Boot一天能把增删改查跑通。可真上过线的都清楚问卷系统的翻车点从来不在 CRUD而在三个不起眼的地方题目改版后历史答卷怎么对齐、问卷状态散落成 if 导致的发布失控、回收高峰期写库连接被打满后答卷静默丢失。这篇笔记从模拟项目 X 的实践视角讲清楚这套系统的数据模型、状态流转、导出方式和踩坑记录目标是让新手拿到能跑的后端骨架让熟手看到参数边界与取舍。2. 从需求到表结构问卷系统数据模型怎么设计才不返工2.1 问卷、题目、答卷三张核心表的最小划分问卷系统最忌讳一上来就把所有字段堆在一张表里。常见做法是先拆三张表问卷主表、题目表、答卷表。选项不单独建表通过题目表里的 options_json 字段承载等真的出现选项级统计分析需求再补一张明细表。这个划分在后面写统计和导出时会非常省事。-- 问卷主表 CREATE TABLE survey_questionnaire ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 问卷ID, title VARCHAR(200) NOT NULL COMMENT 问卷标题, description VARCHAR(1000) DEFAULT NULL COMMENT 问卷说明, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态: 0草稿 1已发布 2已关闭, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, total_submissions INT NOT NULL DEFAULT 0 COMMENT 答卷总数冗余字段, start_time DATETIME DEFAULT NULL COMMENT 允许填写开始时间, end_time DATETIME DEFAULT NULL COMMENT 允许填写截止时间, created_by VARCHAR(64) DEFAULT NULL COMMENT 创建人标识, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_time (status, start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT问卷主表; -- 题目表 CREATE TABLE survey_question ( id BIGINT AUTO_INCREMENT PRIMARY KEY, questionnaire_id BIGINT NOT NULL COMMENT 所属问卷ID, question_type TINYINT NOT NULL COMMENT 题型: 1单选 2多选 3文本 4评分 5下拉, title VARCHAR(500) NOT NULL COMMENT 题干, required TINYINT NOT NULL DEFAULT 1 COMMENT 是否必填: 1是 0否, sort_no INT NOT NULL DEFAULT 0 COMMENT 排序号越小越靠前, options_json TEXT COMMENT 选项JSON结构化题型使用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_questionnaire (questionnaire_id, sort_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT题目表; -- 答卷表 CREATE TABLE survey_answer ( id BIGINT AUTO_INCREMENT PRIMARY KEY, questionnaire_id BIGINT NOT NULL COMMENT 所属问卷ID, submitter_no VARCHAR(64) DEFAULT NULL COMMENT 匿名编号, answer_json TEXT NOT NULL COMMENT 答案JSON每道题按题目ID记录, submit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 提交时间, KEY idx_questionnaire_time (questionnaire_id, submit_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷表;字段设计有几个地方是刻意留出来的。status 用 tinyint 而不是字符串是为了索引和传输效率也方便枚举映射version 是给乐观锁用的编辑和发布并发时防止丢更新total_submissions 是统计冗余字段列表页直接展示回收进度不用每次 count 大表。题目表里 sort_no 控制展示顺序question_type 决定前端渲染哪种组件。答卷表把整份答案序列化成 JSON一行对应一份答卷submit_time 用来按时间做回收漏斗。为什么是这三张表而不是把题目直接塞进问卷表的 JSON 里因为问卷详情需要单独编辑题目、排序、复用。题目独立成表后如果某个问卷要出第二版可以给题目加 version 字段也可以整组复制生成新题目记录后面做版本对比有地方放。把题目塞 JSON 里前端编辑时就是全量覆盖版本历史完全丢失后期根本没法排查问题。2.2 题目选项独立表还是 JSON 字段这是问卷系统设计里最容易反复的一个选择。我一般会选 JSON 内嵌理由很直接问卷的题目结构天然易变单选、多选、下拉、评分、矩阵题差异太大。如果用独立选项表要么很多列空着要么为通用性写一堆映射代码查询题目详情时要么 join 要么 N1代码量上去了收益却不明显。维度独立选项表options_json 内嵌查询题目详情需要 join 或 N1一次取出直接解析选项级统计SQL 聚合方便要写代码解析后统计修改历史问卷选项保留快照较麻烦改 JSON 时要注意历史答案对齐题型扩展可能要改表结构后端加结构发版即可适合场景选项固定、需要 SQL 透视题型多变、选项带图片/分值等附属信息还有几个边界条件要讲清楚。一道题选项数量通常不超过 30 个JSON 解析成本可以忽略但如果做成题库系统选项要被人单独引用、要标正确率那独立表就变成刚需。另一个边界是 BI 分析把问卷数据接进数据仓库需要知道每个选项被选了多少次这种场景只靠 answer_json 解析会非常痛苦。我的做法是在提交事务里同步写一张 answer_detail 明细表answer_json 保留原始快照明细表按题目一行一行拆开后面统计直接查明细表。2.3 冗余字段与状态位给发布和回收提前留的伏笔status、version、total_submissions 这三个字段要在建表时就留好不然后期接需求会改到怀疑人生。status 让所有可填写判断都只对状态字段负责而不是用开始结束时间在代码里到处推断时间推断的痛处在于跨天、时区、补录场景下极易出 bug。version 给乐观锁留后路编辑和发布并发时不至于丢更新。total_submissions 这个冗余字段的代价是必须保证写它的路径和插入答卷在同一个事务里否则统计会漂移回收量特别大的场景再考虑把计数放到异步队列里。索引方面问卷主表的 (status, start_time, end_time) 服务后台列表按状态和时间筛选题目表的 (questionnaire_id, sort_no) 保证详情按顺序加载答卷表的 (questionnaire_id, submit_time) 服务按问卷拉时间漏斗。这些二级索引看起来不起眼等到一份问卷回收几万份答卷后全表扫和走索引的差距是秒级和毫秒级的差别。还有一点问卷列表页如果要展示今日回收量单靠 total_submissions 不够我会在统计表里加一个日期维度的计数用定时任务或者 Redis 累加避免列表接口每天扫一次全表。3. 用 Spring Boot MyBatis-Plus 搭建问卷后端骨架3.1 工程初始化与核心依赖配置后端骨架我用 Spring Boot 2.7.x 加 MyBatis-Plus 3.5.x前者管 Web 和事务后者把单表 CRUD 的样板代码省掉。问卷这种系统大部分操作是单表读写用 MyBatis-Plus 非常合适没必要上重 ORM 或者复杂查询引擎。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesstarter-validation 用来做接口入参校验EasyExcel 是导出模块用的避免自己写 POI 的复杂 API。数据库连接池直接用 Spring Boot 默认的 HikariCP不需要额外引入。spring: datasource: url: jdbc:mysql://localhost:3306/survey_king?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000 jackson: date-format: yyyy-MM-dd HH:mm:ss mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case 必须开否则表里的 questionnaire_id 映射不到实体的 questionnaireId。log-impl 开发环境打开线上记得换掉不然每个 SQL 都打日志性能损耗不小。maximum-pool-size 我给 50这个参数要根据单机 QPS 和数据库最大连接数一起调不是说越大越好后面避坑章节细说。3.2 实体定义与 Mapper 层实体类直接对应三张表。问卷实体里 status 用 Integer 而不是枚举是为了 MyBatis-Plus 写入时不用额外写枚举处理器在 Service 层做枚举转换更直白。Data TableName(survey_questionnaire) public class Questionnaire { TableId(type IdType.AUTO) private Long id; private String title; private String description; private Integer status; Version private Integer version; private Integer totalSubmissions; private LocalDateTime startTime; private LocalDateTime endTime; private String createdBy; private LocalDateTime createdAt; private LocalDateTime updatedAt; }Data TableName(survey_question) public class Question { TableId(type IdType.AUTO) private Long id; private Long questionnaireId; private Integer questionType; private String title; private Integer required; private Integer sortNo; private String optionsJson; private LocalDateTime createdAt; }Mapper 层干净得让人怀疑是不是少写了东西。MyBatis-Plus 的 BaseMapper 已经提供了 selectById、updateById、selectList 这些方法问卷系统单表操作完全够用不需要写 XML。public interface QuestionnaireMapper extends BaseMapperQuestionnaire { } public interface QuestionMapper extends BaseMapperQuestion { } public interface AnswerMapper extends BaseMapperAnswer { }3.3 创建问卷接口事务、参数校验与批量插入创建问卷是第一个真正体现设计的地方。前端提交的是完整问卷结构包含标题、说明和题目列表后端要一次性写入主表和题目表任何一道题失败都要整体回滚否则会出现问卷标题建好了、题目少了一半的脏数据。Data public class QuestionnaireSaveRequest { NotBlank(message 问卷标题不能为空) private String title; private String description; Valid private ListQuestionSaveRequest questions; } Data public class QuestionSaveRequest { NotBlank(message 题干不能为空) private String title; NotNull(message 题型不能为空) private Integer questionType; private Boolean required; private Integer sortNo; private ListOptionItem options; }Controller 只做参数接收和结果包装不写业务逻辑。RestController RequestMapping(/api/questionnaire) RequiredArgsConstructor public class QuestionnaireController { private final QuestionnaireService questionnaireService; private final ObjectMapper objectMapper; PostMapping public ResultLong create(Valid RequestBody QuestionnaireSaveRequest request) { return Result.ok(questionnaireService.create(request)); } PostMapping(/{id}/publish) public ResultVoid publish(PathVariable Long id) { questionnaireService.changeStatus(id, QuestionnaireStatus.PUBLISHED.getCode()); return Result.ok(); } PostMapping(/{id}/submit) public ResultVoid submit(PathVariable Long id, Valid RequestBody AnswerSubmitRequest request) { questionnaireService.submit(id, request); return Result.ok(); } }Service 层是核心。创建方法里最后一句重点说明题目数量少事务内多条 insert 没问题如果以后要做几百道题的题库再把 List 交给 MyBatis-Plus 的 saveBatch。Service RequiredArgsConstructor public class QuestionnaireService { private final QuestionnaireMapper questionnaireMapper; private final QuestionMapper questionMapper; private final AnswerMapper answerMapper; private final ObjectMapper objectMapper; Transactional(rollbackFor Exception.class) public Long create(QuestionnaireSaveRequest request) { Questionnaire questionnaire new Questionnaire(); questionnaire.setTitle(request.getTitle()); questionnaire.setDescription(request.getDescription()); questionnaire.setStatus(QuestionnaireStatus.DRAFT.getCode()); questionnaireMapper.insert(questionnaire); if (request.getQuestions() ! null) { for (QuestionSaveRequest q : request.getQuestions()) { Question question new Question(); question.setQuestionnaireId(questionnaire.getId()); question.setQuestionType(q.getQuestionType()); question.setTitle(q.getTitle()); question.setRequired(Boolean.TRUE.equals(q.getRequired()) ? 1 : 0); question.setSortNo(q.getSortNo() null ? 0 : q.getSortNo()); if (q.getOptions() ! null !q.getOptions().isEmpty()) { question.setOptionsJson(writeJson(q.getOptions())); } questionMapper.insert(question); } } return questionnaire.getId(); } private String writeJson(Object obj) { try { return objectMapper.writeValueAsString(obj); } catch (JsonProcessingException e) { throw new BizException(JSON序列化失败); } } }Transactional(rollbackFor Exception.class) 这一行很关键默认的 rollback 只认 RuntimeException如果业务里抛的是受检异常不写这一行事务不会回滚。题目类型用 Integer 存校验只做了不为空题目类型和选项的匹配关系要在读取和渲染时再校验这样扩展新题型时不用改创建接口。3.4 题目读取与动态题型组装前端拿到的是结构不是数据库行读取问卷详情时前端需要的是带类型、带选项、带排序的完整结构。这里最常见的错误是把数据库行直接返回前端每次自己解析 options_json逻辑散在前端和后端各处一改题型就漏。public QuestionnaireDetailVO getDetail(Long id) { Questionnaire questionnaire questionnaireMapper.selectById(id); if (questionnaire null) { throw new BizException(问卷不存在); } ListQuestion questions questionMapper.selectList( new LambdaQueryWrapperQuestion() .eq(Question::getQuestionnaireId, id) .orderByAsc(Question::getSortNo)); QuestionnaireDetailVO vo new QuestionnaireDetailVO(); vo.setId(questionnaire.getId()); vo.setTitle(questionnaire.getTitle()); vo.setStatus(questionnaire.getStatus()); vo.setStartTime(questionnaire.getStartTime()); vo.setEndTime(questionnaire.getEndTime()); ListQuestionVO questionVOList questions.stream().map(q - { QuestionVO questionVO new QuestionVO(); questionVO.setId(q.getId()); questionVO.setTitle(q.getTitle()); questionVO.setQuestionType(q.getQuestionType()); questionVO.setSortNo(q.getSortNo()); if (q.getOptionsJson() ! null !q.getOptionsJson().isEmpty()) { try { questionVO.setOptions(objectMapper.readValue(q.getOptionsJson(), new TypeReferenceListOptionItem() {})); } catch (JsonProcessingException e) { throw new BizException(题目选项解析失败); } } return questionVO; }).collect(Collectors.toList()); vo.setQuestions(questionVOList); return vo; }题目 ID 在这里必须原样返回给前端前端提交答案时再把题目 ID 带回。很多答案串位的 bug 就是因为前端图省事用了下标这是第 5 章第一个坑的源头。OptionItem 里可以放 key、value、score 这些字段后续做评分题、量表题不用改表结构加字段发版就行。4. 问卷状态机与答卷回收从 DRAFT 到 CLOSED 的核心流程控制4.1 显式建模三态DRAFT、PUBLISHED、CLOSED问卷生命周期只有三个状态但状态流转的方向必须由一处收口。最怕的是 Controller 里写 if status 1 可以提交Service 里又写 if status ! 0 不能编辑散落的判断迟早漏一个。我把状态机收敛到枚举里每个入口先过一次 canTransferTo。public enum QuestionnaireStatus { DRAFT(0, 草稿), PUBLISHED(1, 已发布), CLOSED(2, 已关闭); private final int code; private final String desc; private static final MapInteger, SetInteger TRANSITIONS new HashMap(); static { TRANSITIONS.put(DRAFT.getCode(), Set.of(PUBLISHED.getCode(), CLOSED.getCode())); TRANSITIONS.put(PUBLISHED.getCode(), Set.of(CLOSED.getCode())); TRANSITIONS.put(CLOSED.getCode(), Set.of()); } public boolean canTransferTo(int targetCode) { SetInteger allowed TRANSITIONS.get(this.code); return allowed ! null allowed.contains(targetCode); } public static QuestionnaireStatus of(int code) { for (QuestionnaireStatus status : values()) { if (status.code code) { return status; } } throw new BizException(未知问卷状态: code); } }Set.of 需要 Java 9 以上如果还是 Java 8要换成 Collections.unmodifiableSet。触发状态变更的统一收口方法如下Transactional(rollbackFor Exception.class) public void changeStatus(Long id, int targetStatus) { Questionnaire questionnaire questionnaireMapper.selectById(id); if (questionnaire null) { throw new BizException(问卷不存在); } QuestionnaireStatus current QuestionnaireStatus.of(questionnaire.getStatus()); if (!current.canTransferTo(targetStatus)) { throw new BizException(不允许从 current.getDesc() 流转到 QuestionnaireStatus.of(targetStatus).getDesc()); } questionnaire.setStatus(targetStatus); int rows questionnaireMapper.updateById(questionnaire); if (rows 0) { throw new BizException(问卷状态已变化请刷新后重试); } }这个方法的优势是以后加撤回发布或者转为模板状态只改枚举的转移表业务代码不用动。审计也好做所有状态变更都过同一个方法打日志只加一处。4.2 发布时的并发控制与幂等处理状态机看着简单一旦并发点发布按钮就出问题。两个人同时编辑问卷一个人点发布另一个人改标题最后提交的会把前面人的改动覆盖或者发布出去的题目是别人改到一半的版本。乐观锁是这里最实用的方案。实体上的 Version 注解会让 updateById 自动带上 where version 旧值更新成功的行数会是 1失败是 0。3.3 节 changeStatus 里那个 rows 0 判断就是靠这个拦截并发冲突。Transactional(rollbackFor Exception.class) public void publish(Long id) { Long now System.currentTimeMillis(); Questionnaire questionnaire questionnaireMapper.selectById(id); if (questionnaire null) { throw new BizException(问卷不存在); } if (!QuestionnaireStatus.of(questionnaire.getStatus()).canTransferTo(QuestionnaireStatus.PUBLISHED.getCode())) { throw new BizException(问卷不在草稿状态无法发布); } questionnaire.setStatus(QuestionnaireStatus.PUBLISHED.getCode()); questionnaire.setStartTime(LocalDateTime.now()); int rows questionnaireMapper.updateById(questionnaire); if (rows 0) { throw new BizException(问卷已被他人修改请刷新后重试); } }幂等也在这里处理。连续点两次发布第一次成功第二次进入方法时状态已经是 PUBLISHEDcanTransferTo 直接返回 false不会把问卷重复published一遍。相比用 idempotent 请求头那种方案状态机层面的幂等更省事也不需要额外维护 token。注意 start_time 在发布时写入不要在创建时就填否则会出现还没发布但接口已经开始收答卷的时间窗口。4.3 答卷提交的事务边界与匿名化策略答卷提交是系统中并发压力最大的动作。前端把整份答案传过来后端要做三件事校验问卷状态和时间、解析并落答案、累加统计字段。这三件事必须在同一个事务里否则会出现答案写进去了、计数没加或者统计加了但答案丢了的尴尬局面。Transactional(rollbackFor Exception.class) public void submit(Long questionnaireId, AnswerSubmitRequest request) { Questionnaire questionnaire questionnaireMapper.selectById(questionnaireId); if (questionnaire null || questionnaire.getStatus() ! QuestionnaireStatus.PUBLISHED.getCode()) { throw new BizException(问卷不在可填写状态); } LocalDateTime now LocalDateTime.now(); if (questionnaire.getStartTime() ! null now.isBefore(questionnaire.getStartTime())) { throw new BizException(问卷尚未开始); } if (questionnaire.getEndTime() ! null now.isAfter(questionnaire.getEndTime())) { throw new BizException(问卷已截止); } Answer answer new Answer(); answer.setQuestionnaireId(questionnaireId); answer.setSubmitterNo(java.util.UUID.randomUUID().toString().replace(-, )); answer.setAnswerJson(writeJson(request.getAnswers())); answerMapper.insert(answer); int rows questionnaireMapper.update(null, new LambdaUpdateWrapperQuestionnaire() .eq(Questionnaire::getId, questionnaireId) .setSql(total_submissions total_submissions 1)); if (rows 0) { throw new BizException(提交失败问卷状态异常); } }这里有两个设计点值得说。第一答案题目 ID 要对得上题目表这是第 5 章的讨论重点提交接口本身不校验答案里每个题目 ID 是否存在是为了减少一次全量题目的查询开销靠统计和校验脚本兜底。第二submitter_no 用 UUID 匿名不落用户名和手机号既满足最小化收集原则又留了一个可回访的匿名标识后续做二次回访不用额外建表。并发太大的时候这个事务里的 select 是第一个瓶颈问卷是热点行所有提交都在抢同一行的乐观锁如果扛不住需要引入 Redis 预扣计数或者改异步最终一致那已经超出这套骨架的范畴了。5. 避坑指南SurveyKing 问卷系统开发中常见的 6 个坑与排查方法5.1 题目删改后答卷答案串位现象问卷发布后管理员删了中间一道题导出 Excel 时发现从删除位置往后所有题目的答案整体错位对不上题干。原因前端保存答案时用的是题目在数组里的下标不是题目 ID。删掉一道题后后面的题目索引前移老答案按旧下标写入导出时按新下标解析全部串位。解决答案统一改成按题目 ID 记录结构类似 [{questionId: 101, value: A}, {questionId: 105, value: 很满意}]。导出时先查该问卷当前题目列表按 questionId 建立映射答案里找不到 questionId 的要么丢弃要么单独标记为已删除题目答案不要让没匹配上的数据干扰统计。5.2 大并发提交答卷时数据库连接池被打满现象问卷推送给几千人的当天日志里出现 Connection is not available, request timed out应用频繁超时数据库 CPU 却不高。原因每个提交事务都要 select 问卷、insert 答案、update 统计一个请求占用一个连接。默认连接池只有 10 个连接push 类流量一来全部堵在获取连接的环节。数据库 CPU 不高说明慢不在 SQL而在池子太小。解决先按 QPS 估算连接池。单个问卷提交接口 QPS 200平均每个请求耗库 50ms理论上 10 个连接够用但实际事务里有串行等待50 是起步值。另外把提交接口的 select 换掉改成一次性查询并用缓存承担问卷状态读取减少无谓占用。5.3 导出 Excel 内存溢出现象回收几万份答卷后点导出应用老年代不断增长频繁 Full GC最后 OOM。原因用 POI 把全部答卷先查询成 List再逐行写入 Sheet。几万份答卷加答案 JSON 全量加载进堆内存直接打爆。解决用 EasyExcel 流式写。查询时按 questionnaire_id submit_time 滚动分页每批 500 条解析答案 JSON 写一行不保留全量对象。导出接口还要加行数上限和超时提示超了就走异步任务生成文件前端轮询下载。ExcelWriter writer EasyExcel.write(response.getOutputStream(), AnswerExportRow.class).build(); WriteSheet sheet EasyExcel.writerSheet(答卷).build(); // 滚动分页按 id 递增避免深分页偏移 long lastId 0L; while (true) { ListAnswer page answerMapper.selectList( new LambdaQueryWrapperAnswer() .eq(Answer::getQuestionnaireId, questionnaireId) .gt(Answer::getId, lastId) .orderByAsc(Answer::getId) .last(limit 500)); if (page.isEmpty()) { break; } for (Answer answer : page) { writer.write(Collections.singletonList(buildRow(answer)), sheet); } lastId page.get(page.size() - 1).getId(); } writer.finish();滚动分页用 id lastId 而不是 limit offset是避免深分页后 offset 越拉越大导致慢查询。这里有个容易被忽略的细节答案 JSON 解析失败不能影响整份导出捕获后写成答案解析失败占位导出结束前统计失败行数。5.4 已关闭问卷还能提交成功现象运营把问卷关闭后有人拿着旧链接直接 POST 提交系统返回成功统计里多了脏数据。原因提交接口只判断了问卷存在没判断状态或者把状态判断写在了调用方前端拦住就以为安全了后端接口裸奔。解决一切校验落后端 Service提交接口的 Controller 一律不写 if。状态枚举在提交方法开头硬校验然后时间边界再查一次。这类问题很容易在状态机里漏掉关闭是一种状态变更必须走 changeStatus 入口如果后续有人添加了其他状态也要同步改枚举转移表。5.5 编辑与发布并发导致线上题目混版现象管理员一边编辑满意度调研的第 5 题一边点发布线上用户看到第 5 题是新的第 6 题还是旧的题目版本错乱。原因题目更新和发布操作都只按 questionnaire_id 更新没有版本约束。发布时读到的可能是另一个事务提交前的旧数据或者两个事务都读旧数据然后各自提交互相覆盖。解决问卷主表加上 version 乐观锁发布时 updateById 返回 0 说明版本冲突直接提示请刷新后重试。题目本身不用单独加版本问卷版本号变了整卷题目被认为是新版本。5.6 自定义题型反序列化后 options 全是 null现象新增一个矩阵题题型保存时选项正常读取详情时 options 为 null前端渲染不出选项。原因多半不是数据库没存而是序列化配置出了问题。Spring Boot 默认开启 FAIL_ON_UNKNOWN_PROPERTIES 期间没保存未知类型更常见的是新题型 DTO 里少了 options 字段或者数据库 options_json 里是旧结构 [{text:A}]新代码按 [{key:A, value:A}] 解析字段对不上。解决排查分三步。第一步查库里 options_json 原始值用 SQL length 确认有数据第二步本地起一个测试接口直接调读详情第三步看反序列化有没有报错被吞掉。给 OptionItem 加未知字段忽略避免老数据解析报错同时给新题型建独立 VO不拿通用结构硬套。6. 进阶技巧用一套集成测试和一段对账 SQL 验证问卷全流程6.1 集成测试串起创建、发布、提交、关闭、导出我习惯在测试里用 H2 数据库模拟 MySQL启动项目后直接通过 MockMvc 走 HTTP 接口把真实链路跑一遍。每次改动状态机或者题目结构先跑测试再打包能挡掉大部分回归。SpringBootTest AutoConfigureMockMvc public class SurveyKingFlowTest { Autowired private MockMvc mockMvc; Test void fullFlowShouldWork() throws Exception { // 1. 创建草稿问卷 String createBody {title:满意度调研,questions:[ {title:整体评分,questionType:4,required:true,sortNo:1} ]} ; mockMvc.perform(post(/api/questionnaire) .contentType(MediaType.APPLICATION_JSON) .content(createBody)) .andExpect(status().isOk()); // 2. 发布 mockMvc.perform(post(/api/questionnaire/{id}/publish, 1L)) .andExpect(status().isOk()); // 3. 提交答卷 String answerBody {answers:[{questionId:1,value:9分}]} ; mockMvc.perform(post(/api/questionnaire/{id}/submit, 1L) .contentType(MediaType.APPLICATION_JSON) .content(answerBody)) .andExpect(status().isOk()); // 4. 关闭 mockMvc.perform(post(/api/questionnaire/{id}/close, 1L)) .andExpect(status().isOk()); } }这段测试的价值在于每个动作走真实 HTTP等价于把前端点了一遍。id 固定用 1 在 H2 干净库里是稳定的测试类记得加事务回滚或者清理脚本避免跑完污染上下文。6.2 一段对账 SQL找出统计漂移和脏答案最后分享一个我常用的对账 SQL。它可以放在任何环境定期跑用来找出 total_submissions 与真实答卷数不一致的问卷SELECT q.id, q.title, q.total_submissions, COUNT(a.id) AS actual_count FROM survey_questionnaire q LEFT JOIN survey_answer a ON a.questionnaire_id q.id GROUP BY q.id, q.title, q.total_submissions HAVING COUNT(a.id) q.total_submissions;如果查出有差异优先看是不是有手动改库、迁移数据或者并发提交时更新丢失。再补一条答案合法性检查确认 answer_json 里的 questionId 都能在题目表找到防止 5.1 那种串位问题变成历史脏数据。6.3 收尾我自己的习惯是每次改动问卷状态机或者题目结构先把这两段东西跑一遍再打正式包。串位和统计漂移这类问题在测试环境不明显往往上了生产被用户反馈才发现到时候只能靠手工导数据修复后悔药难找。这套测试脚本加上对账 SQL覆盖了问卷系统最脆弱的两个环节值得在新项目里落地。希望帮到你。本文还有配套的精品资源点击获取