1HikariCP 连接池 核心

数据库连接池是 Spring Boot 数据访问的基石

HikariCP 是 Spring Boot 2.x+ 默认的数据库连接池,以极速轻量著称。它通过字节码优化、无锁设计(ConcurrentBag)、FastList 等技术,比 Tomcat JDBC Pool、DBCP2 快数倍。

连接池 = 共享充电宝柜

没有连接池:每次用数据库都要 new Connection(走 TCP 握手 + 认证,~50ms),用完 close。就像每次充电都买一个新的充电宝。
连接池:预先创建一批连接放在池子里,用的时候借一个,用完还回去。就像共享充电宝——扫码借用,用完归还。
核心参数:池子大小(maximumPoolSize)、最小空闲(minimumIdle)、借出超时(connectionTimeout)、空闲超时(idleTimeout)、最大寿命(maxLifetime)。

HikariCP 配置参数详解

参数默认值说明
maximumPoolSize10最大连接数。不是越大越好!
minimumIdle= maximumPoolSize最小空闲连接数
connectionTimeout30s从池中获取连接的最大等待时间
idleTimeout10min空闲连接超时(超过 minIdle 才回收)
maxLifetime30min连接最大存活时间(建议比 DB 的 wait_timeout 小 30s)
leakDetectionThreshold0(禁用)连接泄漏检测阈值(建议设为 30s)

下面是一份生产级 HikariCP 配置。关键思路:池大小不要设太大(20 足够应付大多数场景);密码用环境变量注入(不要硬编码);必须开启泄漏检测。逐行注释如下:

# application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: ${DB_PASSWORD:secret}  # 环境变量优先
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      leak-detection-threshold: 30000  # 30s 未归还则日志告警
      pool-name: MyHikariPool
      connection-test-query: SELECT 1  # 连接有效性检查
关键点解读

${DB_PASSWORD:secret} — 先读环境变量 DB_PASSWORD,没有则用默认值 secret。生产环境密码绝不写死在 yml 里。
max-lifetime: 1800000(30分钟)— 必须比 MySQL 的 wait_timeout(默认 8 小时)小,建议小 30s 以上,否则连接会被 MySQL 服务端静默断开,HikariCP 拿到一个死连接就会报错。
leak-detection-threshold: 30000 — 借出超过 30 秒还没归还,日志会打出 Apparent connection leak detected 告警,帮你提前发现忘记 close 的代码。

⚠️ maximumPoolSize 不是越大越好!

公式:Tn × (Cm - 1) + 1 = 最优连接数(Tn=并发线程数,Cm=单线程同时持有的连接数)。

实际经验:20-50 个连接通常是最优区间。连接太多反而会降低性能——数据库需要花更多时间做上下文切换和锁管理。

PostgreSQL 官方公式:connections = ((core_count * 2) + effective_spindle_count)

生产案例:连接池耗尽导致服务雪崩

🚨 线上事故:HikariPool-1 - Connection is not available

症状:高峰期大量请求报 HikariPool-1 - Connection is not available, request timed out after 30000ms,所有接口超时。

❌ 错误原因

// 忘记 close! 连接被借走后一直不归还
@Service
public class ReportService {
    @Autowired
    private DataSource dataSource;

    public void generateReport(Long id) {
        Connection conn = dataSource.getConnection();  // 借出
        PreparedStatement ps = conn.prepareStatement("SELECT ...");
        ResultSet rs = ps.executeQuery();
        // 忘记 conn.close()!  连接池被耗尽
    }
}

✅ 正确做法

// 方式 1: try-with-resources
public void generateReport(Long id) throws SQLException {
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement("SELECT ...")) {
        try (ResultSet rs = ps.executeQuery()) {
            // 处理结果
        }
    }  // 自动 close, 归还连接池
}

// 方式 2: 使用 MyBatis/Spring Data, 框架自动管理连接
// @Transactional 方法中, 连接由 Spring 事务管理器自动借出和归还
@Autowired
private ReportMapper reportMapper;  // MyBatis Mapper

public Report getReport(Long id) {
    return reportMapper.findById(id);  // 框架自动管理连接
}

💡 经验法则:Spring Boot 中 99% 的场景都应该用 MyBatis/JPA,不要手动管理 Connection。如果必须手动用,务必开启 leakDetectionThreshold=30000。

2MyBatis 与 JPA

数据访问层(DAO/Mapper)的两条技术路线

MyBatis = 写 SQL 的专业工具,JPA = 自动生成 SQL

MyBatis 像一把手术刀——你写 SQL,它帮你做参数映射和结果映射,SQL 完全可控。
JPA(Hibernate)像自动驾驶——你只定义 Entity,它自动生成 SQL。简单 CRUD 快,但复杂查询难以优化。
国内主流选择:MyBatis + MyBatis-Plus(国内 80%+ 的 Spring Boot 项目用 MyBatis)。

MyBatis vs JPA 对比

维度MyBatisJPA (Hibernate)
SQL 控制完全可控,手写 SQL自动生成,复杂场景用 JPQL/Native SQL
学习曲线低(会 SQL 就会)高(需理解 Entity/关系映射/HQL)
性能优化容易(SQL 可见,好调优)难(N+1 问题、懒加载陷阱多)
动态 SQLXML <if> <foreach> 强大Criteria API / Specification 繁琐
CRUD 效率需写 XML 或注解继承 JpaRepository 一行不写
国内使用率⬇️ 80%+~15%

MyBatis XML Mapper 示例

// Mapper 接口
@Mapper
public interface OrderMapper {
    Order findById(@Param("id") Long id);
    List<Order> findByCondition(OrderQuery query);
    int batchInsert(@Param("orders") List<Order> orders);
    int updateStatus(@Param("id") Long id, @Param("status") String status);
}
<!-- OrderMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.OrderMapper">

    <!-- 结果映射 -->
    <resultMap id="orderMap" type="Order">
        <id property="id" column="order_id"/>
        <result property="amount" column="amount"/>
        <result property="status" column="status"/>
        <association property="user" javaType="User">
            <id property="id" column="user_id"/>
            <result property="name" column="user_name"/>
        </association>
    </resultMap>

    <!-- 单条查询 -->
    <select id="findById" resultMap="orderMap">
        SELECT o.*, u.name as user_name
        FROM orders o
        LEFT JOIN users u ON o.user_id = u.id
        WHERE o.id = #{id}
    </select>

