目标 :能完成真实业务系统的数据增删改查、事务控制和性能优化
学习时长 :2~3 周
前置要求:SQL 基础、完成核心篇
目录
- [MySQL 集成](#MySQL 集成)
- [JDBC 与连接池 HikariCP](#JDBC 与连接池 HikariCP)
- [Spring Data JPA](#Spring Data JPA)
- MyBatis
- MyBatis-Plus
- 分页查询
- [动态 SQL](#动态 SQL)
- 数据库事务
- 乐观锁与悲观锁
- 多数据源配置
- [数据库迁移 Flyway](#数据库迁移 Flyway)
- 慢查询与性能优化
- 面试高频题
1. MySQL 集成
引入依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
配置
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root123
driver-class-name: com.mysql.cj.jdbc.Driver
2. JDBC 与连接池 HikariCP
Spring Boot 2.x 开始默认使用 HikariCP(号称最快的 JDBC 连接池)。
HikariCP 核心参数调优
yaml
spring:
datasource:
hikari:
# 连接池核心配置
maximum-pool-size: 20 # 最大连接数(建议 = CPU核数 * 2 + 1)
minimum-idle: 5 # 最小空闲连接数
idle-timeout: 600000 # 空闲连接超时(10分钟)
connection-timeout: 30000 # 获取连接超时(30秒)
max-lifetime: 1800000 # 连接最大存活时间(30分钟,需小于数据库 wait_timeout)
pool-name: HikariPool-Main
connection-test-query: SELECT 1 # 连接测试 SQL
连接池大小的公式
最优连接数 ≈ CPU 核数 × 2 + 有效磁盘数
(对于纯 DB 操作,通常 20~50 之间)
过大的连接池反而有害:线程竞争 + 上下文切换开销
3. Spring Data JPA
实体映射注解
java
@Data
@Entity
@Table(name = "t_user", indexes = {
@Index(name = "idx_email", columnList = "email", unique = true)
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String username;
@Column(nullable = false, unique = true)
private String email;
@Enumerated(EnumType.STRING) // 枚举存字符串(不要用 ORDINAL,加字段后序号变化)
private UserStatus status;
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// 一对多关系
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Order> orders;
}
Repository 方法命名规则
java
public interface UserRepository extends JpaRepository<User, Long> {
// 按字段查询
Optional<User> findByEmail(String email);
List<User> findByStatus(UserStatus status);
// 组合条件
List<User> findByUsernameAndStatus(String username, UserStatus status);
List<User> findByUsernameOrEmail(String username, String email);
// 范围查询
List<User> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
List<User> findByAgeLessThan(int age);
// 模糊查询
List<User> findByUsernameLike(String pattern); // 需手动加 %
List<User> findByUsernameContaining(String keyword); // 自动加 %keyword%
// 排序
List<User> findByStatusOrderByCreatedAtDesc(UserStatus status);
// 判断存在
boolean existsByEmail(String email);
// 计数
long countByStatus(UserStatus status);
// 自定义 JPQL
@Query("SELECT u FROM User u WHERE u.username LIKE %:kw% OR u.email LIKE %:kw%")
List<User> search(@Param("kw") String keyword);
// 自定义原生 SQL
@Query(value = "SELECT * FROM t_user WHERE age > :age", nativeQuery = true)
List<User> findByAgeNative(@Param("age") int age);
// 更新操作(必须加 @Modifying 和 @Transactional)
@Modifying
@Transactional
@Query("UPDATE User u SET u.status = :status WHERE u.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") UserStatus status);
}
N+1 问题及解决
java
// 问题:查询100个用户,每个用户又查询其订单 → 101次SQL
List<User> users = userRepository.findAll();
users.forEach(u -> u.getOrders().size()); // 触发 N 次懒加载
// 解决方案1:FETCH JOIN(一次查询)
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.status = 'ACTIVE'")
List<User> findActiveWithOrders();
// 解决方案2:EntityGraph
@EntityGraph(attributePaths = {"orders"})
List<User> findByStatus(UserStatus status);
4. MyBatis
配置
yaml
mybatis:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.example.entity
configuration:
map-underscore-to-camel-case: true # 下划线转驼峰
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
Mapper 接口 + XML
java
@Mapper
public interface UserMapper {
User selectById(Long id);
List<User> selectByCondition(UserQuery query);
int insert(User user);
int updateById(User user);
int deleteById(Long id);
}
xml
<!-- resources/mapper/UserMapper.xml -->
<mapper namespace="com.example.mapper.UserMapper">
<resultMap id="UserMap" type="User">
<id property="id" column="id"/>
<result property="username" column="username"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="selectById" resultMap="UserMap">
SELECT * FROM t_user WHERE id = #{id} AND deleted = 0
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO t_user(username, email, created_at)
VALUES(#{username}, #{email}, NOW())
</insert>
<!-- 动态 SQL -->
<select id="selectByCondition" resultMap="UserMap">
SELECT * FROM t_user
<where>
<if test="username != null and username != ''">
AND username LIKE CONCAT('%', #{username}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
ORDER BY created_at DESC
</select>
</mapper>
5. MyBatis-Plus
MP 在 MyBatis 基础上提供 CRUD 方法、条件构造器、分页插件、乐观锁、逻辑删除等能力。
实体注解
java
@Data
@TableName("t_product")
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private BigDecimal price;
@Version // 乐观锁
private Integer version;
@TableLogic // 逻辑删除
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
LambdaQueryWrapper(推荐)
java
// 条件构造:类型安全,避免字段名写错
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
.like(StringUtils.hasText(name), Product::getName, name) // 非空才加条件
.ge(minPrice != null, Product::getPrice, minPrice) // >= minPrice
.le(maxPrice != null, Product::getPrice, maxPrice) // <= maxPrice
.eq(Product::getDeleted, 0) // 未删除
.orderByDesc(Product::getCreatedAt);
List<Product> products = productMapper.selectList(wrapper);
ServiceImpl 内置方法
java
// 继承 ServiceImpl 获得完整 CRUD
@Service
public class ProductService extends ServiceImpl<ProductMapper, Product> {
public void demo() {
// 查询
getById(1L);
list(new LambdaQueryWrapper<Product>().eq(Product::getStatus, 1));
// 保存/更新
save(product); // INSERT
updateById(product); // UPDATE by id
saveOrUpdate(product); // INSERT or UPDATE(id存在则UPDATE)
// 删除(实际是 UPDATE deleted=1)
removeById(1L);
remove(new LambdaQueryWrapper<Product>().lt(Product::getStock, 0));
// 批量
saveBatch(productList, 500); // 500条一批
}
}
6. 分页查询
JPA 分页
java
// PageRequest.of(pageNumber, pageSize),pageNumber 从 0 开始
Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<User> page = userRepository.findAll(pageable);
// Page 对象包含:
page.getContent(); // 当前页数据
page.getTotalElements(); // 总记录数
page.getTotalPages(); // 总页数
page.getNumber(); // 当前页码(0-based)
page.getSize(); // 每页大小
MyBatis-Plus 分页(需注册分页插件)
java
// 注册插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
// 使用(pageNum 从 1 开始)
Page<Product> page = productService.page(
new Page<>(1, 10),
new LambdaQueryWrapper<Product>().orderByDesc(Product::getCreatedAt)
);
7. 动态 SQL
MyBatis XML 动态 SQL
xml
<select id="search" resultType="User">
SELECT * FROM t_user
<where>
<if test="username != null">AND username LIKE #{username}</if>
<if test="status != null">AND status = #{status}</if>
<if test="startDate != null">AND created_at >= #{startDate}</if>
</where>
<choose>
<when test="orderBy == 'name'">ORDER BY username</when>
<when test="orderBy == 'date'">ORDER BY created_at DESC</when>
<otherwise>ORDER BY id DESC</otherwise>
</choose>
LIMIT #{offset}, #{limit}
</select>
<!-- foreach:IN 查询 -->
<select id="selectByIds" resultType="User">
SELECT * FROM t_user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
8. 数据库事务
@Transactional 核心属性
java
@Transactional(
propagation = Propagation.REQUIRED, // 传播行为(默认)
isolation = Isolation.READ_COMMITTED, // 隔离级别
rollbackFor = Exception.class, // 回滚条件(建议指定 Exception.class)
timeout = 30, // 超时时间(秒)
readOnly = false // 只读事务(查询优化)
)
public void transfer(Long from, Long to, BigDecimal amount) {
accountService.deduct(from, amount); // 扣款
accountService.deposit(to, amount); // 入账
// 任何一步抛出异常,整个事务回滚
}
事务传播行为
| 传播行为 | 说明 |
|---|---|
REQUIRED(默认) |
有事务则加入,无则新建 |
REQUIRES_NEW |
始终新建事务,外部事务挂起 |
NESTED |
嵌套事务(保存点),外部回滚时内部也回滚 |
SUPPORTS |
有事务则加入,无则非事务执行 |
NOT_SUPPORTED |
非事务执行,有事务则挂起 |
NEVER |
非事务执行,有事务则抛异常 |
MANDATORY |
必须在已有事务中执行,无则抛异常 |
⚠️ 事务失效的常见场景
java
// 1. 非 public 方法上的 @Transactional(Spring AOP 限制)
@Transactional
private void internalMethod() { ... } // ❌ 失效
// 2. 同类中方法调用(this 调用绕过代理)
@Service
public class OrderService {
public void createOrder() {
this.saveLog(); // ❌ 事务不生效(应注入 OrderService 自身)
}
@Transactional
public void saveLog() { ... }
}
// 3. 异常被吃掉
@Transactional
public void create(User user) {
try {
userRepository.save(user);
throw new RuntimeException("模拟异常");
} catch (Exception e) {
log.error(e.getMessage()); // ❌ 异常被捕获,事务不回滚
}
}
// 4. 非 RuntimeException(默认只回滚运行时异常)
@Transactional
public void create() throws Exception {
throw new Exception("检查型异常"); // ❌ 不回滚
}
// 正确:@Transactional(rollbackFor = Exception.class)
9. 乐观锁与悲观锁
乐观锁(适合读多写少)
java
// JPA 版本
@Entity
public class Product {
@Version // 自动管理 version 字段
private Integer version;
}
// 更新时 WHERE version = 当前值,更新后 version + 1
// 若影响行数为 0,说明并发冲突 → 抛 OptimisticLockException
// MyBatis-Plus 版本(需注册 OptimisticLockerInnerInterceptor)
@Version
private Integer version;
// 自动生成:UPDATE t_product SET stock=? version=version+1 WHERE id=? AND version=?
悲观锁(适合写多读少)
java
// SELECT ... FOR UPDATE(行锁)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);
// 或直接写 SQL
@Query(value = "SELECT * FROM t_product WHERE id = :id FOR UPDATE", nativeQuery = true)
Product selectForUpdate(@Param("id") Long id);
乐观锁 vs 悲观锁
| 特性 | 乐观锁 | 悲观锁 |
|---|---|---|
| 加锁时机 | 提交时 | 读取时 |
| 并发性能 | 高(无锁等待) | 低(排他等待) |
| 适用场景 | 读多写少,冲突少 | 写多,冲突频繁 |
| 失败处理 | 捕获异常重试 | 等待锁释放 |
10. 多数据源配置
java
@Configuration
public class DataSourceConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
// 动态数据源路由(读写分离)
@Bean
public DataSource dynamicDataSource() {
Map<Object, Object> targets = new HashMap<>();
targets.put("master", masterDataSource());
targets.put("slave", slaveDataSource());
AbstractRoutingDataSource routing = new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
// 根据 ThreadLocal 标记决定使用主库还是从库
return DataSourceContextHolder.get();
}
};
routing.setDefaultTargetDataSource(masterDataSource());
routing.setTargetDataSources(targets);
return routing;
}
}
11. 数据库迁移 Flyway
xml
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
yaml
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
sql
-- resources/db/migration/V1__init.sql
CREATE TABLE t_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- resources/db/migration/V2__add_status.sql
ALTER TABLE t_user ADD COLUMN status INT NOT NULL DEFAULT 1;
命名规范 :
V{版本}__{描述}.sql,版本号单调递增,已执行的脚本不可修改。
12. 慢查询与性能优化
慢查询日志
sql
-- 开启慢查询(MySQL)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过1秒记录
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- 分析慢查询
EXPLAIN SELECT * FROM t_user WHERE email = 'xxx@example.com';
EXPLAIN 关键指标
| 字段 | 说明 | 优化目标 |
|---|---|---|
type |
访问类型 | 至少达到 range,最好 ref/eq_ref |
key |
使用的索引 | 不为 null |
rows |
扫描行数 | 越小越好 |
Extra |
附加信息 | 避免 Using filesort、Using temporary |
常用索引优化原则
sql
-- 1. 为常查询、ORDER BY、JOIN 字段建索引
CREATE INDEX idx_status_created ON t_user(status, created_at);
-- 2. 联合索引遵循最左前缀原则
-- (a, b, c) 索引可以走:a、a+b、a+b+c,不能走 b、c、b+c
-- 3. 避免索引失效
WHERE YEAR(created_at) = 2024 -- ❌ 函数操作
WHERE created_at >= '2024-01-01' -- ✅
WHERE email != 'a@b.com' -- ❌ != 不走索引
WHERE email = 'a@b.com' -- ✅
WHERE name LIKE '%zhang%' -- ❌ 前缀模糊
WHERE name LIKE 'zhang%' -- ✅
13. 面试高频题
Q1:MyBatis 和 JPA 怎么选?
复杂 SQL 多、需要精细控制 → MyBatis;业务简单、快速开发 → JPA;大多数企业同时使用两者或 MyBatis-Plus。
Q2:@Transactional 失效的几种情况?
①非 public 方法;②同类内部方法调用;③异常被捕获未重新抛出;④非受 Spring 管理的 Bean;⑤数据库引擎不支持事务(如 MyISAM)。
Q3:乐观锁的原理?
在数据库中增加 version 字段,更新时带上 WHERE version = 当前版本,若更新行数为 0 说明被其他线程先更新,需重试。
Q4:什么是 N+1 问题?如何解决?
查询 N 条父记录时,对每条记录又发一次 SQL 查关联数据,共 N+1 次 SQL。解决:FETCH JOIN 一次查出、
@EntityGraph、批量加载。
Q5:数据库事务的四种隔离级别?
READ_UNCOMMITTED(脏读)→ READ_COMMITTED(防脏读)→ REPEATABLE_READ(防不可重复读,MySQL默认)→ SERIALIZABLE(防幻读,性能最低)。
Q6:HikariCP 的最大连接数如何设置?
公式:connections = (core_count × 2) + effective_spindle_count。一般 10~20 已足够,盲目调大反而因线程争抢降低性能。
Q7:MyBatis-Plus 的逻辑删除原理?
在实体字段上标注
@TableLogic,MP 自动将 DELETE 转为 UPDATE deleted=1,SELECT 自动附加 WHERE deleted=0 条件。
14. 读写分离:AOP + 注解自动切换(专家实践)
知识点 1:读写分离的原理
MySQL 主从复制场景下:
- 主库(Master):处理写操作(INSERT/UPDATE/DELETE)
- 从库(Slave):处理读操作(SELECT),可以有多个
读写分离的关键:在 SQL 执行前,自动选择正确的数据源。
Spring 的 AbstractRoutingDataSource 提供了动态路由机制:根据一个 lookupKey 在多个数据源中选择一个,这个 key 可以存储在 ThreadLocal 中。
知识点 2:完整实现(注解驱动读写分离)
java
// 步骤1:数据源类型枚举
public enum DataSourceType {
MASTER, SLAVE
}
// 步骤2:ThreadLocal 存储当前数据源类型
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceType> CONTEXT =
new ThreadLocal<>();
public static void setMaster() { CONTEXT.set(DataSourceType.MASTER); }
public static void setSlave() { CONTEXT.set(DataSourceType.SLAVE); }
public static DataSourceType get() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}
// 步骤3:自定义路由数据源
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
DataSourceType type = DataSourceContextHolder.get();
// 未设置时默认主库(保证写操作安全)
return type != null ? type : DataSourceType.MASTER;
}
}
// 步骤4:配置多数据源
@Configuration
public class DataSourceConfig {
@Bean("masterDataSource")
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean("slaveDataSource")
@ConfigurationProperties("spring.datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@Primary
public DataSource routingDataSource(
@Qualifier("masterDataSource") DataSource master,
@Qualifier("slaveDataSource") DataSource slave) {
RoutingDataSource routing = new RoutingDataSource();
Map<Object, Object> targets = new HashMap<>();
targets.put(DataSourceType.MASTER, master);
targets.put(DataSourceType.SLAVE, slave);
routing.setTargetDataSources(targets);
routing.setDefaultTargetDataSource(master);
return routing;
}
}
// 步骤5:自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {
// 标注此注解的方法使用从库
}
// 步骤6:AOP 切面自动切换
@Aspect
@Component
@Order(-1) // 必须在 @Transactional 之前执行(优先级更高)
public class DataSourceAspect {
@Around("@annotation(com.example.annotation.ReadOnly)")
public Object switchToSlave(ProceedingJoinPoint joinPoint) throws Throwable {
try {
DataSourceContextHolder.setSlave();
return joinPoint.proceed();
} finally {
DataSourceContextHolder.clear(); // 必须清理
}
}
}
// 步骤7:使用(在 Service 方法上标注)
@Service
public class UserService {
@ReadOnly // 自动使用从库
public List<User> listUsers(UserQuery query) {
return userMapper.selectList(query);
}
// 不标注 → 默认使用主库(写操作)
@Transactional
public User createUser(CreateUserDTO dto) {
return userMapper.insert(new User(dto));
}
}
注意事项:
@Order(-1)保证数据源切换在事务开启之前执行(因为事务开启时已经绑定了连接)- 事务内不要切换数据源(同一事务必须用同一个连接/数据源)
15. 批量操作性能优化(专家必知)
知识点:批量插入的性能对比
单条 INSERT vs 批量 INSERT 的性能差异巨大:
| 方式 | 10000条数据 | 说明 |
|---|---|---|
| 循环单条 INSERT | ~30秒 | 每次网络往返+DB处理 |
| MyBatis-Plus saveBatch | ~3秒 | 分批提交(默认每批1000条) |
| JDBC 批量 addBatch | ~0.5秒 | 单次网络往返,DB批量处理 |
| 拼接 INSERT ... VALUES | ~0.3秒 | 最快,但SQL长度有限制 |
知识点:MyBatis-Plus 批量插入的坑
java
// ❌ 性能差:循环单条保存
for (User user : users) {
userService.save(user); // 每次都是一次 INSERT + 一次网络往返
}
// ⚠️ MyBatis-Plus saveBatch 默认分批1000条,但仍是逐条提交到DB
// 需要配合 rewriteBatchedStatements=true 才能真正批量
userService.saveBatch(users, 1000);
关键配置(MySQL):
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true
# rewriteBatchedStatements=true 将多条 INSERT 合并为一条发送给 MySQL
# 这是批量插入性能提升10倍的关键配置!
JDBC 原生批量(最高性能):
java
@Service
@RequiredArgsConstructor
public class UserBatchService {
private final JdbcTemplate jdbcTemplate;
/**
* JDBC 批量插入(最高性能)
* 适合超大批量数据(万级以上)
*/
public void batchInsert(List<User> users) {
String sql = "INSERT INTO t_user(username, email, status, created_at) VALUES (?,?,?,?)";
jdbcTemplate.batchUpdate(sql, users, 500, (ps, user) -> {
ps.setString(1, user.getUsername());
ps.setString(2, user.getEmail());
ps.setInt(3, user.getStatus());
ps.setObject(4, user.getCreatedAt());
});
}
}
16. SQL 安全:防止 SQL 注入
知识点:MyBatis 中 #{} vs ${} 的区别
xml
<!-- ✅ #{} 使用 PreparedStatement 占位符,安全 -->
<select id="findByUsername" resultType="User">
SELECT * FROM t_user WHERE username = #{username}
-- 实际执行:SELECT * FROM t_user WHERE username = ?
-- 参数通过 setString() 传入,SQL 和数据分离,无注入风险
</select>
<!-- ❌ ${} 直接字符串拼接,有 SQL 注入风险 -->
<select id="findByUsername" resultType="User">
SELECT * FROM t_user WHERE username = '${username}'
-- 如果 username = "' OR '1'='1"
-- 实际执行:SELECT * FROM t_user WHERE username = '' OR '1'='1'
-- → 返回所有用户!严重安全漏洞
</select>
${} 的合法使用场景(动态表名/列名,但必须在代码层做白名单校验):
java
// 合法场景:动态排序字段(必须白名单校验)
@GetMapping("/users")
public List<User> listUsers(
@RequestParam(defaultValue = "created_at") String sortField,
@RequestParam(defaultValue = "DESC") String sortOrder) {
// 白名单校验(防止 SQL 注入)
Set<String> allowedFields = Set.of("username", "created_at", "email");
Set<String> allowedOrders = Set.of("ASC", "DESC");
if (!allowedFields.contains(sortField) || !allowedOrders.contains(sortOrder)) {
throw new IllegalArgumentException("非法排序参数");
}
return userMapper.findAllSorted(sortField, sortOrder); // 这里可以用 ${}
}
17. JPA 高级用法详解
知识点 1:实体类注解与关联映射
java
// 完整的 JPA 实体类示例
@Entity
@Table(name = "t_user", indexes = {
@Index(name = "idx_email", columnList = "email", unique = true),
@Index(name = "idx_status_created", columnList = "status, created_at")
})
@EntityListeners(AuditingEntityListener.class) // 开启审计自动填充
@Data
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false, length = 50)
private String username;
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@Column(name = "password", nullable = false)
@JsonIgnore // 不序列化到响应中
private String password;
@Enumerated(EnumType.STRING) // 枚举存字符串(ACTIVE/DISABLED),而非数字
@Column(name = "status")
private UserStatus status = UserStatus.ACTIVE;
@Column(name = "created_at", updatable = false) // 创建后不可修改
@CreatedDate // 审计:自动填充创建时间
private LocalDateTime createdAt;
@Column(name = "updated_at")
@LastModifiedDate // 审计:自动填充更新时间
private LocalDateTime updatedAt;
// 一对多:一个用户有多个订单
@OneToMany(mappedBy = "user", // mappedBy = 对方实体中本实体的字段名
cascade = CascadeType.ALL, // 级联操作
fetch = FetchType.LAZY) // 懒加载(不查询时不加载)
private List<Order> orders = new ArrayList<>();
}
// 开启审计功能
@SpringBootApplication
@EnableJpaAuditing
public class MyApplication { ... }
知识点 2:懒加载与 N+1 问题
text
N+1 问题(最常见的 JPA 性能坑):
查询 100 个用户(1 次 SQL)
→ 访问每个用户的 orders(因为懒加载,每个触发一次 SQL)
→ 共执行 1 + 100 = 101 次 SQL
java
// ❌ 触发 N+1 问题
List<User> users = userRepository.findAll();
for (User user : users) {
// 每次访问 orders 都触发一次 SELECT * FROM t_order WHERE user_id = ?
System.out.println(user.getOrders().size());
}
// ✅ 方案1:@EntityGraph 一次性查询(JOIN FETCH)
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = {"orders"}) // 告诉 JPA 同时 JOIN 加载 orders
@Query("SELECT u FROM User u")
List<User> findAllWithOrders();
}
// ✅ 方案2:JPQL 手写 JOIN FETCH
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.orders WHERE u.status = :status")
List<User> findByStatusWithOrders(@Param("status") UserStatus status);
知识点 3:Specification 动态查询
java
// 场景:后台列表页,条件不固定(多个条件任意组合)
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> { // 需要继承 JpaSpecificationExecutor
}
@Service
public class UserService {
public Page<User> findUsers(UserQueryDTO query, Pageable pageable) {
Specification<User> spec = buildSpec(query);
return userRepository.findAll(spec, pageable);
}
private Specification<User> buildSpec(UserQueryDTO query) {
return (root, criteriaQuery, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// 动态添加条件(只有传了才加)
if (StringUtils.hasText(query.getUsername())) {
predicates.add(cb.like(root.get("username"),
"%" + query.getUsername() + "%"));
}
if (query.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), query.getStatus()));
}
if (query.getStartTime() != null) {
predicates.add(cb.greaterThanOrEqualTo(
root.get("createdAt"), query.getStartTime()));
}
if (query.getEndTime() != null) {
predicates.add(cb.lessThanOrEqualTo(
root.get("createdAt"), query.getEndTime()));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}
18. MyBatis-Plus 完整功能详解
知识点 1:条件构造器(核心 API)
java
@Service
@RequiredArgsConstructor
public class UserService extends ServiceImpl<UserMapper, User> implements IService<User> {
public void demonstrateWrapper() {
// ===== QueryWrapper(字符串字段名,有拼写错误风险)=====
QueryWrapper<User> qw = new QueryWrapper<>();
qw.eq("status", "ACTIVE") // WHERE status = 'ACTIVE'
.like("username", "张") // AND username LIKE '%张%'
.ge("age", 18) // AND age >= 18
.le("age", 60) // AND age <= 60
.between("created_at", start, end) // AND created_at BETWEEN ? AND ?
.in("role", "ADMIN", "USER") // AND role IN ('ADMIN', 'USER')
.isNotNull("email") // AND email IS NOT NULL
.orderByDesc("created_at") // ORDER BY created_at DESC
.last("LIMIT 10"); // 直接拼接 SQL(谨慎使用)
// ===== LambdaQueryWrapper(类型安全,推荐)=====
LambdaQueryWrapper<User> lqw = new LambdaQueryWrapper<>();
lqw.eq(User::getStatus, UserStatus.ACTIVE)
.like(StringUtils.hasText(keyword), User::getUsername, keyword) // 有值才加条件
.ge(User::getAge, 18)
.orderByDesc(User::getCreatedAt)
.select(User::getId, User::getUsername, User::getEmail); // 只查需要的字段
List<User> users = userMapper.selectList(lqw);
// ===== UpdateWrapper(更新)=====
LambdaUpdateWrapper<User> uwq = new LambdaUpdateWrapper<>();
uwq.eq(User::getId, 1L)
.set(User::getStatus, UserStatus.DISABLED)
.set(User::getUpdatedAt, LocalDateTime.now());
userMapper.update(null, uwq); // 第一个参数为 null 时,update 的值从 wrapper 中取
}
}
知识点 2:分页查询
java
// 配置分页插件
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件(指定数据库类型)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
@Service
public class UserService {
public IPage<UserDTO> listUsers(int page, int size, String keyword) {
// 1. 创建分页参数
Page<User> pageParam = new Page<>(page, size);
pageParam.addOrder(OrderItem.desc("created_at")); // 排序
// 2. 查询(自动添加 LIMIT 子句)
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.hasText(keyword), User::getUsername, keyword);
IPage<User> result = userMapper.selectPage(pageParam, wrapper);
// 3. 转换 DTO
return result.convert(user -> BeanUtils.copyProperties(user, UserDTO.class));
}
}
知识点 3:乐观锁
text
适用场景:并发修改同一条记录时,防止数据覆盖(如库存扣减)
乐观锁原理:
查询时读取 version 字段
更新时 WHERE id=? AND version=? → 若 version 已变,更新影响行数=0(失败)
更新成功时 version 自动 +1
java
@Data
@TableName("t_product")
public class Product {
@TableId
private Long id;
private String name;
private Integer stock;
@Version // 乐观锁版本字段
private Integer version;
}
@Service
public class ProductService {
// 正确的并发扣减库存
public boolean deductStock(Long productId, int quantity) {
for (int retry = 0; retry < 3; retry++) {
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
throw new BusinessException(ResultCode.STOCK_NOT_ENOUGH);
}
product.setStock(product.getStock() - quantity);
int rows = productMapper.updateById(product); // 内部 WHERE id=? AND version=?
if (rows > 0) return true; // 更新成功
// rows == 0:version 已变(并发冲突),重试
log.warn("乐观锁冲突,第 {} 次重试", retry + 1);
}
throw new BusinessException(500, "库存更新失败,请重试");
}
}
19. 事务传播行为详解(7种)
知识点:各传播行为的实际含义
java
@Service
public class OrderService {
@Autowired private OrderService self; // 自注入,解决内部调用事务失效问题
@Transactional
public void createOrder() {
saveOrder(); // 有事务
self.saveLog(); // 通过代理调用,才能让传播行为生效
}
}
@Service
public class LogService {
// REQUIRED(默认):有就加入,没有就新建
// 场景:大多数业务方法
@Transactional(propagation = Propagation.REQUIRED)
public void saveLog1() {
// 在 createOrder 事务内调用:加入 createOrder 的事务
// 若 createOrder 回滚,saveLog1 也回滚
}
// REQUIRES_NEW:无论如何都新建事务,外层事务挂起
// 场景:操作日志(不希望因业务失败导致日志也回滚)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog2() {
// 即使外层事务回滚,saveLog2 也会提交
// 常用:审计日志、短信记录(必须记录,不随业务回滚)
}
// NESTED:嵌套事务(保存点,父事务可以部分回滚)
// 场景:批量操作中单条失败不影响整批
@Transactional(propagation = Propagation.NESTED)
public void processItem() {
// 失败时回滚到保存点,外层事务可以捕获异常继续处理其他记录
}
// NOT_SUPPORTED:不使用事务执行(挂起外层事务)
// 场景:查询操作,避免占用事务资源
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public List<Log> queryLogs() { return ...; }
// SUPPORTS:有事务就加入,没有就以非事务执行
@Transactional(propagation = Propagation.SUPPORTS)
public void flexibleMethod() {}
// MANDATORY:必须在事务中执行,否则抛异常
// 场景:强制要求调用方提供事务(防止被意外以非事务方式调用)
@Transactional(propagation = Propagation.MANDATORY)
public void mustBeInTransaction() {}
// NEVER:必须不在事务中执行,否则抛异常
@Transactional(propagation = Propagation.NEVER)
public void mustNotBeInTransaction() {}
}
最常用的是前3种,记忆口诀:
REQUIRED:跟着走(默认)REQUIRES_NEW:自己开门(独立)NESTED:套娃(保存点)
20. 数据库连接池监控:HikariCP
知识点:连接池核心参数与监控
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
# 核心参数
minimum-idle: 5 # 最小空闲连接数
maximum-pool-size: 20 # 最大连接数(核心,根据并发量和DB能力设置)
idle-timeout: 600000 # 空闲连接超时(10分钟),超过则关闭
max-lifetime: 1800000 # 连接最大存活时间(30分钟),到期强制关闭重建
connection-timeout: 30000 # 获取连接的超时时间(30秒)
connection-test-query: SELECT 1 # 测试连接是否有效的SQL(MySQL 8.0 可省略)
pool-name: HikariCP-Main # 连接池名称(监控用)
# 暴露 Hikari 指标给 Prometheus
management:
metrics:
enable:
hikaricp: true
java
// 编程式监控连接池状态
@Component
@RequiredArgsConstructor
@Slf4j
public class HikariPoolMonitor {
private final DataSource dataSource;
@Scheduled(fixedRate = 60000) // 每分钟打印一次连接池状态
public void printPoolStats() {
if (dataSource instanceof HikariDataSource hikari) {
HikariPoolMXBean pool = hikari.getHikariPoolMXBean();
log.info("连接池状态 [{}]: 活跃={}, 空闲={}, 等待={}, 总数={}",
hikari.getPoolName(),
pool.getActiveConnections(), // 正在使用的连接数
pool.getIdleConnections(), // 空闲连接数
pool.getThreadsAwaitingConnection(), // 等待连接的线程数(>0 说明连接不够用了)
pool.getTotalConnections()); // 连接池总连接数
}
}
}
连接池大小如何设置:
text
不是越大越好!公式参考:
connections = (CPU核心数 × 2) + 有效磁盘数
例:4核 CPU,SSD → (4 × 2) + 1 = 9,通常设 10-20
连接池过大的代价:
- 数据库连接是资源,每个连接占 MySQL 内存约 1MB
- 100 实例 × 20 连接 = 2000 个数据库连接,超过 MySQL max_connections 会报错
- 大量线程竞争有限 CPU,上下文切换开销增大
监控指标:
- 活跃连接数 ≈ 最大连接数 → 需要扩容
- 等待连接的线程 > 0 → 连接池瓶颈,考虑增加连接数或减少慢查询