
写这段代码的时候我特意用一个最朴素的后台管理页面做载体用户信息管理。数据库一张表页面三张接口四种。线上跑通之后我才觉得这套经典组合确实值得展开聊聊。SpringBoot负责把所有配置吞掉Thymeleaf帮你把动态数据塞回HTML里两者一拼一个能用的增删改查系统就这样落地了。如果你正在学SpringBoot或者需要给内部工具快速套一个管理后台这篇内容可以直接照着做。后续会有一堆细节包括JPA的懒人用法、Thymeleaf的变量表达式、分页参数怎么传、表单回显怎么处理以及我踩过的几个坑。我会把每一步怎么写的、为什么这么写都讲清楚保证不光是让你能跑起来还要让你知道跑起来之后出了问题怎么排查。1. 项目概述与核心思路1.1 为什么选SpringBootThymeleaf这个组合先说说这个组合解决什么问题。早期Java做Web页面主流的玩法是JSPJSP有它自己的历史地位但用起来真的挺折磨人要打成war包要装外置Tomcat改了页面还得重启容器本地调试一次半分钟就没了。后来SpringBoot把内嵌容器和自动配置带起来大家才慢慢发现原来做传统Web页面也可以这么轻量。Thymeleaf在这个体系里是天然的选择。它是服务端渲染模板引擎语法上直接写在HTML标签里你拿浏览器直接打开模板文件能看到完整的静态页面结构只是在有数据的时候才把动态部分替换出来这对于前端工程师和美工来说友好得多。反观JSP浏览器直接打开就是一堆标签代码调试体验天差地别。那么问题来了现在前端这么流行Vue、React为什么还要用服务端渲染我个人的看法是看场景。如果项目是纯内部系统不需要SEO用户就是几十个人页面逻辑也不复杂你用Vue后端API这套需要维护两套工程、处理跨域、还要配置打包流程边际成本不小。而SpringBootThymeleaf只需要一个工程Java代码和页面放在一起部署就是一个jar包简单直接。更关键的是增删改查这种基础操作服务端渲染完全够用。你不需要花里胡哨的交互Thymeleaf的th:each循环配上表单提交就能覆盖绝大多数管理后台需求。我见过不少内部系统连JavaScript都没怎么用照样跑得好好的。1.2 这套组合适合什么人、什么场景说到底这套技术栈适合三类人群。第一类是Java后端开发初学者。刚从SSHSpringStrutsHibernate或者纯Servlet时代过来的人会被SpringBoot的自动配置惯坏——不用手动写那么多XML了IDE里点一下就能跑起来这种爽感对建立信心很重要。第二类是做内部工具的人。比如某公司需要一个简单的工单登记系统某实验室需要一个器材借用管理页面这种需求用SpringBootThymeleaf最快几十分钟就能出来一个可用版本。数据量不大、并发不高、界面要求不高但求稳定这套组合非常省心。第三类是需要在已有SpringBoot项目中快速补页面的人。很多后端程序员本来就会写简单的HTML让他们去学一套完整的前端工程化方案有些勉为其难Thymeleaf的学习成本极低半小时上手直接照着官方文档的语法写就行。它不适合什么场景呢坦白说如果页面交互极其复杂需要大量实时更新、复杂状态管理或者需要面向C端做高并发高体验的产品那还是老老实实做前后端分离。技术没有绝对的好用不好用只有适配不适配。2. 环境准备与工程搭建2.1 开发环境与初始化依赖做这个Demo我的环境是JDK 8、Maven 3.6.3、SpringBoot 2.7.10数据库用MySQL 8.0。其实SpringBoot 2.x和3.x都行但3.x需要JDK 17如果你机器上还是Java 8建议用2.x系列兼容性稳妥一点。下面以2.x为例。工程可以用Spring Initializr在线生成也可以直接在IDE里新建。不管哪种方式最终pom.xml里都会有几个关键依赖我直接贴出来parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.10/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-devtools/artifactId scoperuntime/scope optionaltrue/optional /dependency /dependencies你可能会问为什么不用MyBatis而用JPA我只解释一次。这个场景是单表CRUDJpaRepository帮你把最常见的增删改查接口全部预置好了你都不用写SQL直接从方法名上就能推断出它在做什么。MyBatis的优势是复杂SQL的灵活控制但在这个Demo里反而显得重。如果你公司项目标准是MyBatis照葫芦画瓢把Repository替换成Mapper即可Controller层和Service层逻辑完全不用动。spring-boot-devtools这个依赖建议加上它提供热重启功能。改完Java代码或者页面应用自动重启测试效率翻倍。不过有个坑后面我会单独说因为它偶尔会引发ClassCastException排查很久才发现是热部署的类加载器问题。2.2 数据库准备与全局配置在数据库里建一张表。我不喜欢直接用JPA的ddl-auto自动建表虽然方便但生产环境中自动改表结构很危险。我建议手动建好表应用里就把ddl-auto设成none。CREATE DATABASE crud_demo DEFAULT CHARACTER SET utf8mb4; USE crud_demo; CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 姓名, age INT DEFAULT 0 COMMENT 年龄, email VARCHAR(128) DEFAULT NULL COMMENT 邮箱, created_at DATETIME DEFAULT NULL COMMENT 创建时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户信息表;接下来写application.properties关键配置如下# 数据源 spring.datasource.urljdbc:mysql://localhost:3306/crud_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password你的数据库密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver # JPA spring.jpa.hibernate.ddl-autonone spring.jpa.show-sqltrue spring.jpa.properties.hibernate.format_sqltrue # Thymeleaf spring.thymeleaf.cachefalse spring.thymeleaf.prefixclasspath:/templates/ spring.thymeleaf.suffix.html说几个配置项的实际作用。serverTimezoneAsia/Shanghai必须带上否则新版MySQL驱动和本机时区不一致启动时可能报错。useUnicodetruecharacterEncodingutf8这串是中文数据不出乱码的基础保障。show-sqltrue在开发期一定要开你能看到Hibernate到底执行了什么SQL排查问题时特别有用。Thymeleaf默认的模板位置就是classpath:/templates/后缀是.html你不写这两个配置它也能找到但我写出来是为了让你清楚它的约定在哪。spring.thymeleaf.cachefalse关闭模板缓存开发期改完页面刷新就能看到效果不用重启这个必开。2.3 工程目录结构规划我习惯的包结构如下清晰且符合主流规范src/main/java/com/example/crud ├── CrudApplication.java ├── controller │ └── UserController.java ├── service │ └── UserService.java ├── repository │ └── UserRepository.java ├── entity │ └── User.java └── common └── PageResult.java src/main/resources ├── static │ └── css │ └── common.css └── templates ├── list.html ├── add.html └── edit.htmlcommon包里塞一个PageResult用来封装分页数据后面讲分页时会用到。实际项目中你还会需要统一的返回类、异常处理类但这个Demo从简。目录规划这件事不要小看。很多新手直接把所有类堆在一个包下一开始觉得方便等类多了就灾难了。按分层分包后续加功能不至于迷路。3. 核心设计与数据层实现3.1 数据库表与实体类映射实体类直接对应user表字段名和表字段保持一致JPA自动做驼峰到下划线的映射。这里用Lombok的Data注解来省略getter/setter代码少写一大截package com.example.crud.entity; Data Entity Table(name user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name name, nullable false, length 64) private String name; Column(name age) private Integer age; Column(name email, length 128) private String email; Column(name created_at) private LocalDateTime createdAt; }重点解释几个注解背后的含义。GeneratedValue(strategy GenerationType.IDENTITY)对应MySQL的自增主键。有人喜欢用AUTO它会根据数据库方言自动选择策略如果换到Oracle处理方式就不同了但因为我们明确用MySQL直接指定IDENTITY最干净。这里如果写错策略插入时主键生成可能报错多留意。createdAt这个字段我建议在Service层手动赋值为LocalDateTime.now()而不是完全依赖数据库默认值。原因很简单等你以后要在详情页展示“创建时间”你肯定希望它是Java里一个真实存在的字段而不仅仅是数据库里的隐式值。JPA实体类设计的核心心法是“一列属性对应一个字段”。不要为了省事把所有业务字段塞进一个Map里那会让查询、校验、展示都变难受。3.2 Repository层把CRUD交给框架Repository层的写法很优雅你甚至不需要写实现类Spring Data JPA会在启动时自动生成代理实现package com.example.crud.repository; public interface UserRepository extends JpaRepositoryUser, Long { // 根据姓名模糊查询 ListUser findByNameContaining(String name); // 根据姓名模糊查询 分页 PageUser findByNameContaining(String name, Pageable pageable); }JpaRepositoryUser, Long里已经自带了save、findById、findAll、deleteById等方法这是Spring Data JPA最值钱的地方。自定义的findByNameContaining是接照方法命名规则自动生成SQL的。方法名必须是findBy字段名关键词中间不允许随便插单词。比如你要查邮箱为空的人写findByEmailIsNull要查年龄大于等于某值的写findByAgeGreaterThanEqual。这套命名规则是个庞大的约定体系刚开始记不住没关系IDE的智能提示会帮你把方法名拼出来。这里有一个实际经验能直接通过方法名解决的单表查询尽量用方法名不要再写Query自定义JPQL。项目初期一切正常等字段多了、条件复杂了再引入Query和原生SQL也不迟。过度设计最常见的表现就是简单条件也非要在注解里写一堆SQL。3.3 Service层为什么要多包一层有人会觉得单表CRUD直接Controller调Repository就行了Service层就是多余。我一开始也是这么想的但被真实项目教育过之后我的看法变了第一个原因是事务边界。你要删除一个用户时可能还要同时删除他的登录日志、操作记录这些操作必须在一个事务里完成。如果你直接调用多个Repository方法事务边界是模糊的。把写操作集中在Service层加上Transactional保证要么全成功要么全回滚。第二个原因是复用。Controller将来不止一个比如后台管理一个接口App端也要调同一套逻辑那你就不能把业务逻辑写在某个Controller里得抽出来。第三个原因是为了让Controller保持瘦。Controller的角色应该只是“接收参数、调用Service、返回视图或数据”业务规则不该散落在路由方法里。我这里的UserService很简洁package com.example.crud.service; Service public class UserService { Resource private UserRepository userRepository; public PageUser listUsers(int pageNum, int pageSize, String keyword) { Pageable pageable PageRequest.of(pageNum - 1, pageSize); if (keyword null || keyword.isEmpty()) { return userRepository.findAll(pageable); } return userRepository.findByNameContaining(keyword, pageable); } public User getUserById(Long id) { return userRepository.findById(id).orElse(null); } public void saveUser(User user) { if (user.getCreatedAt() null) { user.setCreatedAt(LocalDateTime.now()); } userRepository.save(user); } public void deleteUser(Long id) { userRepository.deleteById(id); } }PageRequest.of(pageNum - 1, pageSize)这个减一操作很关键。前端页码从1开始Spring Data JPA的页码从0开始不对齐的话你点第2页拿到的还是第一页的数据这是个极其隐蔽的坑。4. 控制层与Thymeleaf页面渲染4.1 Controller路由设计Controller的核心是返回视图名称而不是返JSON所以这里用Controller不是RestController。我把常用路由统一设计为访问路径方法类型功能返回/GET重定向到列表页redirect:/list/listGET带分页和搜索的用户列表list.html/add/pageGET跳转新增页面add.html/addPOST接收新增表单并保存redirect:/list/edit/page/{id}GET跳转编辑页面并回显edit.html/editPOST接收编辑表单并保存redirect:/list/delete/{id}POST按ID删除redirect:/list路由设计有个原则页面跳转用GET数据变更用POST或者DELETE。但HTML表单默认只支持GET和POST所以删除接口这里用POST最省事。有些人为了偷懒用GET请求做删除浏览器地址栏一敲就删数据了搜索引擎爬虫也能触发删除这是安全事故。Controller的主干代码Controller public class UserController { Resource private UserService userService; GetMapping({/, /list}) public String list(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 5) int pageSize, RequestParam(required false) String keyword, Model model) { PageUser page userService.listUsers(pageNum, pageSize, keyword); model.addAttribute(page, page); model.addAttribute(keyword, keyword); return list; } GetMapping(/add/page) public String addPage() { return add; } PostMapping(/add) public String add(RequestParam String name, RequestParam Integer age, RequestParam(required false) String email, RedirectAttributes redirectAttributes) { User user new User(); user.setName(name); user.setAge(age); user.setEmail(email); userService.saveUser(user); redirectAttributes.addFlashAttribute(msg, 新增成功); return redirect:/list; } GetMapping(/edit/page/{id}) public String editPage(PathVariable Long id, Model model) { User user userService.getUserById(id); if (user null) { return redirect:/list; } model.addAttribute(user, user); return edit; } PostMapping(/edit) public String edit(RequestParam Long id, RequestParam String name, RequestParam Integer age, RequestParam(required false) String email, RedirectAttributes redirectAttributes) { User user userService.getUserById(id); if (user null) { return redirect:/list; } user.setName(name); user.setAge(age); user.setEmail(email); userService.saveUser(user); redirectAttributes.addFlashAttribute(msg, 修改成功); return redirect:/list; } PostMapping(/delete/{id}) public String delete(PathVariable Long id, RedirectAttributes redirectAttributes) { userService.deleteUser(id); redirectAttributes.addFlashAttribute(msg, 删除成功); return redirect:/list; } }几个容易忽略的细节。RequestParam在处理表单提交时非常直观。required false表示这个参数可以不传比如邮箱字段为空。如果前端表单漏传了配置为required true的参数Spring会直接抛异常页面变成500所以参数多的时候稍微花点心思设置。Model对象用来向模板传数据它的作用域是本次请求。model.addAttribute(page, page)之后Thymeleaf就能通过${page}访问这个对象。RedirectAttributes.addFlashAttribute的含义要解释一下重定向是两次请求如果你直接把参数放在URL后面传虽然能带过去但URL会变得很长而且某些数据也不适合暴露在地址栏。FlashAttribute会把数据临时存放在Session里重定向后的那一次请求自动取出并消费掉然后立即清除。用来传“新增成功”这种提示信息正合适。4.2 Thymeleaf模板语法的常用套路Thymeleaf的语法不需要全记住你只要掌握这几个高频套路CRUD页面基本可以覆盖八成需求。第一是变量表达式${user.name}。它类似于EL表达式用来取Model里的数据。如果后台传的是PageUser你用${page.content}拿到当前页的用户列表。第二是遍历th:each。tr th:eachuser : ${page.content} td th:text${user.id}1/td td th:text${user.name}张三/td td th:text${user.age}18/td td th:text${user.email}zhangsanexample.com/td /tr这里有个很重要的知识点th:each里的user变量是循环内部的局部变量你可以通过user的索引来拿当前是第几行比如userStat.index 1会得到从1开始的序号。这通常用来做表格序号列。第三是URL表达式{/edit/page/${user.id}}。Thymeleaf会把这一串拼接成上下文相关的路径。注意{}里面可以嵌入${}两者配合很自然。第四是条件判断th:if和th:unless。p th:if${keyword ! null} th:text当前搜索关键词 ${keyword}/p p th:unless${keyword ! null}全部数据/p第五是表单回显th:value。在编辑页面你需要把数据库里的数据填到输入框里这就是涉及回显的关键技巧。input typetext classform-control namename th:value${user ! null ? user.name : }第六是片段引入th:replace和th:fragment。把页面公共的导航栏、分页条抽到单独文件里再在其他页面引入。比如common/fragments.html里定义nav th:fragmentpagination(page) th:if${page.totalPages 1} ... /nav然后列表页里写div th:replacecommon/fragments :: pagination(${page})/divThymeleaf页面最大的特点是直接用浏览器打开这个HTML文件能看到静态占位内容。因为th:text只是把标签里的内容替换掉而静态内容就是占位符本身。这个特性对前端联调很友好不至于打开页面一片空白。5. 完整增删改查实操过程5.1 用户列表与分页查询列表页是整个项目的心脏先写它。一个标准的列表页结构包括搜索框、新增按钮、表格、分页条、提示消息。搜索框我放在列表上方提交方式用GET这样筛选条件会出现在URL里刷新页面也不会丢。!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title用户管理/title link th:href{/css/common.css} relstylesheet /head body div classcontainer h2用户列表/h2 div th:if${msg} classalert th:text${msg}/div form th:action{/list} methodget classsearch-form input typetext namekeyword th:value${keyword} placeholder按姓名搜索 button typesubmit搜索/button a th:href{/add/page} classbtn-primary新增用户/a /form table classtable thead tr th序号/th thID/th th姓名/th th年龄/th th邮箱/th th创建时间/th th操作/th /tr /thead tbody tr th:eachuser, stat : ${page.content} td th:text${stat.count}/td td th:text${user.id}/td td th:text${user.name}/td td th:text${user.age}/td td th:text${user.email}/td td th:text${#temporals.format(user.createdAt, yyyy-MM-dd HH:mm:ss)}/td td a th:href{/edit/page/{id}(id${user.id})} classbtn-edit编辑/a form th:action{/delete/{id}(id${user.id})} methodpost styledisplay:inline; onsubmitreturn confirm(确认删除该用户吗); button typesubmit classbtn-delete删除/button /form /td /tr tr th:if${page.content.isEmpty()} td colspan7 styletext-align:center;暂无数据/td /tr /tbody /table !-- 分页栏 -- div classpagination a th:href{/list(pageNum${page.number}, pageSize${page.size}, keyword${keyword})} th:if${page.hasPrevious()}上一页/a span th:text第 ${page.number 1} / ${page.totalPages} 页/span a th:href{/list(pageNum${page.number 2}, pageSize${page.size}, keyword${keyword})} th:if${page.hasNext()}下一页/a span th:text共 ${page.totalElements} 条记录/span /div /div /body /html有几个点需要展开讲讲。{/edit/page/{id}(id${user.id})}这种写法是Thymeleaf路径参数的推荐用法比起直接拼URL字符串更安全它会自动处理特殊字符转义。删除按钮放在form里而不是简单的a链接这样就能用POST方法发送请求。onsubmit里加一个确认弹窗防止误操作。这是大多数管理后台都该有的基础防误删设计。#temporals.format()是Thymeleaf内置的时间格式化工具不这么写的话LocalDateTime直接渲染出来是带大量小数秒的字符串看着非常难受。分页部分有三个常量需要注意page.number是当前页索引从0开始page.totalPages是总页数page.totalElements是总记录数。页面上展示页码时记得page.number 1。下一页的URL番号应该是page.number 2因为你传进后台的pageNum是以1为第一页的而后端会把pageNum - 1传给PageRequest这个换算关系必须心中有数。5.2 新增用户的完整链路新增页面的表单很简单四个字段对应实体类。form th:action{/add} methodpost classform div classform-group label姓名/label input typetext namename required maxlength64 /div div classform-group label年龄/label input typenumber nameage required min0 max150 /div div classform-group label邮箱/label input typeemail nameemail maxlength128 /div button typesubmit classbtn-primary保存/button a th:href{/list} classbtn-cancel返回列表/a /form浏览器端配合required、min、max就能完成基础的必填校验和范围校验。但请记住前端校验只是用户体验的一部分后端必须做同样的校验因为请求可以绕过浏览器直接发给服务器。我在Controller里目前没有做太严格的校验实际生产建议至少检查name是否为空。一个可行的方案是用JSR-303的Valid注解搭配实体类上的NotBlank等约束然后捕获MethodArgumentNotValidException。SpringBoot已经内置了这些规范引入spring-boot-starter-validation依赖即可。这里演示从简但心里要有这根弦。新增的流程是用户填写表单点击保存请求POST到/addController接住参数new一个User对象塞数据调用userService.saveUser()然后重定向到列表页通过FlashAttribute在页面上弹出“新增成功”。整个链路没有一丝多余的动作。如果想在新增时顺便做个重复校验比如姓名不能重复可以在Service里先调用findByName查一下如果已经存在就抛异常Controller里捕获异常并返回错误消息。但是先声明一下这个Demo为了聚焦CRUD没有做这个校验别在评论区问为什么没有。真实项目里加一个方法也就几行的事。5.3 编辑回显与数据更新编辑页面和新增页面的表单几乎一样唯一区别是输入框多了一个th:value回显。form th:action{/edit} methodpost classform input typehidden nameid th:value${user.id} div classform-group label姓名/label input typetext namename th:value${user.name} required maxlength64 /div div classform-group label年龄/label input typenumber nameage th:value${user.age} required /div div classform-group label邮箱/label input typeemail nameemail th:value${user.email} /div button typesubmit classbtn-primary更新/button a th:href{/list} classbtn-cancel返回列表/a /form这里有个非常关键的隐藏域nameid。如果你忘了在编辑表单里提交idController的RequestParam Long id会因为拿不到参数直接报400错误所有编辑请求全部失败。我见过不止一个新手在这个坑里反复进出。为什么不用实体类直接接收表单参数而要一个个取呢其实可以把Controller的add/edit方法参数改成ModelAttribute User userSpring会自动把请求参数绑定到实体属性上。但如果前端提交了id字段Spring也会帮你绑定这样用户可以把一个不存在的id传进来后端如果没做存在性检查save就会神奇地变出一条新记录。所以我在Service的saveUser方法里先判断了createdAt是否为空如果为空则新建时补充时间但没有对id做存在性判断。严谨的做法是再加一步存在性校验但Demo保持简单就好。编辑流程本身不复杂点击编辑链接跳转到/edit/page/1后台根据id查到用户塞进Model页面以th:value展示用户修改后提交Controller再根据id把对应对象取出更新字段最后save。这里特别说明一下JPA的save行为如果传入对象的id不为空Spring Data JPA会先查询这条记录是否存在存在就走更新操作不存在就插入一条新记录。很多人不知道这一点以为save永远是新增。所以想写“新增或更新”的二合一方法直接save就完了省去判断。5.4 删除操作与安全实践删掉的代码在Controller里就两行PostMapping(/delete/{id}) public String delete(PathVariable Long id) { userService.deleteUser(id); return redirect:/list; }但这东西里面是有讲究的。第一用POST方法而不是GET。刚才说了用GET做删除等于把核按钮放在浏览器地址栏上谁都能按。虽然我这里没加权限控制但真实系统里必须对删除接口做权限校验至少是登录状态校验。第二加确认弹窗。页面里已经写了onsubmitreturn confirm(...)。这是前端最后一道防误删。管理后台里头一两次的手滑误删都是靠这个弹窗救回来的。第三删除后重定向而不是返回页面。重定向可以避免刷新页面时重复提交删除请求也符合Post/Redirect/Get模式。有人会问为什么删除要用路径参数{id}而不是表单隐藏域id两种方式都可以路径参数更直观也更符合REST风格。从Thymeleaf角度看两种写法差别不大我在列表页用的路径参数形式在编辑页用的是隐藏域形式你都可以试试。真正要留意的还有级联删除的问题。如果这个用户有订单、有评论表外键关联直接deleteById会报外键约束异常。生产环境中我建议用逻辑删除加一个deleted字段删除操作只是把它置为1查询时统一过滤掉。这样不仅能防外键断裂还能在出问题时恢复数据。这个Demo用的是物理删除但你要清楚它们之间的权衡。6. 常见问题与排查技巧实录6.1 启动与连接问题这类问题每个人都逃不掉我按我踩过的顺序来写。启动报Access denied for user rootlocalhost。原因就一个字密码不对。但有的时候你明明密码不错就是连不上那就要检查application.properties里的用户名密码前后有没有空格。特别是从文档复制配置时前面会粘上一个不可见字符非常恶心。排查方法随便用个客户端连一下数据库排除数据库本身的问题再回头检查SpringBoot配置。启动报Unable to load authentication plugin caching_sha2_password。这是MySQL 8.0的新默认认证方式和老版本驱动不兼容导致的。解决办法就是升级mysql-connector-java版本SpringBoot 2.7默认引入了8.x驱动一般不会遇到。如果你用的是老SpringBoot版本需要换个新驱动。启动报Failed to configure a DataSource。这通常是工程里配置了spring-boot-starter-data-jpa但没有写数据源连接信息或者写法不对。检查一下配置里是否有spring.datasource.url以及application.properties是否真的被加载到了。还是那句话先看一眼配置文件有没有奇怪的空格。端口被占用。报Port 8080 was already in use说明后面一个进程占了8080。排查命令是在命令行执行netstat -ano | findstr 8080找到PID后去任务管理器杀掉。如果想换个端口直接在application.properties里加server.port8081。热部署导致的诡异问题。我遇到过用devtools热部署之后页面一直报ClassCastException看代码完全找不出问题。原因是用DevTools重启后类加载器变成了两套某些旧类和新类混在一起。解决办法是把部署方式切换成非devtools模式或者干净地重新启动一次。这个坑不太好排查但不算高频遇到了不慌就行。6.2 模板渲染与页面问题页面返回404但Controller方法确实被调用了。SpringBoot找不到模板无非三个原因URL对不上、模板文件位置不对、命名不对。默认情况下模板必须放在src/main/resources/templates/下后缀是.html。如果你配置了spring.thymeleaf.prefix千万别把前缀写成了/WEB-INF/这种老掉牙的路径。还有一种情况是IDE资源文件没有自动拷贝到target目录IDEA里点击Build - Rebuild Project就好。页面显示纯HTML代码而不是渲染后的效果。这个原因很朴素请求没有经过DispatcherServlet而是被当成静态资源直接返回了。常见于Thymeleaf模板放在了src/main/resources/static/目录下把它移到templates/即可。也有可能是Controller返回了ModelAndView但设置了ResponseBody把这东西去掉。页面中文变成乱码。请求和响应编码不一致就会这样。我在模板头部加meta charsetUTF-8后端返回字符串时SpringBoot默认UTF-8编码一般没问题。如果你在手动拼HTML并写成return html.../html这种形式那就要检查Controller里是否加了producestext/html;charsetUTF-8。重定向到列表页后提示消息没显示。原因是FlashAttribute需要使用redirectAttributes.addFlashAttribute()如果你直接model.addAttribute(msg, ...)再return redirect:/list这个msg是带不过去的因为重定向会造成Model数据丢失。还有另一种可能是模板里th:if${msg}的判断条件写错了变量名对不上也会导致整个元素不渲染。页面加载的时候列表数据没显示。先看后端日志里show-sql有没有把SQL打出来。如果SQL都没有说明方法压根没执行到如果SQL有但页面还是空那就是模板字段名写错了。最常见的错误是把user.name写成user.userNameSpring的SpEL表达式是很较真的一个字母错了就是取不到。表单提交后返回400。多半是Controller方法里RequestParam的参数和前端name属性对不上。记住nameage对应RequestParam Integer age类型也要匹配。你传一个abc给Integer age就会报类型转换错误。处理这种问题看一眼浏览器开发者工具里的请求参数列表立竿见影。Thymeleaf表达式报EL1008E错误。这是属性名不存在。新手最常见是把对象里的私有字段名当成了属性名比如写${user.CreatedAt}。JavaBean的属性名是getCreatedAt()去掉get后首字母小写也就是createdAt。大小写敏感没有商量余地。6.3 表单提交与数据问题新增或编辑成功后弹出“新增成功”提示但刷新一次又弹。如果用的是FlashAttribute正常情况只会出现一次因为它是即用即删的。如果你发现刷新还在说明你可能用了Session级别的Attribute。检查有没有把session.setAttribute(msg, ...)和FlashAttribute混用后者会正常消费前者会一直留在会话里。保存的用户年龄为0。表单里没填年龄Controller收到的就是null但实体类字段是Integer返回列表页时可能显示为空。如果数据库默认值比较大比如0那你只能看到一堆0。这类问题的根源是前端校验和后端校验都没有做必填限制加上浏览器端的required属性即可。编辑一个用户保存后却发现新增了一个重复用户。这是困扰过无数人的经典问题。我在前文提过JPA的save在有id的情况下会走更新但前提是id能够正确传给Controller。如果编辑页面漏了隐藏域id或者参数名不匹配后端拿到的id就是null于是save成了新增。排查方法在Controller里打印一下user.getId()一目了然。分页点击下一页搜索条件丢了。你搜索了“张三”后点第2页结果“张三”没了列表变成了全量数据。这是因为分页链接里的keyword参数没有一起传过去。注意看我在分页链接里写的keyword${keyword}只有当模板里拿到并回传这个值时搜索和分页才能共存。如果你把pageSize也做了可配置那也要一并传。删除某条记录后页码越界。比如说当前页只有1条数据删掉之后页码还是2查出来一个空页面。比较糙的处理是在Service或Controller里判断page.number page.totalPages时把页码修正为最后一页。这个细节容易忽略但用户在实际使用时一定会碰到。复杂查询条件越来越多怎么办。单表方法名的写法终归有瓶颈条件一多方法名又臭又长。到那个阶段建议使用Specification它是Spring Data JPA提供的动态查询标准接口比Query更灵活。如果你已经娴熟掌握CRUD可以进阶学习这一块。还有一个替代方案是在Service层做内存过滤但数据量大时效率堪忧。我实际经历里最消耗时间的一次排查是列表页点击搜索之后发现请求参数正常、Controller也执行了、SQL也打印了但页面数据就是老样子。最后才发现是浏览器缓存了静态页面强制刷新之后好了。这类和代码逻辑无关的玄学问题往往比真正的Bug更让人抓狂。碰到页面表现和代码不一致时第一时间按下CtrlF5再看后端日志最后再怀疑代码能省下不少时间。这套链路跑通之后你可以顺着两个方向继续扩展往深了做加Spring Security登录认证、加参数校验、加日志切面往宽了做加更多业务表、做一对多关联查询、接入消息队列。但无论如何增删改查这个基本功是所有后端的起点。把它做扎实后面的路都顺。