动态 SQL(MyBatis 最强大的特性)

<!-- 条件查询: 根据参数动态拼接 SQL -->
<select id="findByCondition" resultMap="orderMap">
    SELECT * FROM orders
    WHERE 1=1
    <if test="status != null">
        AND status = #{status}
    </if>
    <if test="userId != null">
        AND user_id = #{userId}
    </if>
    <if test="startTime != null">
        AND create_time >= #{startTime}
    </if>
    <if test="endTime != null">
        AND create_time <= #{endTime}
    </if>
    ORDER BY create_time DESC
    LIMIT #{offset}, #{pageSize}
</select>

<!-- 批量插入: foreach 遍历 -->
<insert id="batchInsert">
    INSERT INTO orders (user_id, amount, status, create_time)
    VALUES
    <foreach collection="orders" item="order" separator=",">
        (#{order.userId}, #{order.amount}, #{order.status}, NOW())
    </foreach>
</insert>

<!-- choose-when-otherwise: 类似 switch-case -->
<select id="search" resultMap="orderMap">
    SELECT * FROM orders WHERE 1=1
    <choose>
        <when test="type == 'paid'">
            AND status = 'PAID'
        </when>
        <when test="type == 'unpaid'">
            AND status = 'PENDING'
        </when>
        <otherwise>
            AND status IS NOT NULL
        </otherwise>
    </choose>
</select>
🔒 MyBatis-Plus 增强功能

MyBatis-Plus 在 MyBatis 基础上提供:通用 CRUD(BaseMapper)、条件构造器(QueryWrapper/LambdaQueryWrapper)、分页插件、代码生成器。

// 继承 BaseMapper 即可获得通用 CRUD
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
    // 自定义 SQL 仍然可用
}

// 使用示例
@Autowired
private OrderMapper orderMapper;

// Lambda 条件构造器 (编译时检查, 避免字段名写错)
List<Order> orders = orderMapper.selectList(
    new LambdaQueryWrapper<Order>()
        .eq(Order::getStatus, "PAID")
        .ge(Order::getAmount, new BigDecimal("100"))
        .orderByDesc(Order::getCreateTime)
        .last("LIMIT 100")
);

// 分页查询
Page<Order> page = orderMapper.selectPage(
    new Page<>(1, 20),  // 第1页, 每页20条
    new LambdaQueryWrapper<Order>().eq(Order::getUserId, userId)
);
// page.getRecords() = 数据列表
// page.getTotal() = 总记录数

3Redis 缓存

缓存是高并发系统的第一道防线

Redis 缓存 = 办公桌 vs 档案柜

数据库查询就像去档案柜(DB)找资料——远、慢(~10ms)。
Redis 缓存就像把常用资料放在办公桌上——近、快(~0.1ms)。
问题:办公桌空间有限,得决定放什么、什么时候清理、档案柜更新了办公桌怎么同步。
这些就是缓存的三大问题:缓存穿透、缓存雪崩、缓存击穿。

缓存模式

模式流程适用场景
Cache-Aside(旁路缓存)先查缓存→miss→查DB→写入缓存读多写少(最常用)
Write-Through写DB同时写缓存写要求强一致
Write-Behind先写缓存,异步写DB写量极大,可接受短暂不一致
Read-Through缓存层自动从DB回源Spring Cache @Cacheable

下面用 Spring Cache + Redis 实现 Cache-Aside 模式。问题是什么:每个查询都打 DB 会压垮数据库,需要在 DB 前面加一层 Redis 缓存。怎么做:在方法上加注解,框架自动帮你查缓存、写缓存、删缓存,你只写业务逻辑。关键三个注解:@Cacheable(读时查缓存,miss 才执行方法)、@CacheEvict(写/删时让缓存失效)、@CachePut(执行方法并更新缓存)。

// Spring Cache + Redis 实现 Cache-Aside
@Service
public class UserService {

    @Cacheable(value = "users", key = "#id")  // 先查缓存, miss 则执行方法后写入
    public User getUserById(Long id) {
        return userMapper.findById(id);  // 只有 cache miss 才执行
    }

    @CacheEvict(value = "users", key = "#user.id")  // 更新时删除缓存
    public void updateUser(User user) {
        userMapper.update(user);
    }

    @CacheEvict(value = "users", allEntries = true)  // 清空整个缓存空间
    public void deleteAllCache() { }

    // ⚠️ @Cacheable 的坑:
    // 1. 类内部调用失效 (同 @Transactional)
    // 2. key 默认用参数, 建议显式指定 SpEL
    // 3. 缓存 null 值需要配置 allowNullValues=true
}
@Cacheable 使用注意事项(面试高频)

1. 类内部调用不生效:同类中 A 方法直接 this 调用带 @Cacheable 的 B 方法,不经过 AOP 代理,缓存不会生效。解决:拆到两个类,或注入自己(@Autowired UserService self)再调用。
2. 建议显式指定 key:多参数时不指定 key 会拼出奇怪的 key,建议始终用 SpEL 显式指定:key = "#id"
3. 默认不缓存 null 值:查不到的数据每次都打 DB(缓存穿透)。需要配 allowNullValues=true 或用 unless 控制。

缓存三大问题

Cache Three Major Problems
🔴 缓存穿透
查不存在的数据
每次都穿透到 DB
✅ 布隆过滤器 / 缓存空值
🟠 缓存击穿
热点 key 过期瞬间
大量请求打到 DB
✅ 互斥锁 / 永不过期
🟣 缓存雪崩
大量 key 同时过期
DB 瞬间被压垮
✅ 随机 TTL / 多级缓存

上面图解了三大问题的现象,下面给出完整的代码级解决方案。每个方案对应一个方法,思路是:穿透→缓存空值+布隆过滤器;击穿→互斥锁只让一个线程回源;雪崩→随机 TTL 避免同时过期。这是面试中被要求"手写代码"的高频题。

// 缓存穿透:缓存空值 + 布隆过滤器
// 场景:攻击者用不存在的ID疯狂请求,缓存没有→每次打DB
// 思路:查不到也缓存一个空值标记(短TTL),下次同样的请求直接返回null
public User getUserSafe(Long id) {
    String key = "user:" + id;
    Object cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        if ("NULL".equals(cached)) return null;  // 命中空值标记,直接返回
        return (User) cached;                     // 命中正常缓存
    }
    // 缓存没有,查DB
    User user = userMapper.findById(id);
    if (user == null) {
        // 查不到也缓存(防止穿透),但TTL设短一点(5分钟)
        redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
    } else {
        redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
    }
    return user;
}

