前一阵朋友甩过来一个需求说要做一个基于Spring Boot的网上问卷调查系统。我一开始也以为这活儿不难毕竟市面上问卷产品一大堆照葫芦画瓢就能糊弄过去。结果真正动手才发现问卷系统真正的难点根本不在CRUD而在于题目类型多变、答题数据要做统计、发布后的问卷结构不能随便改。这篇就把我实际做这类系统时踩过的坑、验证过可行的一套方案整理出来给准备做问卷系统、毕设选型或者想了解Spring Boot项目落地细节的朋友一个参考。1. 网上问卷系统到底要做什么需求边界与核心场景1.1 问卷的生命周期从草稿到发布再到回收一份问卷从无到有要经历四个阶段草稿、已发布、已暂停、已结束。这四个阶段不是简单的状态字段切换它们决定了接口的约束逻辑。草稿阶段题目编辑器要支持自由增删改前端拖拽排序、后端保存草稿结构这个阶段对数据一致性要求最低。发布阶段问卷结构必须冻结答题人看到的是固定版本不能出现题目做到一半选项变了的诡异情况。回收阶段答题数据持续写入要处理高并发提交和防重复提交。结束阶段入口关闭统计报表开始发挥作用运营人员要能按时间、按渠道筛选答卷数据。我见过不少项目把发布后的问卷要不要允许改这个问题拖到后期。产品经理说我只改一个题目的措辞之前的答卷不看了开发这边一听就觉得简单结果真改了之后统计报表里同一道题出现两套答案全乱了。所以从建模第一天起问卷主表就要带版本号发布一次版本号加一答卷只关联当时的版本快照。1.2 三类用户视角管理员、答题人、统计分析者做问卷系统的需求分析我习惯把用户拆成三个视角来讨论因为它们的技术落点完全不同。管理员视角关注的是问卷管理、答卷查看、状态控制。要能复制问卷模板、批量上下线、导出答卷数据。这个视角对后台表格的查询条件、分页性能有要求答卷多了以后不带条件的全表查询会直接卡死。答题人视角关注的是填起来顺手、提交有反馈、断网不丢数据。这里涉及前端交互设计也涉及后端接口的稳定性。问卷系统最常见的接口超时场景往往出在提交接口因为提交时既写主表又更新统计宽表事务一大就出问题。统计分析者视角关注的是按题目分组、按时间筛选、不同维度交叉对比。这个视角决定了数据库要不要建统计宽表缓存怎么设计。如果需求里明确说想看男生和女生在某个题目上的选项分布那从数据模型阶段就要把性别、年龄这类冗余字段写入答卷快照而不是统计时再回查。1.3 最容易遗漏的非功能需求除了功能需求问卷系统有几个非功能需求经常被忽略等上了生产才追悔莫及。第一是匿名与去重之间的平衡。很多问卷业务要求不记录手机号和真实IP但运营又想要一个人只能填一次这就需要用设备指纹方案。冷启动阶段可以用IP加User-Agent加浏览器指纹算一个哈希值只存哈希不存原始IP既能去重也在一定程度上规避隐私合规问题。第二是防刷。公开问卷挂在公网上几小时内就会引来扫描器和机器人。我在实际项目里见过一份问卷还没对外宣传答题量已经破千的全是爬虫脚本刷的。后来加了滑动窗口限流和图形验证码兜底才算把数据洗干净。第三是Excel导出。导出答卷列表看着简单问卷有三十个题目、几千份答卷时如果不做异步导出和流式写入接口超时和内存溢出是必然的。这些非功能需求直接影响技术选型别等产品提了再做。2. Spring Boot选型理由与工程骨架搭建2.1 为什么是Spring Boot而不是其他方案问卷系统这类Web应用如果团队不大、工期又紧Spring Boot几乎是首选。理由是它把Web应用里最麻烦的几件事全部打包解决了内置Tomcat一个jar包直接跑自动配置让数据源、Redis、模板引擎的接入成本压到最低starter生态覆盖了MyBatis、邮件、定时任务等常见场景。招聘市场上会Spring Boot的候选人也最多项目后期接手成本很低。有人会问用Node的Express写接口不是更轻快吗Express写单个接口确实快但一旦涉及统一事务管理、定时统计、后台权限这类完整业务就需要自己拼很多中间件做出来的系统维护成本反而更高。Django自带Admin不错但国内团队对它的部署和进程管理普遍不如Java体系熟练。Spring Boot的约定优于配置加上分层模型恰好匹配问卷系统这种业务规则多、状态变化多的场景。2.2 工程结构与依赖清单我给这类系统定的工程结构大致如下不复杂但边界清晰实际中小项目可以直接照搬survey-system/ ├── survey-admin # 管理端接口与页面资源 ├── survey-api # 答题端接口 ├── survey-common # 通用工具、异常定义、常量 ├── survey-core # 核心业务逻辑问卷、答卷、统计 └── survey-infra # 基础设施数据库访问、缓存、消息之所以强调把管理端和答题端分开是因为两边的安全级别完全不同。管理端需要登录鉴权和操作审计答题端更关注防重防刷和高可用混在一起写Controller很容易把边界搞乱。起步依赖方面我实际用的是这些spring-boot-starter-web提供接口和内置容器spring-boot-starter-validation参数校验配合Bean Validation注解spring-boot-starter-data-redis防重复提交和统计缓存mybatis-plus-boot-starter数据访问自带分页插件mysql-connector-jMySQL驱动spring-boot-starter-aop限流和审计日志用AOP实现2.3 项目初始化时的版本取舍版本选择这里直接说结论Spring Boot 2.7.x是目前中小项目最稳的选择。3.x的JDK基线已经升到17很多服务器上还是JDK 8硬上3.x会非常痛苦。项目一开始就要把Spring Boot版本、JDK版本、依赖版本全部定下来后面换版本的成本远比你想象的高。还有一个容易被忽视的小事是banner。Spring Boot启动时控制台会打印一个大Logo很多人觉得无所谓就删了。但在团队协作和运维排查时banner里打印出应用名、项目编号、版本号能帮人快速确认当前跑的是哪套代码。网上有Spring Boot的banner生成器把项目编号比如project25765做成字符画放进去启动日志一眼就能看出环境这个习惯我从很早养到现在。3. 数据结构设计问卷、题目与答卷的分层建模3.1 问卷主表状态机用整数表达问卷主表的设计核心是状态流转草稿0、已发布1、已暂停2、已结束3。我强烈建议用整数而不是字符串存储状态判断高效是一方面更重要的是扩展状态机时不会破坏既有语义。比如后来要加一个审核中状态整数直接加4就行字符串枚举要改动的地方就多得多。主表的字段看起来不多但每个都有讲究。标题和描述不用多说开始时间、结束时间要区分问卷内容的发布时间和答题开放时间两个概念很多人合并成一个字段结果运营想提前准备问卷却不想让人填就做不到了。每日限次和总限次是防刷的第一道闸门默认0表示不限。创建人和创建时间属于审计字段任何时候都不要省。最关键的是version字段。问卷每发布一次version加一。答题端只认当前发布版本这样后续回溯数据时能准确知道某一份答卷对应的是问卷的哪个版本。CREATE TABLE survey ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, description TEXT, status TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, start_time DATETIME, end_time DATETIME, daily_limit INT DEFAULT 0, total_limit INT DEFAULT 0, creator_id BIGINT, created_at DATETIME, updated_at DATETIME );3.2 题目和选项组合模式的三层建模题目和选项我采用经典的问卷-题目-选项三层结构。题目的type字段用字符串表示题型单选、多选、填空、评分、矩阵题。每种题型对应不同的校验逻辑和统计口径。这里我踩过一个实打实的坑最开始图省事把选项设计成逗号分隔的字符串塞在一个字段里单查方便但选项内容一旦出现逗号或者分号就全乱套。最后老老实实拆成独立的选项表每个选项带一个sort_order字段保证前端展示顺序和数据顺序一致。还有一个取舍问题现在不少文章推荐用MySQL的JSON类型直接把整个问卷结构存一个字段编辑和展示确实爽但统计的时候就极度痛苦因为没法用SQL按题目聚合。我的意见是JSON字段只能用于草稿编辑器的临时保存正式发布的问卷结构必须落入关系表统计SQL才能高效地跑起来。3.3 答卷表快照优于多表关联答卷数据存法有两种流派。一种是把每个回答都建模成答卷-题目-答案三张关联表统计得很细但查答卷列表时联表次数太多数据量大就扛不住。另一种是答卷主表加答案快照列整份答卷的完整内容以JSON快照存一列同时把统计需要的关键答案冗余成字段。我实际采用的是折中方案主流程用快照保证查询性能统计走独立的统计宽表通过异步任务在答卷提交后生成统计行。这样答题人点击提交按钮后毫秒级返回统计报表慢几百毫秒也没关系反正不是实时操作。快照字段的序列化结构里我建议存两层题目ID加题目标题快照一份答题内容一份。这样即使之后题目被删了或者改标题历史答卷依然能还原出当时的题目长什么样。3.4 统计友好的冗余字段做问卷调查系统统计口径决定了表结构设计的细节。按时间维度看数据答卷表里的created_at必须建索引。按渠道维度看数据答卷表里要冗余一个channel字段标记PC端、手机H5还是小程序端。按人群维度看数据那性别、年龄、城市这类字段就得在答卷落库时从用户信息或问卷参数里带过来。很多人在这一步会犹豫觉得冗余字段破坏范式。但问卷系统是典型的读多写少业务读请求的价值和性能远大于那一点存储成本。在宽表里做聚合查询比在JSON快照里用函数解析字段快几个数量级。4. 核心流程实现问卷CRUD、答题提交与统计展示4.1 创建问卷的接口设计创建问卷接口里最需要注意的是前端提交的并不是一个扁平对象而是一棵结构树问卷对象带着题目列表题目对象带着选项列表。如果用数据库实体直接接收层次会非常别扭。正确做法是用嵌套VO接收JSON并且配合Valid和Validated注解做级联校验。校验规则要定得足够细。问卷标题不能为空至少包含一道题目单选题必须有选项且选项不能重复填空题要限制最大答案长度矩阵题的行标签和列标签必须都存在。校验逻辑我建议集中在service层而不是Controller里因为创建和更新要走同一套校验写在Controller会复制代码。草稿保存和正式发布是两个接口。草稿保存只写JSON临时字段不校验完整性正式发布时才走完整校验并且开启事务把问卷结构落回题目表、选项表。4.2 答卷提交事务边界与幂等答卷提交是整个系统的并发热点也是最容易出错的地方。我的设计思路分三步。第一步防重入。在Redis里用问卷ID加设备指纹生成幂等键提交前setnx只有设置成功才允许继续。第二步校验答题内容。题目ID是否属于当前问卷版本、是否漏答必答题、填空题长度是否超限。第三步写入答卷主表并提交事务然后异步刷新统计宽表。幂等键的过期时间是有讲究的。填答一份问卷一般在几分钟内完成我把过期时间定成30分钟。太短会导致答题人填到一半键失效重复提交防不住太长会积累大量无用key给Redis内存造成压力。事务边界我踩过大坑。最初把答卷写库和统计宽表更新放在同一个事务里测试环境没问题上了有并发量的环境后统计宽表数据一多事务时间变长锁竞争激烈提交接口的P95延迟直接从200毫秒飙到3秒。后来把统计更新拆出去答卷主表提交成功后丢一个异步任务给线程池处理接口性能立刻恢复到预期水平。4.3 统计看板SQL聚合与缓存统计看板的数据分两类。一类是实时性要求高的答卷总数、今日新增、平均完成时长直接用Redis计数器累加查询时GET一个key性能极高压测环境下单机轻松扛几万QPS。另一类是分布类统计比如单选题各选项的占比、多选题的选中率这些数据从统计宽表里面GROUP BY出来数据量不大时直查数据库没问题数据量大了再每天定时把结果写入Redis缓存。SQL的聚合口径要特别小心。单选统计比较直观直接按选项ID分组。多选统计要先把每份答卷的选项拆成多行再按行聚合分母应该是参与过这道题的人数而不是总答卷数否则百分比加起来会超过100%。矩阵题更麻烦要做行和列的笛卡尔积没有经验的开发很容易算错我建议先写死一个用例集验证统计结果没问题再上线。5. 安全与可靠性全局异常、XSS过滤与防刷防重5.1 全局异常统一处理的落地问卷系统接口几十个如果不做全局异常处理每个接口都自己catch异常代码会迅速退化成一坨try-catch。我习惯用RestControllerAdvice统一处理把异常映射成固定结构的响应体。响应体的设计遵循三个原则业务异常返回业务码参数校验异常返回400和具体字段错误信息系统异常返回500但绝不暴露堆栈。日志里要记录完整堆栈和请求路径返回给前端的信息保持克制。曾经我把数据库层的SQL异常直接抛给前端页面上直接显示表不存在排错是方便了但信息泄漏风险很大。后来统一改成系统繁忙请稍后重试具体原因留在服务端日志里。全局异常处理还有一个细节要对参数校验异常做特殊处理。Spring的MethodArgumentNotValidException会携带字段错误列表把每个字段的默认错误消息收集起来一并返回前端才能精准提示用户标题不能为空第3题未作答。5.2 全局过滤器处理XSS攻击问卷的题目和答案都是用户输入里面夹杂JavaScript脚本是常有的事尤其是公开问卷扫描器天天在测XSS漏洞。做这个项目时我专门加了一个全局过滤器处理XSS思路是用HttpServletRequest包装类在读取参数时对、、script等关键字做HTML实体转义。网上很多帖子都是直接继承HttpServletRequestWrapper然后重写getParameter但这里有个大坑如果项目里有文件上传或者富文本编辑器需要保留HTML标签全局转义会把正常内容也破坏掉。处理PDF上传这类场景时尤其明显文件名里含一个尖括号转义之后就找不到原文件了。正确做法是做白名单机制业务确认为纯文本的字段才启动转义富文本字段单独校验标签白名单不允许出现script、iframe这类危险标签。5.3 防重复提交与答题限流公开问卷挂在网上最怕脚本刷单。从产品角度可以限制同一设备号只能提交一次从技术角度我用轻量级的方案解决。设备指纹先通过哈希算法生成一个匿名标识然后用Redis对问卷ID加设备指纹做setnx过期时间等于填答窗口。已经填过的人再次点提交直接返回您已参与过该问卷。如果是大流量活动运营还需要在答题接口前加一个滑动窗口限流用AOP实现一个限流注解几行代码就搞定比在每个接口里手写计数逻辑干净得多。限流在应用层还是代理层做取决于流量规模。应用层限流能拿到业务上下文可以做细粒度控制适合问卷系统这种中小流量场景。如果是百万级PV的抢答场景建议在nginx层用Lua脚本限流但问卷业务一般用不上应用层方案足够。5.4 MyBatis分页插件的正确用法管理后台的答卷列表、回收记录、用户管理这些页面必须要分页。直接写LIMIT加手动count不仅烦还容易因为边界条件写错导致数据不准。MyBatis分页插件配置好之后有两个明显收益自动生成count语句、翻页时自动拼接limit。但使用它有个极易踩的坑分页插件生效的前提是startPage调用后必须紧跟第一条查询语句中间如果夹了别的查询分页参数就被消耗到错误的查询上结果就是翻页数据错乱。另一个坑是在for循环里重复分页查询每次循环都执行一次count数据量大时直接把数据库拖垮。这种场景应该考虑一次查出全部ID再用IN批量查询详情循环次数要控制在个位数以内。6. 部署与运维从开发机到服务器的落地细节6.1 配置文件多环境拆分开发、测试、生产三套环境的数据库地址、Redis地址、日志级别都不一样。如果靠注释改配置部署时迟早翻车。Spring Boot原生支持多环境配置文件我把公共配置放application.yml差异部分放application-dev.yml、application-prod.yml启动时用--spring.profiles.activeprod指定环境。有一个配置项必须提醒生产环境的数据库连接池参数不能直接照搬开发环境。我见过一个项目连接池maxActive设成300而数据库本身连接数上限才128服务一启动连接反复获取不到全站接口跟着雪崩。连接池大小要按数据库上限、应用实例数、单实例并发数综合估算不是越大越好。6.2 Docker部署与外部化配置Docker部署Spring Boot项目非常成熟基础镜像选eclipse-temurin对应版本把构建好的jar包考进去暴露端口即可。但有个极其常见的坑容器时区默认是UTC不设置的话Java程序拿到的当前时间和数据库时间差了八个小时所有统计报表都会错一天。Dockerfile里务必加上ENV TZAsia/Shanghai这条环境变量。数据库连接串、Redis密码这些敏感配置不要写死在jar包里的配置文件用环境变量注入这样打包出来的镜像可以复用换环境不用重新构建。这套镜像不可变加配置外置的做法能让发布和回滚流程都简单很多。6.3 从老jar反编译聊起的维护教训接手老项目时如果源码丢了只剩线上运行的jar工作量就大了。网上关于怎么将springboot jar反编译成项目的讨论很多正经做法是用CFR这类工具把class文件还原成Java代码。但反编译出来的代码只能用来理解逻辑直接当源码继续开发是不现实的因为泛型、注解、Lambda表达式都会变形。这件事给我的教训是项目从一开始就做好源码和版本管理。问卷系统看起来业务不复杂但线上数据要回溯时没有版本号支撑会非常被动。构建产物要留带版本号和构建日期的归档tag命名规范要提前定好这些工程上的习惯比多写几个接口重要得多。6.4 日志与慢SQL排查最后聊聊日志和慢SQL。生产环境的日志用logback输出到文件并按天滚动控制台只保留启动信息。日志级别设为INFO业务模块单独开一个logger输出DEBUG遇到线上问题临时调整这个logger的级别再刷新不用重新发版排查效率高很多。数据库层面打开MySQL的慢查询日志阈值设置成1秒每周扫一次慢SQL清单。问卷系统的统计接口最容易出慢查询一个GROUP BY没走到索引可能把接口拖到两秒定位到之后在宽表上补索引立竿见影。我建议一旦发现慢SQL不要只加索引了事要回到SQL本身看能不能改写查询条件或者调整统计策略把大查询挪到离线任务里跑给用户始终保留一条快路径。这类问题越早建立排查习惯线上事故越少。问卷系统虽然比电商、支付简单但公开访问、数据持续增长、统计需求多变这三点决定了它同样需要认真对待工程化。把版本管理、配置外置、日志规范这些基础工作做在前面后续无论是加功能还是扩量都会轻松很多。