news 2026/10/18 23:52:02

问卷系统设计之道:数据模型、状态机与并发控制的工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
问卷系统设计之道:数据模型、状态机与并发控制的工程实践

简介: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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷表';

字段设计有几个地方是刻意留出来的。status 用 tinyint 而不是字符串,是为了索引和传输效率,也方便枚举映射;version 是给乐观锁用的,编辑和发布并发时防止丢更新;total_submissions 是统计冗余字段,列表页直接展示回收进度,不用每次 count 大表。题目表里 sort_no 控制展示顺序,question_type 决定前端渲染哪种组件。答卷表把整份答案序列化成 JSON,一行对应一份答卷,submit_time 用来按时间做回收漏斗。

为什么是这三张表而不是把题目直接塞进问卷表的 JSON 里?因为问卷详情需要单独编辑题目、排序、复用。题目独立成表后,如果某个问卷要出第二版,可以给题目加 version 字段,也可以整组复制生成新题目记录,后面做版本对比有地方放。把题目塞 JSON 里,前端编辑时就是全量覆盖,版本历史完全丢失,后期根本没法排查问题。

2.2 题目选项:独立表还是 JSON 字段

这是问卷系统设计里最容易反复的一个选择。我一般会选 JSON 内嵌,理由很直接:问卷的题目结构天然易变,单选、多选、下拉、评分、矩阵题,差异太大。如果用独立选项表,要么很多列空着,要么为通用性写一堆映射代码,查询题目详情时要么 join 要么 N+1,代码量上去了,收益却不明显。

维度独立选项表options_json 内嵌
查询题目详情需要 join 或 N+1一次取出,直接解析
选项级统计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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.4</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

starter-validation 用来做接口入参校验,EasyExcel 是导出模块用的,避免自己写 POI 的复杂 API。数据库连接池直接用 Spring Boot 默认的 HikariCP,不需要额外引入。

spring: datasource: url: jdbc:mysql://localhost:3306/survey_king?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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: auto

map-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 BaseMapper<Questionnaire> { } public interface QuestionMapper extends BaseMapper<Question> { } public interface AnswerMapper extends BaseMapper<Answer> { }

3.3 创建问卷接口:事务、参数校验与批量插入

创建问卷是第一个真正体现设计的地方。前端提交的是完整问卷结构,包含标题、说明和题目列表,后端要一次性写入主表和题目表,任何一道题失败都要整体回滚,否则会出现问卷标题建好了、题目少了一半的脏数据。

@Data public class QuestionnaireSaveRequest { @NotBlank(message = "问卷标题不能为空") private String title; private String description; @Valid private List<QuestionSaveRequest> questions; } @Data public class QuestionSaveRequest { @NotBlank(message = "题干不能为空") private String title; @NotNull(message = "题型不能为空") private Integer questionType; private Boolean required; private Integer sortNo; private List<OptionItem> options; }

Controller 只做参数接收和结果包装,不写业务逻辑。

@RestController @RequestMapping("/api/questionnaire") @RequiredArgsConstructor public class QuestionnaireController { private final QuestionnaireService questionnaireService; private final ObjectMapper objectMapper; @PostMapping public Result<Long> create(@Valid @RequestBody QuestionnaireSaveRequest request) { return Result.ok(questionnaireService.create(request)); } @PostMapping("/{id}/publish") public Result<Void> publish(@PathVariable Long id) { questionnaireService.changeStatus(id, QuestionnaireStatus.PUBLISHED.getCode()); return Result.ok(); } @PostMapping("/{id}/submit") public Result<Void> 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("问卷不存在"); } List<Question> questions = questionMapper.selectList( new LambdaQueryWrapper<Question>() .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()); List<QuestionVO> 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 TypeReference<List<OptionItem>>() {})); } 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 Map<Integer, Set<Integer>> 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) { Set<Integer> 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("问卷已被他人修改,请刷新后重试"); } }

幂等也在这里处理。连续点两次发布,第一次成功,第二次进入方法时状态已经是 PUBLISHED,canTransferTo 直接返回 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 LambdaUpdateWrapper<Questionnaire>() .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) { List<Answer> page = answerMapper.selectList( new LambdaQueryWrapper<Answer>() .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,覆盖了问卷系统最脆弱的两个环节,值得在新项目里落地。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 23:51:18

Claude Code实战:从安装配置到终端里的AI逻辑引擎

很多时候我们聊到 AI 编码助手&#xff0c;第一反应都是 IDE 里那个能补全代码、帮你写注释的侧边栏插件。但真正把 AI 从“辅助工具”推上“协作者”位置的关键变化&#xff0c;我认为不是图形界面&#xff0c;而是终端。别小看那个黑底白字的窗口。当 Claude Code 这种 CLI 工…

作者头像 李华
网站建设 2026/10/18 23:50:51

连续光谱1D-CNN源码包解析:从数据管线到注意力机制实战

简介&#xff1a;面向连续光谱分析的数值型卷积神经网络&#xff08;CNN&#xff09;Python源码包&#xff0c;专为光谱数据处理、特征提取与模式识别任务设计&#xff0c;适合具备一定深度学习基础、希望快速搭建可复现光谱分析模型的研究者或开发者。压缩包共19个文件&#x…

作者头像 李华
网站建设 2026/10/18 23:49:11

IsoBuster 扇区级数据恢复实战:从坏盘镜像到文件提取

简介&#xff1a;IsoBuster破解版是一款面向光盘镜像处理与数据恢复场景的实用工具资源&#xff0c;适合需要提取、转换、修复ISO、BIN、NRG等镜像文件的用户&#xff0c;以及从事系统维护、数据抢救和虚拟光驱操作的技术人员。压缩包为zip格式&#xff0c;整体约2.83MB&#x…

作者头像 李华
网站建设 2026/10/18 23:42:51

网易云QQ音乐歌词批量下载:163MusicLyrics 从单曲到歌单一次搞定

网易云QQ音乐歌词批量下载&#xff1a;163MusicLyrics 从单曲到歌单一次搞定 【免费下载链接】163MusicLyrics 云音乐歌词获取处理工具【网易云、QQ音乐】 项目地址: https://gitcode.com/GitHub_Trending/16/163MusicLyrics 先说结论&#xff1a;如果你受够了一首一首复…

作者头像 李华
网站建设 2026/10/18 23:41:42

Claude Code高频指令实战指南:100条常用命令详解

“每天用 Claude Code 的人&#xff0c;常用指令都在这 100 条里”——这个标题听起来有点夸张&#xff0c;但说真的&#xff0c;我从第一次在终端里敲claude到现在&#xff0c;已经把它当成了每天离不开的搭档。一开始我也只会上来就问“帮我写个函数”&#xff0c;后来踩了不…

作者头像 李华