// 缓存击穿:互斥锁,只让一个线程回源查DB
// 场景:热点key过期瞬间,大量请求同时打到DB
// 思路:用Redis分布式锁,只放一个线程查DB并回填缓存,其他线程等着读缓存
public User getUserWithLock(Long id) {
    String key = "user:" + id;
    User user = (User) redisTemplate.opsForValue().get(key);
    if (user != null) return user;  // 缓存命中,直接返回

    String lockKey = "lock:user:" + id;
    try {
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(locked)) {
            // 抢到锁,查DB并回填缓存
            user = userMapper.findById(id);
            redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
            return user;
        } else {
            // 没抢到锁,等其他线程回填完,重试读缓存
            Thread.sleep(50);
            user = (User) redisTemplate.opsForValue().get(key);
            return user != null ? user : userMapper.findById(id);  // 兜底
        }
    } finally {
        redisTemplate.delete(lockKey);
    }
}

// 缓存雪崩:随机TTL,避免大量key同时过期
// 基础TTL + 随机偏移量,让过期时间分散开
int ttl = 1800 + new Random().nextInt(300);  // 30min + 0~5min随机
redisTemplate.opsForValue().set(key, user, ttl, TimeUnit.SECONDS);
三个方案的关键点

缓存空值:查不到也缓存一个 "NULL" 字符串,设置短 TTL(5分钟),防止攻击者用不存在的 ID 疯狂请求。
互斥锁:用 Redis SETNX 实现,拿到锁的线程回源查 DB 并回填缓存,其他线程等待 50ms 后重试读缓存。注意:不用 synchronized(它是 JVM 级锁,分布式环境无效),必须用 Redis 分布式锁。
随机 TTL:基础 TTL 上加一个随机值(如 30min + 0~5min),让 key 的过期时间分散开,避免同一时刻大量 key 集体过期。

缓存与数据库一致性

🔎 经典问题:先更新 DB 还是先删缓存?
方案异常情况一致性
先删缓存,再更新 DB删缓存成功,更新 DB 前有线程读到旧数据并写回缓存❌ 可能不一致
先更新 DB,再删缓存(推荐)删缓存失败(可接受,下次读会回源)✅ 最终一致
延迟双删更新 DB → 删缓存 → sleep 500ms → 再删一次✅ 更强一致

下面是推荐方案的完整代码问题:更新数据后,缓存还留着旧数据,读到的就是脏数据。为什么是"先更新 DB 再删缓存"而不是"先删缓存再更新 DB":如果先删缓存,在 DB 更新完成前有其他线程来读,会读到旧 DB 数据并回填缓存,导致缓存一直是旧的。先更新 DB 再删缓存,即使删缓存失败,下次读也会 miss 回源拿到最新数据(最终一致)。进阶:延迟双删 — 更新后再延迟 500ms 删一次,彻底解决并发读写窗口问题。

// ✅ 推荐方案: Cache Aside Pattern (先 DB 后 Cache)
@Transactional
public void updateUser(User user) {
    userMapper.update(user);    // 1. 先更新 DB
    redisTemplate.delete("user:" + user.getId());  // 2. 再删缓存
}

// ✅ 进阶: 延迟双删 (解决并发读旧数据写回缓存的问题)
@Transactional
public void updateUserSafe(User user) {
    userMapper.update(user);
    redisTemplate.delete("user:" + user.getId());
    // 提交事务后延迟再删一次 (用消息队列或 ScheduledExecutor)
    scheduledExecutor.schedule(() -> {
        redisTemplate.delete("user:" + user.getId());
    }, 500, TimeUnit.MILLISECONDS);
}
延迟双删为什么有效?

并发场景:线程 A 更新 DB,同时线程 B 在读旧数据。A 删缓存后,B 把旧数据写回缓存。A 延迟 500ms 再删一次,此时 B 的写入已经完成,第二次删除把脏数据清掉。
为什么是 500ms:要大于一次"读 DB + 写缓存"的耗时(通常 10~50ms),留足余量。可以用消息队列实现延迟删除,比 ScheduledExecutor 更可靠(不怕应用重启)。

4消息队列集成

异步解耦、削峰填谷、最终一致性

Spring Boot + RabbitMQ

下面是一个完整的 RabbitMQ 生产级实现,包含三个角色:配置类(定义 Exchange/Queue/Binding)、生产者(发消息)、消费者(收消息)。关键设计:① 队列配死信队列(DLX)— 处理失败的消息不丢,进死信队列人工排查;② 消费者手动 ACK(acknowledge-mode=manual)— 处理成功才 ACK,失败 NACK 进死信;③ 幂等性检查 — 用 Redis SETNX 记录 msgId,防止重复消费。这段代码涵盖了 MQ 面试的几乎所有考点。

// 配置
@Configuration
public class RabbitConfig {
    public static final String EXCHANGE = "order.exchange";
    public static final String QUEUE = "order.queue";
    public static final String ROUTING_KEY = "order.create";

    @Bean
    public DirectExchange exchange() {
        return new DirectExchange(EXCHANGE, true, false);
    }

    @Bean
    public Queue queue() {
        return QueueBuilder.durable(QUEUE)
            .withArgument("x-message-ttl", 60000)       // 消息 TTL 60s
            .withArgument("x-dead-letter-exchange", "dlx.exchange")  // 死信队列
            .build();
    }

    @Bean
    public Binding binding() {
        return BindingBuilder.bind(queue()).to(exchange()).with(ROUTING_KEY);
    }
}

// 生产者
@Service
public class OrderProducer {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendOrder(OrderMessage msg) {
        rabbitTemplate.convertAndSend(
            RabbitConfig.EXCHANGE,
            RabbitConfig.ROUTING_KEY,
            msg,
            message -> {
                message.getMessageProperties().setMessageId(UUID.randomUUID().toString());
                return message;
            }
        );
    }
}

// 消费者 (手动 ACK + 幂等性)
@Component
@RabbitListener(queues = RabbitConfig.QUEUE)
public class OrderConsumer {

    @Autowired
    private OrderService orderService;
    @Autowired
    private StringRedisTemplate redisTemplate;

