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 配置参数详解
| 参数 | 默认值 | 说明 |
|---|---|---|
| maximumPoolSize | 10 | 最大连接数。不是越大越好! |
| minimumIdle | = maximumPoolSize | 最小空闲连接数 |
| connectionTimeout | 30s | 从池中获取连接的最大等待时间 |
| idleTimeout | 10min | 空闲连接超时(超过 minIdle 才回收) |
| maxLifetime | 30min | 连接最大存活时间(建议比 DB 的 wait_timeout 小 30s) |
| leakDetectionThreshold | 0(禁用) | 连接泄漏检测阈值(建议设为 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 的代码。
公式:Tn × (Cm - 1) + 1 = 最优连接数(Tn=并发线程数,Cm=单线程同时持有的连接数)。
实际经验:20-50 个连接通常是最优区间。连接太多反而会降低性能——数据库需要花更多时间做上下文切换和锁管理。
PostgreSQL 官方公式:connections = ((core_count * 2) + effective_spindle_count)
生产案例:连接池耗尽导致服务雪崩
症状:高峰期大量请求报 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,它帮你做参数映射和结果映射,SQL 完全可控。
JPA(Hibernate)像自动驾驶——你只定义 Entity,它自动生成 SQL。简单 CRUD 快,但复杂查询难以优化。
国内主流选择:MyBatis + MyBatis-Plus(国内 80%+ 的 Spring Boot 项目用 MyBatis)。
MyBatis vs JPA 对比
| 维度 | MyBatis | JPA (Hibernate) |
|---|---|---|
| SQL 控制 | 完全可控,手写 SQL | 自动生成,复杂场景用 JPQL/Native SQL |
| 学习曲线 | 低(会 SQL 就会) | 高(需理解 Entity/关系映射/HQL) |
| 性能优化 | 容易(SQL 可见,好调优) | 难(N+1 问题、懒加载陷阱多) |
| 动态 SQL | XML <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 基础上提供:通用 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 缓存
缓存是高并发系统的第一道防线
数据库查询就像去档案柜(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
}
1. 类内部调用不生效:同类中 A 方法直接 this 调用带 @Cacheable 的 B 方法,不经过 AOP 代理,缓存不会生效。解决:拆到两个类,或注入自己(@Autowired UserService self)再调用。
2. 建议显式指定 key:多参数时不指定 key 会拼出奇怪的 key,建议始终用 SpEL 显式指定:key = "#id"。
3. 默认不缓存 null 值:查不到的数据每次都打 DB(缓存穿透)。需要配 allowNullValues=true 或用 unless 控制。
缓存三大问题
查不存在的数据
每次都穿透到 DB
热点 key 过期瞬间
大量请求打到 DB
大量 key 同时过期
DB 瞬间被压垮
上面图解了三大问题的现象,下面给出完整的代码级解决方案。每个方案对应一个方法,思路是:穿透→缓存空值+布隆过滤器;击穿→互斥锁只让一个线程回源;雪崩→随机 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 → 删缓存 → 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); // 回滚幂等标记
}
}
}
① 幂等检查: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 SETNX | setIfAbsent + 过期时间 | 通用(MQ 消费去重) |
| 状态机 | 只允许合法的状态转换 | 订单状态流转 |
| Token 机制 | 前端获取 token,后端验证后删除 | 表单提交防重 |
症状: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;
}
}
不用 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;
}
}
3 个 Pod 同时部署,定时任务会在每个 Pod 上都执行一次。解决方案:
- Redis 分布式锁:执行前 tryLock,抢到锁才执行
- XXL-JOB:集中调度,只分发到一个执行器
- 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 设计
connections = ((core_count * 2) + effective_spindle_count)。实际经验:20-50 个连接通常是最优区间。连接太多会导致数据库 CPU 上下文切换开销增大,反而降低 TPS。应该通过压测找到最优值。#{} 是 PreparedStatement 参数占位符(?),自动防 SQL 注入,预编译。${} 是字符串拼接,有 SQL 注入风险,只用于表名/列名等不能参数化的地方(如动态 ORDER BY 字段)。永远优先用 #{}。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")。
| 阶段 | 内容 | 推荐资源 |
|---|---|---|
| 入门 | Spring Data JPA / MyBatis 基础 | Spring Guides |
| 进阶 | 连接池调优、缓存模式、MQ 集成 | MyBatis 官方文档 / Redis 实战 |
| 高级 | 分库分表、分布式事务、性能调优 | ShardingSphere 文档 / Seata |
| 生产 | 监控告警、慢 SQL 排查、容量规划 | Druid / Prometheus + Grafana |