1. 项目背景与整体思路
1.1 看似基础的Spring Boot数据库操作,为什么值得单开一篇
前两天有个朋友问我,他说自己在网上跟着视频抄了一个Spring Boot项目,CRUD都能跑通,但一旦让他不看教程自己重写一遍,脑子就一片空白。为什么?因为数据库操作这段从来不是“抄几个注解”那么简单,它牵扯到配置文件、连接池、事务、日志、权限、表结构设计等一长串东西。你漏掉任何一个环节,跑起来就像拆盲盒——有时候运气好能出数据,有时候直接白屏,还有时候接口能通但数据死活写不进去。
Spring Boot的数据库操作,本质上是把“Java应用连上数据库并读写数据”这一整套链路理顺。我在这篇实战记录里不打算只贴一段能跑的代码就收工,我想把那些文档里不会明说的细节一起讲透:Navicat里怎么给账号申请只读权限、application.yml里每个参数到底管什么、为什么你改了数据库连接却还在用旧连接、以及从“单表CRUD”走到“企业级系统”会遇到哪些分水岭。这样你以后不管做“校园讲座预约系统”还是“企业办公用品管理系统”,都能借此把根基打牢。
适合谁来读?两类人。第一类是刚学会Spring Boot、想系统补上数据库操作这块短板的同学;第二类是已经在写业务接口、但经常在数据源配置、事务控制、日志排查上卡壳的初级开发。我尽量用大白话讲清楚原理,再给你可以直接抄走的配置和代码,这样不同基础的人都能有收获。
1.2 这套实战做出来是什么样
为了让这篇内容不飘在空中,我选了一个非常常见的业务场景:地址簿管理。为什么选它?因为它把数据库操作最核心的增删改查全都覆盖了,而且表结构足够简单,不需要额外引入复杂关联关系,正好可以用来讲清楚Spring Boot操作数据库的最小闭环。
整体目标是这样:本地建一张联系人表,用Navicat把数据库账号权限分好,Spring Boot项目通过配置文件连上数据库,然后对外提供几个HTTP接口,分别实现新增联系人、查询联系人列表、修改联系人和删除联系人。跑通这个闭环以后,我们再顺手讨论一下多表设计、缓存、消息推送和权限控制的扩展方向。你会发现,校园讲座预约系统、餐饮SaaS、办公用品管理这类项目,本质上都是在这个骨架上面不断叠功能。
2. 数据库准备与 Navicat 那点事
2.1 建库建表前的几个决策
很多人一上来就打开Navicat新建数据库,这没错,但我想先提醒你三件事。第一,数据库名和表名最好统一用小写加下划线,比如address_book,不要用大写驼峰。这不是纯强迫症,Linux服务器上的MySQL对大小写敏感,你本地用大写没问题,一旦部署到服务器,SQL语句里大小写稍微不一致就直接报错“Table doesn't exist”。第二,表结构里的注释尽量写清楚,因为后面写实体类时候,字段注释能帮你减少大量理解成本。第三,字符集直接选utf8mb4,别再用老旧的utf8。我见过太多项目因为utf8mb4和utf8的区别,最后存emoji或者生僻字时出现乱码,排查半天还得改表结构。
建表语句我给你一个可以直接复制的版本:
CREATE TABLE address_book ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', name VARCHAR(64) NOT NULL COMMENT '联系人姓名', phone VARCHAR(20) NOT NULL COMMENT '手机号', address VARCHAR(255) DEFAULT NULL COMMENT '地址', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='地址簿表';这里有两个设计细节值得展开。第一个是主键用BIGINT自增还是用雪花ID,取决于你的业务规模。单机小项目自增完全够用,但如果是分布式多实例写入同一个表,自增ID很容易撞车,这时就得换成雪花算法生成的全局唯一ID。第二个是时间字段,我习惯让数据库层自动维护create_time和update_time,而不是让Java代码手动设值。原因很简单:防止不同服务器之间时钟不一致,也省得每个接口都重复写一遍时间赋值逻辑。你在代码里只要管业务字段就行了。
2.2 Navicat 操作:怎么创建只读权限账号
数据库一般不建议所有人共用同一个高权限账号。开发环境可以图省事直接用root,但一旦涉及到测试环境、线上环境,或者需要把数据库暴露给同事、外包、数据分析师,就必须做权限隔离。我先把Navicat里创建只读账号的完整步骤理一遍。
打开Navicat,连接到你的数据库服务器,然后依次点击“用户”或“用户权限”菜单。新建一个用户,名字比如叫reader,主机选择localhost或者%,密码单独设置。关键在“服务器权限”或“权限”选项卡里,只勾选SELECT,其他增删改权限一概不勾。如果只允许读某一个库,就在“对象权限”里选择具体的数据库和表,把SELECT勾上,INSERT、UPDATE、DELETE、ALTER、DROP这些全都留空。
我实操的时候还会多做一步:给只读账号单独设置最大连接数,或者干脆限制来源IP。这样就算有人拿这个账号去拖数据,也不会因为连接数占满影响正常的读写服务。顺便说一句,只读权限不是线上才需要考虑的事。哪怕是本地开发,我建议你至少建两个账号,一个admin管结构,一个app账号负责日常读写。这样你在执行项目交接的时候,不至于把改表结构的权限随手丢给所有人。
还有一个容易忽略的操作:如果你改了用户的权限,Navicat有时候不会立刻生效。你需要在“用户权限”界面点刷新权限,或者执行FLUSH PRIVILEGES。我遇到过一个很诡异的现象,权限明明已经去掉了,但通过旧连接还是能删数据,后来发现就是连接池里的旧连接没有释放,MySQL在连接建立时读取了一次权限,之后这个连接就一直持有旧权限直到断开。这个问题放到Spring Boot里同样存在,等下讲数据源配置时你会再次遇到它。
2.3 为什么单独维护只读账号,不只是为了安全
聊到这里,肯定有人觉得“我一人一个库,要什么只读账号”。但实际项目里,数据库账号往往不是一个人说了算。甲方要报表、运营要导出数据、测试要造数据,这些人不一定都懂SQL语法,万一一个手滑执行了DELETE不带WHERE,后果你可能要加班修复。只读账号相当于给他们开了个“只许看、不许碰”的窗口,这不光防外部误操作,也是防内部误操作。
再往前想一步,只读账号还能用来做数据源的读写分离。很多中型项目会把读操作和写操作分流到不同数据库节点,主库负责增删改,从库负责查询。Spring Boot里可以配置多个数据源,读库连接正好用只读账号,写库连接用读写账号。这样你在架构选型上就提前留好了余地,不至于以后流量上来了再大改代码。这个思路你在做基于Spring Boot的校园讲座预约系统这类高并发读场景时特别有用:讲座列表、座位余量查询都是读多写少,很适合把只读实例独立出来扛流量。
3. Spring Boot 端配置,别看轻这几行 yml
3.1 依赖引入与 application.yml 配置
数据库准备好了,接着就是Spring Boot项目里引入依赖。最常用的是Spring Data JPA和MyBatis两类,我这边先用Spring Data JPA做演示,因为它的设计哲学是“让开发者专注于实体和仓库接口,SQL由框架生成”。如果你偏好手写SQL,可以把spring-boot-starter-data-jpa换成mybatis-spring-boot-starter,核心思路完全不变,只是数据库访问层写起来风格不同。
我见过太多新手在依赖上踩坑。有一个非常典型的坑:网上教程写的是spring-boot-starter-parent版本2.7,但你的JDK是21,启动直接报错“Unsupported class file major version”。版本对应关系一定要提前确认:Spring Boot 2.x最高支持到JDK 17,Spring Boot 3.x要求JDK 17以上,而JDK 21搭配Spring Boot 3.2以上体验最好。如果你非要配Spring Boot 3.5,那直接上JDK 21,配合虚拟线程来处理高并发任务会非常舒服。这一点我在后面第7节展开讲。
application.yml里最核心的配置段如下:
spring: datasource: url: jdbc:mysql://localhost:3306/address_book?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: app_user password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 idle-timeout: 600000 jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true每个参数背后都有讲究。url里的serverTimezone必须设置,不然你连数据库时会相差8小时或者直接报时区错误。allowPublicKeyRetrieval=true是针对MySQL 8.0的,不加上去用默认账号密码登录时偶尔会报Public Key Retrieval is not allowed。driver-class-name在Spring Boot 2.x以后其实可以省略,但写上能避免某些IDE环境自动检测出错。owner concern:不加这些,本地Windows能跑,一到Docker或云服务器就翻车。
3.2 连接池参数,往往是性能问题的第一道关口
Spring Boot 2.x之后默认的连接池是HikariCP,这个连接池的特点是快、轻量、稳定。但你千万别觉得默认值永远适合你。我这里专门讲一下maximum-pool-size这个参数:它代表数据库连接池里最多同时存在多少个物理连接。
很多人一碰到并发高,第一反应是把这个值调到100、200,结果数据库连接数被打爆,反而更慢。为什么?因为每条连接背后都是数据库进程里的一个线程,连接过多会造成上下文切换开销陡增,事务锁竞争也更激烈。比较合理的做法是先用公式估算:单条连接能处理的QPS大概在几百到一千,你预估系统峰值QPS是2000,事务平均耗时20毫秒,那连接数大约等于(峰值QPS × 平均事务耗时秒数),也就是2000×0.02=40。然后再打一个折扣,留出Buffer,一般取30到50。如果想更精确,就压测后根据监控慢慢调,不要一步登天。
还有两个容易被忽略的参数。connection-timeout代表从连接池获取连接的超时时间,如果业务高峰期抢不到连接,等太久会让线程越积越多;idle-timeout代表空闲连接存活时间,空闲太久会被回收,这个值不要低于数据库自身的wait_timeout,否则会出现连接没断,但数据库已经把它杀了,下次使用时报错。这类问题排查起来特别隐蔽,因为不是每次都报错,而是跑一段时间后随机报错。日志里如果出现“Connection is not available, request timed out after 30000ms”,无一例外都是连接池配置和数据库参数不匹配闹的。
3.3 多环境配置:不要把密码写在同一个文件里
真实项目里,开发环境、测试环境、生产环境的数据库地址和账号密码完全不同。要是你只在application.yml里写一套配置,发布时手动改,迟早会出事故。Spring Boot原生支持多Profile配置,把公共配置放在application.yml,把不同环境的差异放到application-dev.yml、application-test.yml、application-prod.yml里,启动时用参数指定:
java -jar demo.jar --spring.profiles.active=prod还推荐使用环境变量把敏感信息注入进去,而不是把生产密码直接写进代码仓库。比如配置里写成:
spring: datasource: password: ${DB_PASSWORD}这样部署到服务器时只需要在系统的环境变量里设置DB_PASSWORD即可。密码不进Git、不截图发到群里、不写进博客,这是我在实际项目里踩过坑之后养成的底线习惯。
4. 写一个能跑的地址簿管理接口
4.1 快速创建 Spring Boot 项目并跑通第一个接口
先说怎么快速创建一个Spring Boot项目。如果你不想在IDE里一步步点Next,最简单的办法是直接用Spring Initializr生成,在浏览器打开start.spring.io,填入项目名,选择Java版本、Spring Boot版本,依赖那一栏勾选Spring Web、Spring Data JPA、MySQL Driver、Validation。生成之后下载解压,用IDE导入,然后就能得到最基础的可启动工程。这一步是很多教程最轻视的“第1关”,但恰恰是第一个Spring Boot程序最容易卡住的地方:依赖没下载全、Maven仓库源太慢、Java版本不匹配。
第一次运行Spring Boot程序,看到控制台出现“Started Application in X seconds”才算真正过关。如果你在这一步遇到端口被占用,可以在application.yml里改server.port;如果启动报错“Failed to configure a DataSource”,说明你还没在配置里给数据源填完整,或者依赖里没有对应的数据库驱动。这些报错其实都是组件之间配合出现了问题,一步步拆开看,并不可怕。
第一个接口,不要一上来就写CRUD。先写一个最简单的GET接口,返回字符串或者返回当前时间,目的不是业务价值,而是验证整个Web请求链路通不通。我的代码习惯是先建一个包叫controller,里面放一个HealthController,注明这是健康检查接口,将来部署了探活也能用上。
@RestController @RequestMapping("/api/health") public class HealthController { @GetMapping public Result<String> health() { return Result.success("ok"); } }跑通以后,你会对“请求从哪里进来、controller怎么接住、响应怎么回去”有个直观感受,再来写数据库相关接口就顺了。
4.2 实体类、Repository 和 Service,一张表对接三层代码
数据库表准备好以后,第一件事是写实体类。实体类说白了就是Java里和表结构一一对应的一个POJO。字段名、类型、注解都要对得上,否则框架会在启动时或者运行时给你报一堆映射错误。
@Entity @Table(name = "address_book") public class AddressBook { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 64) private String name; @Column(nullable = false, length = 20) private String phone; private String address; private LocalDateTime createTime; private LocalDateTime updateTime; // getter / setter 省略 }写完实体类后写Repository接口。Spring Data JPA的Repository很有意思,它允许你通过方法名直接推导SQL。比如你要查所有联系人,就在接口里声明findAll(),这是父接口自带的方法;你要按姓名查询,写findByName(String name),框架会自动生成类似where name = ?的查询。它还有一个大招,支持在方法上写JPQL或者原生SQL,适合复杂查询。
Service层的作用是承载业务逻辑。我强烈建议不要在Controller里直接注入Repository,因为Controller的职责应该只是接收参数、调用业务服务、返回响应。如果业务逻辑越堆越多,Controller会变得又臭又长,以后想加一个事务注解或者加一个权限判断,都会牵一发动全身。分层不是为了好看,是为了降低维护成本。你去看那些基于Spring Boot的企业办公用品管理系统,代码结构再烂,基本也都会分出controller、service、repository这几层。
4.3 Controller 里的增删改查与参数校验
地址簿的接口我设计成这样:
- GET /api/address/list:查询全部联系人
- GET /api/address/{id}:按ID查询单条
- POST /api/address:新增联系人
- PUT /api/address/{id}:修改联系人
- DELETE /api/address/{id}:删除联系人
新增接口的代码长这样:
@PostMapping public Result<Long> add(@Valid @RequestBody AddressBookRequest request) { AddressBook book = new AddressBook(); book.setName(request.getName()); book.setPhone(request.getPhone()); book.setAddress(request.getAddress()); addressBookService.save(book); return Result.success(book.getId()); }这里有一个关键点我特意标注了:@Valid。参数校验必须做在Controller入口,而不是等到进数据库才报错。因为数据库层的异常很粗糙,用户看到的是“字段不能为空”的英文提示,体验很差。更专业的做法是定义一个请求DTO,字段上加@NotBlank、@Pattern这样的校验注解,让Spring在进入Service之前就把非法参数拦下来。手机号校验就是典型场景,我用这样一个正则:
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone;为什么手机号校验这么重要?因为地址簿的数据后续可能会被营销系统、通知系统拿去用,一个格式错误的手机号会导致短信发送失败,或者统计报表对不上。你宁可接口调用方多传几次正确参数,也不要把脏数据沉淀到库里。
修改接口和删除接口也都有讲究。修改时先把ID对应的记录查出来,如果查不到就抛业务异常,而不是直接调save方法覆盖数据;删除时同理,先判断存在不存在,删除一个不存在的ID返回成功会误导调用方。我自己习惯把这类判断封装在Service里,并抛出一个自定义异常,由全局异常处理器统一转成JSON返回,这样代码里就不会到处散落if else处理。
5. 常见坑:数据库操作报错的排查实录
5.1 驱动类、时区和SSL,三个启动期炸弹
数据库操作最常见的报错,很多都发生在项目启动阶段。第一个是“Cannot load driver class: com.mysql.jdbc.Driver”,这个基本是因为你依赖里压根没引入MySQL驱动,或者引入了旧的驱动类名。MySQL 8之后驱动类名是com.mysql.cj.jdbc.Driver,不要再用旧的那个mysql.jdbc.Driver。第二个报错是“The server time zone value 'GMT+08:00' is unrecognized”,这是url里没设置serverTimezone导致的。第三个是一些安全连接相关的问题:useSSL=false能解决本地连不上,allowPublicKeyRetrieval=true能解决MySQL 8的认证方式问题。
这三个报错经常连着一起出现,所以新手常常怀疑自己是不是装错了数据库。其实本质都是客户端和MySQL服务器之间的握手参数没协商好。我处理这类问题的实操顺序是:先打印url确认没乱码,再看driver类名对不对,最后检查MySQL服务端版本。这三个排查步骤能覆盖90%的启动期连接问题。
5.2 Bean 注入失败和事务不生效,最容易让人心态崩溃
Spring Boot里写数据库操作,离不开依赖注入。把Repository注入到Service里,或者把Service注入到Controller里,都是家常便饭。但有时候你会遇到“Field xxx in XxxService required a bean of type 'xxx' that could not be found”的报错。第一次看到这个报错,第一反应是到处加注解,其实通常的原因就几个:类没放在Spring能扫描到的包路径下、忘记加@Repository或@Service、或者依赖冲突导致组件没有注册成功。
关于Bean的注入控制,我还要多说一句。Spring Boot支持字段注入、构造器注入和Setter注入,但我在实际维护项目中更推荐构造器注入。为什么?因为构造器注入能把依赖关系在对象创建时一次性确定下来,字段注入写起来方便,但很容易绕开对依赖的显式控制,尤其在写单元测试时需要额外的反射处理。你在网上会看到很多老项目用@Autowired字段注入,能用,但如果要长期维护,建议慢慢迁到构造器注入。
事务不生效这个问题更隐蔽。你给Service方法加了@Transactional,执行出错时数据应该回滚,结果发现库里还是多了脏数据。最常见的元凶是自调用:同一个类里的方法A调用方法B,B上的@Transactional注解不生效,因为Spring事务是通过动态代理实现的,自调用绕过了代理对象,事务切面根本没机会介入。解决办法是把事务方法放到另一个Service类里,或者通过AopContext.currentProxy()获得代理对象主动调用。另一种情况是方法不是public的,@Transactional只能作用于public方法,因为代理机制的限制。还有一种情况是异常被内部catch住了,事务感知不到异常,自然不回滚。所谓“事务”不是一句注解那么轻巧,它背后是代理、异常传播和事务边界三个概念同时配合。
5.3 通过日志定位慢 SQL 和数据问题
Spring Boot里最常见的日志输出方式就是控制台加文件。配置文件中可以这么设置:
logging: level: root: info org.hibernate.SQL: debug org.hibernate.type.descriptor.sql.BasicBinder: trace这样设置之后,你会在日志里看到打印出来的SQL语句和参数值。为什么这个对数据库排查很重要?因为很多业务问题本质是SQL没按预期执行。比如你明明只想查5条数据,框架却把全表数据load进内存再过滤,这时候看日志能立刻发现问题。日志不是给人凭空观察用的,它是定位慢查询、核对字段映射、捕捉异常堆栈的核心工具。
在实际项目里,我还会引入p6spy或者直接开启MySQL的慢查询日志,把所有执行时间超过1秒的SQL单独记录。抓到了慢SQL,再用EXPLAIN看执行计划,检查是否命中索引。这些习惯配合Spring Boot的日志体系,能让你在预警系统报警之前就发现大多数数据库问题。
6. 再往前走一步:从单库单表到企业级功能
6.1 多表设计的典型场景:校园讲座预约系统
地址簿只是一个最小闭环,真实项目里的数据库操作远远不止单表增删改查。我拿热搜里出现的校园讲座预约系统来举例,这个系统至少涉及五张表:讲座表、用户表、预约表、会场表、签到记录表。预约表引用讲座ID和用户ID,最关键的业务规则是“同一讲座同一用户只能预约一次”,这就要在预约表上建联合唯一索引,或者在插入前先查询校验。
更麻烦的是并发预约问题。两个用户同时抢同一个讲座的最后一个名额,如果代码里是先查再插,那两次查询都发现还有名额,然后都插入成功,座位超卖。这里一定要用数据库层面的约束,或者事务配合锁来解决。最稳妥的方式是给预约表加联合唯一索引,或者用SELECT FOR UPDATE锁定讲座记录,让同一时刻只有一个事务能修改余量。这类问题在单表CRUD里完全不会遇到,但一旦做成真实系统就会立刻冒出来。
6.2 WebSocket 推送、Caffeine 缓存和 MinIO 文件服务
把基础CRUD打通之后,很多人会开始追求“高并发”“实时性”“文件存储”,热搜词里正好也出现Spring Boot集成WebSocket yml配置、Caffeine缓存和MinIO。我分开说一下它们在数据库操作场景里的作用。
讲座座位或订单状态变化后,客户端希望实时收到通知,这就要用WebSocket。Spring Boot里配置WebSocket不算复杂,但容易在yml配置和握手拦截器上翻车。你可以在application.yml里定义端点路径、允许的跨域来源,然后在配置类里注册WebSocket处理器。一个常见的坑是Spring Boot 3和javax的版本兼容问题,Spring Boot 3里大量包名从javax改成了jakarta,如果导入的是老包的WsConfigurer,就会抛NoClassDefFoundError。
缓存层我用Caffeine,它的定位是本地缓存,比Redis轻量,适合放变动不频繁的数据,比如讲座基础信息、系统字典。Spring Boot集成Caffeine也很简单,先引入依赖,然后在配置类里注册CacheManager,接着在Service方法上加@Cacheable注解。它的意义不只是“快”,更重要的是减轻数据库压力。如果每次都直接查DB,数据库迟早成为瓶颈。
MinIO是这几年很火的对象存储方案,适合存头像、讲座海报、办公用品管理系统的Excel模板。Spring Boot集成MinIO主要靠依赖io.minio:minio和配置类里手动构建MinioClient。注意对象存储的URL签名会有时效,数据库里存的一般是文件对象名,而不是临时下载URL,不然过期之后前端就拿不到文件了。
6.3 权限控制、多租户与数据隔离
企业办公用品管理系统这类项目里,数据库操作还有一个绕不开的主题:权限控制。不是说用户登录了就能看所有数据,而是不同部门、不同角色能看到的数据范围不一样。控制方式可以在SQL层加条件,也可以在代码层过滤。对于中小系统,我推荐在Repository方法中显式拼接部门ID条件,或者使用Spring Security配合@PreAuthorize注解控制接口级权限。
Spring Security的配置在Spring Boot 3里变化比较大。以前WebSecurityConfigurerAdapter还在,Spring Boot 3之后主流的写法是直接定义SecurityFilterChain这个Bean,把各种请求的放行规则、登录页、csrf开关都写在一个方法里。这部分建议单独花时间看官方文档,因为网上很多旧教程已经对不上新版本了。权限和数据隔离如果做不好,往往比SQL性能问题更致命,轻则越权访问,重则数据泄露。
多租户则是另一层问题。餐饮SaaS这种一个数据库服务很多家商户的系统,每个商户的数据必须隔离。常见做法有独立数据库、独立表、共享表加租户ID三种方案。共享表加租户ID最省资源,但必须确保所有增删改查都带上tenant_id过滤条件,漏掉一个查询就会把商户A的数据展示给商户B。这种问题靠测试很难全发现,最好的办法是在框架层面做强制拦截。
7. 新版本新特性:Spring Boot 3.5、JDK 21 和虚拟线程
7.1 为什么要关注版本组合
热搜里出现“Java21 + Spring Boot 3.5启用虚拟线程”,这其实代表了当前新项目选型的一个重要方向。JDK 21引入了虚拟线程,目的就是解决“高并发下的线程阻塞”问题。以前每个请求占用一个操作系统线程,线程数量一大,上下文切换开销非常可观。虚拟线程是JVM管理的轻量级线程,数量可以开到几十万而不压垮系统,特别适合大量I/O等待的场景,比如数据库查询、外部接口调用。
但虚拟线程不是银弹。它只适合I/O密集型任务,不适合CPU密集型计算。如果你的业务里全是复杂计算、图像处理,虚拟线程的优势就发挥不出来。另外,虚拟线程搭配传统的某些连接池或同步锁时会有坑,比如在synchronized块里做数据库操作不一定会释放调度线程。所以在Spring Boot 3.5里启用虚拟线程,我建议先在非核心非关键链路上做灰度验证,确认链路稳定再逐步切过去。
7.2 迁移到 Spring Boot 3 时,数据库相关代码要注意什么
如果你手上有老项目想从Spring Boot 2.x迁到3.x,数据库相关的迁移点主要集中在几个地方:javax命名空间改成jakarta、部分配置属性名发生了调整、Spring Security的配置方式翻新、Jetty/Tomcat等容器的默认行为也有变化。拿javax.persistence改成jakarta.persistence来说,你的实体类里所有@Entity、@Table导入都需要替换,批量替换倒不难,就怕有的依赖还是老版本,编译时各种矛盾。
配置属性上最典型的变化是spring.datasource.*整体保持兼容,但很多内嵌容器、监控端点、分页插件的属性名前缀变了。迁移时别存侥幸心态,最好把启动日志里所有deprecated配置项逐个处理,否则线上环境时不时出现奇怪行为。实操层面,我会建议先用Spring Boot Migrator这类工具扫描,再做一轮手动的配置核对,最后用一个覆盖主要读写链路的自动化测试把迁移后的项目跑一遍。
8. 踩坑记录与个人体会
8.1 我自己经历过的最诡异的数据库连环坑
这个东西我要单独分享,因为它综合了前面讲的很多知识点,而且特别容易复现。之前有一个系统,测试环境一切正常,一到生产环境就出现偶发性的“连接被关闭”。排查过程非常折磨人,前后查了三天。最后发现是数据库服务器的wait_timeout只有8小时,而连接池的空闲连接没及时释放,超过8小时之后,数据库那侧就把空闲连接断掉了。连接池里的连接却不知道,还给业务线程用,于是第一次执行SQL时直接报错。
解决办法有两个层次。第一个层次是把HikariCP的max-lifetime设置得比数据库wait_timeout短一点,比如数据库8小时,连接池设5小时。第二个层次是打开连接池的连接测试,HikariCP里有一个connection-test-query,虽然默认有,但一定要确认配置没被覆盖掉。经过这次之后,我在任何项目的配置检查清单里都固定加一条:核对数据库wait_timeout和连接池的各项超时时间。这一条写进我的个人日志里,省掉未来可能的一大堆麻烦。
8.2 最后的经验:命名、分层和变更管理,比手写代码更重要
数据库操作写得多了,你会发现最花时间的地方不是写SQL,而是命名、分层和变更管理三件事。命名要是乱来,比如表叫t_user_info,字段叫u_name,维护半年后你想重构都无从下手。分层要是乱来,所有逻辑都堆在Controller里,事务边界根本画不出来,并发问题只会越来越多。变更管理要是乱来,开发环境改个字段,测试环境还拿旧SQL造数据,部署上去就全线报错。
我建议你在项目初期就定一套固定规范:表名用下划线命名、Java实体用小写驼峰、Repository方法名遵循统一前缀、Service层严格按事务边界拆分、数据库变更脚本统一收进resources/db/migration目录,而不是直接在Navicat里手动执行。规范往往看起来“麻烦”,但它能让你在项目进入第四周、第四个月之后,依然不用靠回忆来维护代码。
8.3 后续可以怎么继续扩展
地址簿管理系统做到这里,已经具备从“能用”走向“好用”的基础了。你可以继续加索引优化查询性能,也可以引入Redis做缓存热点联系人,还可以给接口补充分页查询和批量导入功能。如果你正在做毕业设计,比如基于Spring Boot的校园讲座预约系统,你就可以在这个骨架上把讲座管理、预约、签到、消息通知、数据统计逐个填进去,再配合本文讲到的只读账号、连接池调优、日志排查方案,整个系统的技术深度会立刻和其他人拉开差距。
记住一个原则:数据库操作从来不只是CRUD,它是数据一致性、性能、安全性和可维护性的共同载体。多写几遍,多想几层,你就从“能跑”走到了“能扛”。