    @RabbitHandler
    public void handle(OrderMessage msg, Channel channel, Message message) throws IOException {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        String msgId = message.getMessageProperties().getMessageId();

        // ① 幂等性检查: Redis SETNX
        Boolean isNew = redisTemplate.opsForValue()
            .setIfAbsent("msg:processed:" + msgId, "1", 24, TimeUnit.HOURS);
        if (Boolean.FALSE.equals(isNew)) {
            channel.basicAck(deliveryTag, false);  // 已处理, 直接 ACK
            return;
        }

        try {
            // ② 业务处理
            orderService.processOrder(msg);
            // ③ 手动 ACK
            channel.basicAck(deliveryTag, false);
        } catch (Exception e) {
            // ④ 处理失败: 拒绝并 requeue (或进入死信队列)
            channel.basicNack(deliveryTag, false, false);
            redisTemplate.delete("msg:processed:" + msgId);  // 回滚幂等标记
        }
    }
}
消费者代码的 5 个步骤(面试要能背)

① 幂等检查:SETNX msgId,已处理过的直接 ACK 跳过(防重复消费)。
② 业务处理:调用 Service 完成实际逻辑。
③ 成功 ACK:basicAck 告诉 RabbitMQ 消费成功,消息从队列删除。
④ 失败 NACK:basicNack(deliveryTag, false, false) — 第三个参数 false 表示不重新入队,消息进死信队列。
⑤ 回滚幂等标记:处理失败时删除 Redis 中的 msgId,让重试时能重新处理。

Spring Boot + Kafka

Kafka 生产级使用需要关注三个核心:消息不丢(生产端acks+消费端手动ack)、消息不重复(消费端幂等)、异常不阻塞(死信队列)。

配置文件

# application.yml
spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all                    # 等所有副本确认才认为发送成功
      retries: 3                   # 发送失败自动重试3次
      properties:
        enable.idempotence: true   # 开启幂等生产者(防网络重试导致重复)
    consumer:
      group-id: order-consumer-group
      auto-offset-reset: earliest  # 没有offset记录时从最早开始消费
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      enable-auto-commit: false    # 关闭自动提交,改为代码里手动提交
      properties:
        spring.json.trusted.packages: "com.example.dto"  # 信任DTO所在包,否则反序列化报错
    listener:
      ack-mode: manual             # 手动提交模式(代码里调acknowledge()才提交offset)
      concurrency: 3               # 3个消费者线程并行消费

生产者

orderId 作为消息 key → 同一订单的消息 hash 到同一 partition → 保证同一订单的消息顺序消费

@Slf4j
@Service
public class KafkaOrderProducer {

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public void send(OrderEvent event) {
        kafkaTemplate.send("order-events", event.getOrderId().toString(), event)
            .addCallback(
                result -> log.info("发送成功: partition={}, offset={}",
                    result.getRecordMetadata().partition(),
                    result.getRecordMetadata().offset()),
                ex -> log.error("发送失败: {}", event, ex)
            );
    }
}

消费者(幂等 + 手动ack)

Kafka 保证「至少一次」投递,意味着同一条消息可能被消费多次。消费端必须做幂等处理

@Slf4j
@Component
public class KafkaOrderConsumer {

    @Autowired
    private OrderService orderService;
    @Autowired
    private StringRedisTemplate redisTemplate;

    @KafkaListener(topics = "order-events", groupId = "order-group")
    public void handle(OrderEvent event, Acknowledgment ack) {
        try {
            // 第1步:幂等检查 —— 用消息唯一ID在Redis标记,已处理过就跳过
            String dedupKey = "kafka:msg:done:" + event.getMsgId();
            Boolean isNew = redisTemplate.opsForValue()
                .setIfAbsent(dedupKey, "1", 24, TimeUnit.HOURS);
            if (Boolean.FALSE.equals(isNew)) {
                log.warn("消息已处理过,跳过: msgId={}", event.getMsgId());
                ack.acknowledge();  // 跳过也要提交offset,否则会反复重投
                return;
            }

            // 第2步:执行业务逻辑
            orderService.process(event);

            // 第3步:业务成功,手动提交offset
            ack.acknowledge();

        } catch (Exception e) {
            log.error("处理失败: msgId={}", event.getMsgId(), e);
            // 删除幂等标记,让重试时能重新处理
            redisTemplate.delete("kafka:msg:done:" + event.getMsgId());
            throw e;  // 抛出异常,由DefaultErrorHandler接管重试
        }
    }
}

错误处理 + 死信队列

消费失败的消息不能一直重试卡住分区。重试 3 次仍失败 → 发到死信 topic(原topic名加 .DLT),由人工或定时任务处理。

@Configuration
public class KafkaErrorConfig {

    @Bean
    public DefaultErrorHandler kafkaErrorHandler(
            KafkaTemplate<Object, Object> template) {
        // 死信发布器:失败消息发到 原topic名.DLT
        DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(
            template,
            (record, ex) -> new TopicPartition(
                record.topic() + ".DLT", record.partition())
        );
        // 重试3次,间隔1秒,仍失败则发到死信队列
        DefaultErrorHandler handler = new DefaultErrorHandler(recoverer,
            new FixedBackOff(1000L, 3));
        // 反序列化异常不重试(重试也没用,直接进死信)
        handler.addNotRetryableExceptions(DeserializationException.class);
        return handler;
    }
}
完整消费链路

正常流程:生产者发消息(key=orderId)→ 按 key hash 进 partition → 消费者幂等检查 → 处理业务 → ack 提交 offset → 完成。
异常流程:消费者抛异常 → 删除幂等标记 → DefaultErrorHandler 捕获 → 间隔1秒重试 → 3次仍失败 → 发到 order-events.DLT → 原消息 ack(不阻塞分区)。
顺序性保证:同一 orderId 的消息进同一 partition,单线程消费 → 同一订单的处理顺序严格一致。

幂等性设计

方案原理适用场景
唯一索引DB UNIQUE 约束新增操作(防重)
乐观锁version 字段 + UPDATE WHERE version=?更新操作
Redis SETNXsetIfAbsent + 过期时间通用(MQ 消费去重)
状态机只允许合法的状态转换订单状态流转
Token 机制前端获取 token,后端验证后删除表单提交防重
🚨 生产案例:MQ 消费不幂等导致重复扣款

症状:MQ 消费者重试时,用户被重复扣款。

