第三篇:数据篇 — 数据库、事务与持久层开发

目标 :能完成真实业务系统的数据增删改查、事务控制和性能优化
学习时长 :2~3 周
前置要求:SQL 基础、完成核心篇


目录

  1. [MySQL 集成](#MySQL 集成)
  2. [JDBC 与连接池 HikariCP](#JDBC 与连接池 HikariCP)
  3. [Spring Data JPA](#Spring Data JPA)
  4. MyBatis
  5. MyBatis-Plus
  6. 分页查询
  7. [动态 SQL](#动态 SQL)
  8. 数据库事务
  9. 乐观锁与悲观锁
  10. 多数据源配置
  11. [数据库迁移 Flyway](#数据库迁移 Flyway)
  12. 慢查询与性能优化
  13. 面试高频题

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 filesortUsing 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 条件。


上一篇:02_核心篇 | 下一篇:04_进阶篇


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 → 连接池瓶颈,考虑增加连接数或减少慢查询
相关推荐
duangww1 小时前
OPEN SQL去掉文本中间的空格
数据库·abap
zxrhhm1 小时前
MySQL 索引回表(Back to Table)详解
数据库·mysql
m0_741481781 小时前
Vue.js核心基础之响应式系统与虚拟DOM渲染关联机制
jvm·数据库·python
架构源启1 小时前
2026 进阶篇:Spring Boot响应式编程 + Spring AI 1.1.4 流式实战 + Vue前端完整实现(避坑指南)
java·前端·vue.js·人工智能·spring boot·spring·ai编程
Gauss松鼠会1 小时前
GaussDB数据库统计信息自动收集机制
数据库·经验分享·sql·oracle·gaussdb
许彰午2 小时前
# Oracle shutdown immediate关不掉——一次排坑实录
数据库·oracle
消失的旧时光-19432 小时前
SQL 怎么学(工程实战总纲|用一套用户模型打穿全流程)
java·数据库·sql
abc123456sdggfd2 小时前
如何统一SQL视图报错信息_使用异常处理机制包装视图
jvm·数据库·python
qq_460978402 小时前
如何处理SQL循环逻辑_探索递归CTE实现复杂计算
jvm·数据库·python
码农阿豪2 小时前
Django接金仓数据库:我踩过的坑和填坑指南
数据库·python·django