原因:消费成功但 ACK 失败 → MQ 重新投递 → 重复执行扣款逻辑。

解决:消费前用 Redis SETNX 检查 msgId,已处理过的直接跳过。

5RESTful API 设计

好的 API 设计 = 客户端不需要看文档也能猜到接口

RESTful 规范

HTTP 方法语义示例幂等
GET查询GET /api/orders/123
POST创建POST /api/orders
PUT全量更新PUT /api/orders/123
PATCH部分更新PATCH /api/orders/123
DELETE删除DELETE /api/orders/123

下面是一个规范的 RESTful Controller 完整示例,包含增删改查全套接口。设计思路:URL 用名词(/api/orders),HTTP 方法表达操作语义(GET 查/POST 增/PUT 改/DELETE 删),所有接口统一返回 Result<T> 包装类(code + message + data),让前端解析逻辑一致。关键点:分页查询用 @RequestParam 传 page/size,路径变量用 @PathVariable,请求体用 @RequestBody + @Valid 触发参数校验。

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Autowired
    private OrderService orderService;

    // GET /api/orders/{id} - 查询单个
    @GetMapping("/{id}")
    public Result<OrderVO> getOrder(@PathVariable Long id) {
        return Result.success(orderService.getById(id));
    }

    // GET /api/orders?page=1&size=20 - 分页查询
    @GetMapping
    public Result<PageVO<OrderVO>> listOrders(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(required = false) String status) {
        return Result.success(orderService.list(page, size, status));
    }

    // POST /api/orders - 创建
    @PostMapping
    public Result<OrderVO> createOrder(@Valid @RequestBody CreateOrderDTO dto) {
        return Result.success(orderService.create(dto));
    }

    // PUT /api/orders/{id} - 全量更新
    @PutMapping("/{id}")
    public Result<OrderVO> updateOrder(
            @PathVariable Long id,
            @Valid @RequestBody UpdateOrderDTO dto) {
        return Result.success(orderService.update(id, dto));
    }

    // DELETE /api/orders/{id} - 删除 (软删除)
    @DeleteMapping("/{id}")
    public Result<Void> deleteOrder(@PathVariable Long id) {
        orderService.delete(id);
        return Result.success();
    }
}

// 统一响应格式
@Data
public class Result<T> {
    private int code;      // 业务状态码: 200=成功, 400=参数错误, 500=系统错误
    private String message;
    private T data;
    private long timestamp;

    public static <T> Result<T> success(T data) {
        Result<T> r = new Result<>();
        r.code = 200;
        r.message = "success";
        r.data = data;
        r.timestamp = System.currentTimeMillis();
        return r;
    }

    public static <T> Result<T> success() {
        return success(null);
    }

    public static <T> Result<T> fail(int code, String message) {
        Result<T> r = new Result<>();
        r.code = code;
        r.message = message;
        r.timestamp = System.currentTimeMillis();
        return r;
    }
}
为什么用 Result<T> 统一包装?

不用 HTTP 状态码表达业务结果——HTTP 状态码表达"请求是否到达服务器",业务结果用 Result.code 表达(200 成功 / 400 参数错 / 401 未登录 / 500 系统错)。前端只需一套解析逻辑:if (res.code === 200) { /* 用 res.data */ }。这个 Result 类在全局异常处理器中也会用到,所有异常都会被包装成 Result 返回。

参数校验(JSR-380)

// DTO 定义校验规则
@Data
public class CreateOrderDTO {
    @NotNull(message = "用户ID不能为空")
    @Min(value = 1, message = "用户ID必须大于0")
    private Long userId;

    @NotEmpty(message = "商品列表不能为空")
    @Valid  // 级联校验列表中的元素
    private List<@Valid OrderItemDTO> items;

    @DecimalMin(value = "0.01", message = "金额必须大于0")
    @Digits(integer = 10, fraction = 2, message = "金额格式错误")
    private BigDecimal amount;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")
    private String phone;

    @Size(max = 200, message = "备注不能超过200字")
    private String remark;
}

// Controller 中用 @Valid 触发校验
@PostMapping
public Result<OrderVO> createOrder(@Valid @RequestBody CreateOrderDTO dto, 
                                      BindingResult errors) {
    if (errors.hasErrors()) {
        String msg = errors.getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining("; "));
        throw new BusinessException(400, msg);
    }
    return Result.success(orderService.create(dto));
}

全局异常处理

// ① 统一异常基类
public class BusinessException extends RuntimeException {
    private final int code;
    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }
    public int getCode() { return code; }
}

// ② 全局异常处理器
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 业务异常
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusiness(BusinessException e) {
        log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    // 参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Void> handleValidation(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .collect(Collectors.joining("; "));
        return Result.fail(400, msg);
    }

    // 非法参数
    @ExceptionHandler(IllegalArgumentException.class)
    public Result<Void> handleIllegal(IllegalArgumentException e) {
        return Result.fail(400, e.getMessage());
    }

    // 兜底: 未知异常
    @ExceptionHandler(Exception.class)
    public Result<Void> handleUnknown(Exception e) {
        log.error("系统异常", e);
        return Result.fail(500, "系统繁忙,请稍后重试");
    }
}

6定时任务

单机用 @Scheduled,集群用 XXL-JOB / Quartz

@Scheduled 基础用法

@Component
@EnableScheduling  // 启动类上加
public class ScheduledTasks {

    // 固定频率: 每5秒执行 (不管上次是否完成)
    @Scheduled(fixedRate = 5000)
    public void heartbeat() {
        log.info("Heartbeat at {}", LocalDateTime.now());
    }

    // 固定延迟: 上次执行完成后等5秒再执行
    @Scheduled(fixedDelay = 5000)
    public void syncData() {
        log.info("Sync data...");
    }

    // Cron 表达式: 每天凌晨 2:30 执行
    @Scheduled(cron = "0 30 2 * * ?")
    public void dailyReport() {
        reportService.generateDailyReport();
    }

    // ⚠️ @Scheduled 默认单线程! 多个任务串行执行
    // 解决: 配置 TaskScheduler
    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);
        scheduler.setThreadNamePrefix("scheduled-");
        return scheduler;
    }
}
@Scheduled 集群环境注意事项

3 个 Pod 同时部署,定时任务会在每个 Pod 上都执行一次。解决方案:

  1. Redis 分布式锁:执行前 tryLock,抢到锁才执行
  2. XXL-JOB:集中调度,只分发到一个执行器
  3. K8s CronJob:只在指定 Pod 上运行
// Redis 分布式锁解决集群重复执行
@Scheduled(cron = "0 30 2 * * ?")
public void dailyReport() {
    String lockKey = "lock:daily-report";
    try {
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 2, TimeUnit.HOURS);
        if (Boolean.TRUE.equals(locked)) {
            reportService.generateDailyReport();
        }
    } finally {
        redisTemplate.delete(lockKey);
    }
}

XXL-JOB 分布式任务调度

// XXL-JOB Handler 示例
@Component
public class XxlJobHandler {

    @Autowired
    private ReportService reportService;

    // 基本模式
    @XxlJob("dailyReport")
    public void dailyReport() {
        XxlJobHelper.log("开始生成日报...");
        reportService.generateDailyReport();
        XxlJobHelper.handleSuccess("日报生成成功");
    }

    // 分片广播: 大数据量分片处理
    // 3个执行器, 每个处理 1/3 的数据
    @XxlJob("shardingDataSync")
    public void shardingDataSync() {
        int shardIndex = XxlJobHelper.getShardIndex();  // 0, 1, 2
        int shardTotal = XxlJobHelper.getShardTotal();  // 3

        // 每个执行器只处理自己分片的数据
        // 例如: user_id % shardTotal == shardIndex
        List<User> users = userMapper.findShard(shardIndex, shardTotal);
        for (User user : users) {
            syncService.sync(user);
        }
    }
}

7单元测试

好的测试 = 代码质量的保障

Spring Boot 测试分三个层级,从快到慢:纯单元测试(不启动容器,用 Mock 模拟依赖,毫秒级)→ 集成测试(启动完整 Spring 容器,测 Controller 到 DB 的全链路,秒级)→ 数据层测试(只测 Mapper/Repository,用内嵌数据库,不污染真实 DB)。下面给出三种测试的完整代码模板,面试中常被问到"你怎么写测试"。

// ① 纯单元测试 (不需要 Spring 容器)
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserMapper userMapper;

    @Mock
    private RedisTemplate<String, Object> redisTemplate;

    @InjectMocks
    private UserService userService;

    @Test
    void shouldReturnUserWhenIdExists() {
        // Given
        Long id = 1L;
        User mockUser = new User(id, "Alice");
        when(userMapper.findById(id)).thenReturn(mockUser);

        // When
        User result = userService.getUserById(id);

        // Then
        assertNotNull(result);
        assertEquals("Alice", result.getName());
        verify(userMapper, times(1)).findById(id);
    }
}

// ② Spring Boot 集成测试 (启动容器)
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldReturnOrderWhenIdExists() throws Exception {
        mockMvc.perform(get("/api/orders/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.code").value(200))
            .andExpect(jsonPath("$.data.id").value(1));
    }

    @Test
    void shouldCreateOrderSuccessfully() throws Exception {
        String json = "{\"userId\":1,\"amount\":99.99}";
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.code").value(200));
    }
}

// ③ 数据层测试 (使用内嵌数据库, 不影响真实 DB)
@DataJpaTest  // 或 @MybatisTest
@Transactional
class OrderMapperTest {

    @Autowired
    private OrderMapper orderMapper;

    @Test
    void shouldInsertAndFind() {
        Order order = new Order();
        order.setUserId(1L);
        order.setAmount(new BigDecimal("100.00"));
        order.setStatus("PENDING");

        orderMapper.insert(order);
        assertNotNull(order.getId());

        Order found = orderMapper.findById(order.getId());
        assertEquals("PENDING", found.getStatus());
    }
}
三种测试怎么选?

纯单元测试(@ExtendWith(MockitoExtension.class)):测 Service 业务逻辑。用 @Mock 模拟 Mapper 行为,不连真实数据库。Given(准备数据)→ When(调用方法)→ Then(断言结果)。速度极快(ms 级),占测试总量 70%。
集成测试(@SpringBootTest + MockMvc):测 Controller 到 DB 的完整链路。MockMvc 模拟 HTTP 请求,不启动真实服务器,但会启动 Spring 容器。用来验证"接口能不能通"。
数据层测试(@DataJpaTest / @MybatisTest):用 H2 内嵌数据库或 Testcontainers(启动真实 Docker 容器)。生产推荐 Testcontainers——H2 和 MySQL 语法有差异,可能测试通过但生产报错。

🔒 测试金字塔
层级占比工具速度
Unit Test(单元测试)70%JUnit 5 + Mockito🔥 极快(ms)
Integration Test(集成测试)20%@SpringBootTest + Testcontainers🕔 中等(s)
E2E Test(端到端测试)10%RestAssured / Selenium🐎 慢(s~min)

8面试题精选 30 道

覆盖数据层、缓存、消息队列、API 设计

Q1.Easy HikariCP 为什么快?
答案框架:① 字节码优化(用 Javassist 生成代理,避免反射);② ConcurrentBag 无锁连接获取(CAS + ThreadLocal + CopyOnWriteArrayList);③ FastList 替代 ArrayList(无范围检查);④ 精简代码(~130KB jar,代码量少 bug 少);⑤ Socket FastPath(绕过 JDK 缓冲)。
Q2.Medium 数据库连接池大小应该设多少?
答案框架:不是越大越好。公式:connections = ((core_count * 2) + effective_spindle_count)。实际经验:20-50 个连接通常是最优区间。连接太多会导致数据库 CPU 上下文切换开销增大,反而降低 TPS。应该通过压测找到最优值。
🏭 生产连接:监控 HikariPool 的 pending、active、idle 连接数指标。
Q3.Easy MyBatis 中 #{} 和 ${} 的区别?
答案框架#{} 是 PreparedStatement 参数占位符(?),自动防 SQL 注入,预编译。${} 是字符串拼接,有 SQL 注入风险,只用于表名/列名等不能参数化的地方(如动态 ORDER BY 字段)。永远优先用 #{}。
🔄 追问:什么场景必须用 ${}?(动态表名、列名、排序字段)
Q4.Medium MyBatis 的一级缓存和二级缓存是什么?
答案框架:一级缓存(SqlSession 级别,默认开启):同一个 SqlSession 中相同的查询走缓存。Spring 中每个请求一个 SqlSession,一级缓存基本无效。二级缓存(Mapper 级别,需手动开启):跨 SqlSession 共享,但分布式环境下有脏读问题。生产环境通常不用 MyBatis 二级缓存,改用 Redis。
Q5.Medium 什么是 N+1 查询问题?如何解决?
答案框架:查 N 条订单,再循环查每条订单的用户 → 1 + N 次查询。解决:MyBatis 用 <resultMap> + <association>/<collection> 做 JOIN 查询一次性查出;或者先查订单 ID 列表,再批量查用户(IN 查询),最后内存组装。
🏭 生产连接:N+1 查询是最常见的性能问题,10 条数据可能变成 11 次 DB 查询。
Q6.Hard 缓存穿透、缓存击穿、缓存雪崩分别是什么?怎么解决?
答案框架:穿透=查不存在的数据(布隆过滤器/缓存空值);击穿=热点 key 过期瞬间大量请求穿透(互斥锁/永不过期/逻辑过期);雪崩=大量 key 同时过期(随机 TTL/多级缓存/熔断限流)。
🔄 追问:布隆过滤器的原理?(多个 Hash 函数映射到位数组,判断"一定不存在"或"可能存在")
Q7.Medium 缓存和数据库如何保证一致性?
答案框架:推荐 Cache-Aside(先 DB 后 Cache):① 更新 DB → 删除缓存(不是更新缓存,避免并发写覆盖);② 延迟双删解决并发读旧数据问题;③ 最终一致可用 binlog 订阅(Canal)异步更新缓存;④ 强一致用分布式锁(性能差,不推荐)。
Q8.Medium Spring Cache @Cacheable 的原理是什么?
答案框架:基于 AOP 代理。方法被调用时,代理先检查缓存 key 是否存在:存在→直接返回缓存值(不执行方法);不存在→执行方法→返回值写入缓存。@CacheEvict 在方法执行后删除缓存。@CachePut 总是执行方法并更新缓存。
🔄 追问:为什么类内部调用 @Cacheable 会失效?(this 调用不经过代理,同 @Transactional)
Q9.Medium RabbitMQ 的消息如何保证不丢失?
答案框架:三道防线——① 生产者确认(publisher-confirm-type=correlated,消息到达 Exchange 返回 ack);② 持久化(Exchange/Queue/Message 都设 durable=true);③ 消费者手动 ACK(acknowledge-mode=manual,处理成功才 basicAck)。配合死信队列处理失败消息。
🏭 生产连接:金融场景还要求消息内容幂等(防重复消费)。
Q10.Hard Kafka 如何保证消息顺序性?
答案框架:Kafka 只保证同一个 Partition 内有序。保证顺序的方法:① 同一业务 key 的消息发到同一 Partition(key.hashCode() % partitionCount);② 消费者单线程消费一个 Partition;③ 如果多线程消费,按 key hash 分发到同一线程。
🔄 追问:如果 Partition=1 会怎样?(完全有序但无法水平扩展吞吐量)
Q11.Medium 如何保证消息消费的幂等性?
答案框架:① 唯一索引(DB 层兜底);② Redis SETNX 去重(msgId 作为 key,处理前检查);③ 乐观锁(version 字段);④ 状态机(订单状态只能单向流转,重复消息不会改变状态)。
🏭 生产连接:幂等性是 MQ 消费的第一原则,重试是常态。
Q12.Easy RESTful API 的核心原则是什么?
答案框架:① 用 HTTP 方法表达操作语义(GET=查、POST=增、PUT=改、DELETE=删);② 用 URL 表达资源(名词,如 /api/orders/123);③ 用 HTTP 状态码表达结果(200/201/400/404/500);④ 无状态(每次请求携带所有必要信息);⑤ 统一响应格式(code/message/data)。
Q13.Medium @Valid 和 @Validated 的区别?
答案框架:@Valid 是 JSR-303 标准注解,支持嵌套校验(@Valid 在集合元素上触发级联)。@Validated 是 Spring 扩展注解,支持分组校验(指定校验组)和方法级别参数校验。简单场景用 @Valid,需要分组时用 @Validated。
Q14.Medium @RestControllerAdvice 如何实现全局异常处理?
答案框架:@RestControllerAdvice = @ControllerAdvice + @ResponseBody。配合 @ExceptionHandler(异常类型.class) 捕获不同类型异常,返回统一格式的 Result 对象。异常处理顺序:精确匹配优先(先匹配子类异常,再匹配父类)。
🔄 追问:如何处理未知异常?(@ExceptionHandler(Exception.class) 兜底)
Q15.Hard @Scheduled 在集群中为什么会有问题?怎么解决?
答案框架:每个 Pod 都会独立执行 @Scheduled,导致重复执行。解决:① Redis 分布式锁(执行前 SETNX 抢锁);② XXL-JOB/Elastic-Job(集中式调度,只分发到一个执行器);③ K8s CronJob(只在指定 Pod 运行);④ 数据库行锁(SELECT ... FOR UPDATE)。
🏭 生产连接:推荐 XXL-JOB,支持分片广播、失败重试、日志可视化。
Q16.Easy MyBatis 的动态 SQL 有哪些标签?
答案框架:<if>(条件判断)、<choose>/<when>/<otherwise>(switch-case)、<where>(自动处理 AND/OR 前缀)、<set>(自动处理 UPDATE 末尾逗号)、<foreach>(遍历集合,用于 IN/批量操作)、<trim>(自定义前后缀裁剪)、<sql>/<include>(SQL 片段复用)。
Q17.Medium 什么是 Spring Data Redis 的 RedisTemplate 序列化问题?
答案框架:默认 JdkSerializationRedisSerializer 用 Java 序列化,存入 Redis 的 key 会有乱码前缀(\xac\xed\x00\x05...),不易阅读和跨语言使用。推荐配置 Jackson2JsonRedisSerializer 或 GenericJackson2JsonRedisSerializer,key 用 StringRedisSerializer。
🔄 追问:StringRedisTemplate 和 RedisTemplate 的区别?(前者 key/value 都是 String,后者是 Object)
Q18.Hard 如何设计一个分布式锁?
答案框架:Redis 方案:SET key value NX PX 30000 + value=UUID(防误删)+ Lua 脚本删除(原子性检查+删除)。进阶:Redisson RLock(支持可重入、看门狗自动续期、RedLock 算法)。ZooKeeper 方案:创建临时顺序节点,最小节点获得锁(更强一致性但性能差)。
🏭 生产连接:推荐 Redisson,不要手写分布式锁。
Q19.Medium 如何实现接口限流?
答案框架:① 计数器(固定窗口,简单但有临界问题);② 滑动窗口(Sentinel 默认);③ 令牌桶(Guava RateLimiter / Redis + Lua,允许突发);④ 漏桶(Nginx limit_req,匀速)。分布式限流用 Redis + Lua 脚本。
Q20.Medium 什么是 CQRS?Spring Boot 如何实现?
答案框架:CQRS(Command Query Responsibility Segregation)= 读写分离。写操作走主库,读操作走从库 + 缓存。Spring Boot 实现:@Transactional(readOnly=true) 路由到从库(AbstractRoutingDataSource 动态切换),写操作路由到主库。
🔄 追问:读写分离有什么问题?(主从延迟,写完立刻读可能读到旧数据)
Q21.Easy Spring Boot 中如何读取配置文件?
答案框架:① @Value(单值注入);② @ConfigurationProperties(批量映射到 POJO,推荐);③ Environment API(编程式读取);④ @PropertySource(指定额外配置文件)。优先级:命令行参数 > 环境变量 > application.yml > 默认值。
Q22.Hard 如何实现 Spring Boot 应用的分库分表?
答案框架:ShardingSphere(Sharding-JDBC)是主流方案。① 垂直拆分(按业务拆库);② 水平拆分(按 hash/range 拆表);③ 配置分片规则(user_id % 4 → table_0~3);④ 配置读写分离;⑤ 分布式 ID 用雪花算法。注意:跨库 JOIN/事务/分页是分库分表后的核心难题。
🏭 生产连接:数据量不到千万级不要分库分表,先用索引和缓存优化。
Q23.Medium 什么是 Flyway?为什么要用它?
答案框架:Flyway 是数据库版本管理工具(类似 Git 管理 DB Schema)。通过版本化 SQL 脚本(V1__init.sql / V2__add_column.sql),在应用启动时自动执行未应用的迁移。保证多环境(dev/staging/prod)的数据库 Schema 一致性。Liquibase 是类似工具。
🔄 追问:Flyway 和 Liquibase 的区别?(Flyway 用 SQL,Liquibase 用 XML/YAML/JSON 抽象描述)
Q24.Medium Spring Boot Test 有哪些测试注解?
答案框架:@SpringBootTest(完整容器启动);@WebMvcTest(只测 Web 层,不加载 Service/DAO);@DataJpaTest/@MybatisTest(只测数据层,内嵌 DB);@MockBean(替换 Bean 为 Mock);@Testcontainers(启动真实 Docker 容器,如 MySQL/Redis/Kafka)。
🔄 追问:为什么推荐 Testcontainers 而不是 H2 内嵌数据库?(H2 和 MySQL 语法有差异,可能测试通过但生产失败)
Q25.Hard 如何排查 Spring Boot 应用的慢 SQL?
答案框架:① 开启 MySQL 慢查询日志(slow_query_log=ON, long_query_time=1);② Druid 监控面板(/druid/sql.html);③ p6spy(日志打印 SQL 执行时间);④ EXPLAIN 分析执行计划;⑤ Arthas 在线诊断。常见原因:缺索引、深分页、全表扫描、隐式类型转换。
🏭 生产连接:线上必须监控 P99 SQL 耗时,设置慢 SQL 告警。
Q26.Medium Spring Boot 中如何集成 Elasticsearch?
答案框架:Spring Data Elasticsearch 提供 ElasticsearchRestTemplate / ElasticsearchRepository。定义 @Document Entity,继承 ElasticsearchRepository<T, ID> 即可获得 CRUD。复杂查询用 NativeSearchQuery + BoolQueryBuilder。生产推荐直接用 RestHighLevelClient 或新的 Java API Client。
Q27.Medium 如何设计一个好的 API 分页方案?
答案框架:① 传统分页(page+size):简单,但深分页慢(OFFSET 越大越慢);② 游标分页(cursor/seek method):WHERE id > last_id ORDER BY id LIMIT 20,深分页快但不能跳页;③ 大数据量导出用游标(MyBatis Cursor)。响应包含:records、total、page、size、hasMore。
🔄 追问:为什么 OFFSET 1000000 很慢?(要扫描 100万行再丢弃)
Q28.Hard 如何处理 Spring Boot 中的分布式事务?
答案框架:① 2PC/XA(强一致但性能差,不推荐);② TCC(Try-Confirm-Cancel,代码侵入大);③ 本地消息表(可靠消息最终一致,最常用);④ Saga(长事务拆分,每步有补偿);⑤ Seata(阿里开源,支持 AT/TCC/Saga 模式)。微服务中推荐本地消息表 + MQ。
🏭 生产连接:BASE 理论——基本可用、软状态、最终一致。
Q29.Easy Spring Boot 如何配置多数据源?
答案框架:① 定义多个 DataSource @Bean,用 @ConfigurationProperties 绑定不同前缀(spring.datasource.primary / spring.datasource.secondary);② 为每个 DataSource 创建独立的 SqlSessionFactory 和 MapperScan(指定 basePackages);③ 使用 dynamic-datasource-spring-boot-starter 优雅切换(@DS("slave") 注解)。
Q30.Hard 你在实际项目中遇到过哪些数据层性能问题?
答案框架(参考模板)
1. 慢查询:深分页 LIMIT 1000000,20 → 改为游标分页 WHERE id > ? LIMIT 20
2. N+1 查询:循环查用户信息 → 批量 IN 查询 + 内存组装
3. 连接池耗尽:连接泄漏 → 开启 leakDetectionThreshold + try-with-resources
4. 缓存击穿:秒杀商品过期瞬间打崩 DB → 互斥锁 + 永不过期
5. 批量插入慢:逐条 insert → MyBatis foreach 批量 + rewriteBatchedStatements=true

回答技巧:STAR 法则,量化结果("QPS 从 500 提升到 5000")。
🏭 这是开放题,提前准备 2-3 个真实案例。
📚 学习路线建议
阶段内容推荐资源
入门Spring Data JPA / MyBatis 基础Spring Guides
进阶连接池调优、缓存模式、MQ 集成MyBatis 官方文档 / Redis 实战
高级分库分表、分布式事务、性能调优ShardingSphere 文档 / Seata
生产监控告警、慢 SQL 排查、容量规划Druid / Prometheus + Grafana