本文属于《从入门到放弃》系列，但这次我要劝你别放弃，因为不写单元测试的代码，就像没系安全带的汽车——平时感觉挺自由，出事了直接起飞。前言：一个没写单元测试的程序员的一天早上9点，产品经理说："这个需求很简单，就改一行代码。"你信了，改了。下午3点，线上告警疯狂轰炸你的钉钉。晚上11点，你还在公司排查为什么改了A模块，B模块炸了，C模块也顺便炸了。你开始怀疑人生："我明明只改了一行啊？！"这，就是没有单元测试的代价。如果你写了单元测试，这一行代码改完，跑一下测试，3秒钟就知道有没有搞砸。而不是等到线上用户帮你测试。用户测试的成本，那可是真金白银的赔偿啊兄弟！一、单元测试是什么？不就是写代码测代码吗？没错，单元测试就是用代码测试代码。但很多人对单元测试有误解，以为就是启动整个Spring容器，然后调一下接口看看返回值对不对。那叫集成测试，不叫单元测试。来看一张图，理解一下测试金字塔：graph TB subgraph 测试金字塔 E2E["🔺 E2E测试数量最少 | 成本最高 | 速度最慢"] INT["🔶 集成测试数量适中 | 成本中等 | 速度中等"] UNIT["🟢 单元测试数量最多 | 成本最低 | 速度最快"] end E2E --> INT INT --> UNIT style E2E fill:#ff6b6b,stroke:#333,stroke-width:2px,color:#fff style INT fill:#feca57,stroke:#333,stroke-width:2px,color:#333 style UNIT fill:#1dd1a1,stroke:#333,stroke-width:2px,color:#fff单元测试的特点：特性说明快毫秒级执行，跑几百个测试也就几秒钟独立不依赖数据库、Redis、MQ等外部服务可重复跑一万次结果都一样自动化CI/CD流水线自动跑单元测试测的是"单元"，在Java里，通常指的是一个方法，最多是一个类。二、Spring Boot测试全家桶Spring Boot贴心地给我们准备了一套测试工具，加个依赖就能用： org.springframework.boot spring-boot-starter-test test 这一个依赖，包含了以下豪华套餐：graph LR subgraph spring-boot-starter-test JUnit5["JUnit 5测试框架本体"] Mockito["MockitoMock神器"] AssertJ["AssertJ断言增强"] Hamcrest["Hamcrest匹配器"] JSONPath["JSONPathJSON断言"] Spring["Spring TestSpring测试支持"] end style JUnit5 fill:#6c5ce7,color:#fff style Mockito fill:#00b894,color:#fff style AssertJ fill:#0984e3,color:#fff style Hamcrest fill:#fdcb6e,color:#333 style JSONPath fill:#e17055,color:#fff style Spring fill:#00cec9,color:#fff2.1 JUnit 5 基础写法先来个最简单的例子热热身：import org.junit.jupiter.api.Test; import org.junit.jupiter.api.DisplayName; import static org.junit.jupiter.api.Assertions.*; class CalculatorTest { @Test @DisplayName("1 + 1 应该等于 2，如果不等于，那数学就完蛋了") void testAdd() { Calculator calculator = new Calculator(); int result = calculator.add(1, 1); assertEquals(2, result); } @Test @DisplayName("除以0应该抛异常，不然宇宙会爆炸") void testDivideByZero() { Calculator calculator = new Calculator(); assertThrows(ArithmeticException.class, () -> { calculator.divide(10, 0); }); } }JUnit 5常用注解速查表：注解作用使用场景@Test标记测试方法每个测试方法都要加@DisplayName给测试起个人话的名字让测试报告更易读@BeforeEach每个测试前执行初始化测试数据@AfterEach每个测试后执行清理资源@BeforeAll所有测试前执行一次重量级初始化@Disabled禁用测试临时跳过某个测试@ParameterizedTest参数化测试同一逻辑不同参数2.2 参数化测试：一次写完，多组数据假设你要测试一个判断成年的方法，难道要写18个测试？不用，参数化测试安排上：import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; class AgeValidatorTest { @ParameterizedTest @DisplayName("成年人判断") @CsvSource({ "17, false", "18, true", "19, true", "100, true", "0, false" }) void testIsAdult(int age, boolean expected) { AgeValidator validator = new AgeValidator(); assertEquals(expected, validator.isAdult(age)); } @ParameterizedTest @DisplayName("这些年龄都应该是成年人") @ValueSource(ints = {18, 25, 30, 65, 100}) void testAdultAges(int age) { AgeValidator validator = new AgeValidator(); assertTrue(validator.isAdult(age)); } }三、Mock：假装有对象在真实项目中，你的Service不可能是孤独的，它依赖Repository、依赖其他Service、依赖第三方API...但单元测试要"隔离"，怎么办？Mock！用假的对象替代真的依赖。这就像拍电影，男主角要从大楼跳下去，你不能真的让他跳吧？找个替身（Mock）啊！sequenceDiagram participant Test as 测试代码 participant Service as UserService participant Mock as Mock的Repository participant Real as 真实数据库 Note over Test,Real: 单元测试时 Test->>Service: 调用 getUserById(1) Service->>Mock: 调用 findById(1) Mock-->>Service: 返回预设的假数据 Service-->>Test: 返回结果 Note over Real: 数据库根本没启动！3.1 Mockito基础用法import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.Optional; import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MockitoExtension.class) // 启用Mockito class UserServiceTest { @Mock // 这是个假的！ private UserRepository userRepository; @Mock private EmailService emailService; @InjectMocks // 把上面的假货注入到这里 private UserService userService; @Test @DisplayName("根据ID查用户 - 用户存在") void testGetUserById_Found() { // Given: 准备数据，告诉Mock对象该怎么表演 User fakeUser = new User(1L, "张三", "zhangsan@test.com"); when(userRepository.findById(1L)).thenReturn(Optional.of(fakeUser)); // When: 执行被测方法 User result = userService.getUserById(1L); // Then: 验证结果 assertNotNull(result); assertEquals("张三", result.getName()); assertEquals("zhangsan@test.com", result.getEmail()); // 验证Mock对象的方法确实被调用了 verify(userRepository, times(1)).findById(1L); } @Test @DisplayName("根据ID查用户 - 用户不存在，应该抛异常") void testGetUserById_NotFound() { // Given when(userRepository.findById(999L)).thenReturn(Optional.empty()); // When & Then assertThrows(UserNotFoundException.class, () -> { userService.getUserById(999L); }); } }3.2 Mock的各种骚操作@Test void testMockitoFeatures() { // 1. 返回固定值 when(userRepository.findById(1L)).thenReturn(Optional.of(user)); // 2. 抛异常 when(userRepository.findById(-1L)).thenThrow(new RuntimeException("ID不能为负数！")); // 3. 根据参数动态返回（Answer） when(userRepository.findById(anyLong())).thenAnswer(invocation -> { Long id = invocation.getArgument(0); if (id > 0) { return Optional.of(new User(id, "用户" + id, "user" + id + "@test.com")); } return Optional.empty(); }); // 4. 连续调用返回不同值 when(userRepository.count()) .thenReturn(1L) // 第一次调用返回1 .thenReturn(2L) // 第二次调用返回2 .thenReturn(3L); // 第三次及以后返回3 // 5. void方法抛异常 doThrow(new RuntimeException("发送失败")) .when(emailService).sendEmail(anyString(), anyString()); // 6. 验证方法调用次数 verify(userRepository, times(1)).findById(1L); verify(userRepository, never()).deleteById(anyLong()); verify(emailService, atLeast(2)).sendEmail(anyString(), anyString()); // 7. 捕获参数 ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class); verify(userRepository).save(captor.capture()); User savedUser = captor.getValue(); assertEquals("张三", savedUser.getName()); }3.3 @MockBean vs @Mock这俩长得像，但用的地方不一样：graph TB subgraph 纯单元测试 M["@Mock + @InjectMocks"] M1["不启动Spring容器"] M2["速度快，毫秒级"] M3["测试Service层逻辑"] end subgraph Spring集成测试 MB["@MockBean"] MB1["需要启动Spring容器"] MB2["速度慢，秒级"] MB3["测试Controller或需要Spring特性时"] end M --> M1 --> M2 --> M3 MB --> MB1 --> MB2 --> MB3 style M fill:#1dd1a1,color:#fff style MB fill:#feca57,color:#333四、Service层测试实战来个实际的例子，假设我们有个订单服务：// 被测试的Service @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final ProductService productService; private final InventoryService inventoryService; private final PaymentService paymentService; @Transactional public Order createOrder(CreateOrderRequest request) { // 1. 检查商品是否存在 Product product = productService.getProductById(request.getProductId()); if (product == null) { throw new ProductNotFoundException("商品不存在"); } // 2. 检查库存 if (!inventoryService.checkStock(request.getProductId(), request.getQuantity())) { throw new InsufficientStockException("库存不足"); } // 3. 计算金额 BigDecimal totalAmount = product.getPrice() .multiply(BigDecimal.valueOf(request.getQuantity())); // 4. 创建订单 Order order = Order.builder() .userId(request.getUserId()) .productId(request.getProductId()) .quantity(request.getQuantity()) .totalAmount(totalAmount) .status(OrderStatus.PENDING) .createTime(LocalDateTime.now()) .build(); return orderRepository.save(order); } }测试代码：@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderRepository orderRepository; @Mock private ProductService productService; @Mock private InventoryService inventoryService; @Mock private PaymentService paymentService; @InjectMocks private OrderService orderService; private CreateOrderRequest validRequest; private Product testProduct; @BeforeEach void setUp() { validRequest = CreateOrderRequest.builder() .userId(1L) .productId(100L) .quantity(2) .build(); testProduct = Product.builder() .id(100L) .name("机械键盘") .price(new BigDecimal("299.00")) .build(); } @Test @DisplayName("创建订单 - 正常流程") void testCreateOrder_Success() { // Given when(productService.getProductById(100L)).thenReturn(testProduct); when(inventoryService.checkStock(100L, 2)).thenReturn(true); when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> { Order order = invocation.getArgument(0); order.setId(1L); // 模拟数据库生成ID return order; }); // When Order result = orderService.createOrder(validRequest); // Then assertNotNull(result); assertEquals(1L, result.getUserId()); assertEquals(100L, result.getProductId()); assertEquals(2, result.getQuantity()); assertEquals(new BigDecimal("598.00"), result.getTotalAmount()); // 299 * 2 assertEquals(OrderStatus.PENDING, result.getStatus()); // 验证调用链 verify(productService).getProductById(100L); verify(inventoryService).checkStock(100L, 2); verify(orderRepository).save(any(Order.class)); } @Test @DisplayName("创建订单 - 商品不存在") void testCreateOrder_ProductNotFound() { // Given when(productService.getProductById(100L)).thenReturn(null); // When & Then ProductNotFoundException exception = assertThrows( ProductNotFoundException.class, () -> orderService.createOrder(validRequest) ); assertEquals("商品不存在", exception.getMessage()); // 验证后续方法没有被调用 verify(inventoryService, never()).checkStock(anyLong(), anyInt()); verify(orderRepository, never()).save(any()); } @Test @DisplayName("创建订单 - 库存不足") void testCreateOrder_InsufficientStock() { // Given when(productService.getProductById(100L)).thenReturn(testProduct); when(inventoryService.checkStock(100L, 2)).thenReturn(false); // When & Then assertThrows(InsufficientStockException.class, () -> { orderService.createOrder(validRequest); }); verify(orderRepository, never()).save(any()); } @Test @DisplayName("创建订单 - 金额计算正确性（多组数据）") @ParameterizedTest @CsvSource({ "1, 299.00, 299.00", "2, 299.00, 598.00", "10, 99.99, 999.90", "100, 0.01, 1.00" }) void testCreateOrder_AmountCalculation(int quantity, String price, String expectedTotal) { // Given testProduct.setPrice(new BigDecimal(price)); validRequest.setQuantity(quantity); when(productService.getProductById(100L)).thenReturn(testProduct); when(inventoryService.checkStock(anyLong(), anyInt())).thenReturn(true); when(orderRepository.save(any(Order.class))).thenAnswer(i -> i.getArgument(0)); // When Order result = orderService.createOrder(validRequest); // Then assertEquals(new BigDecimal(expectedTotal), result.getTotalAmount()); } }五、Controller层测试Controller层测试用MockMvc，可以模拟HTTP请求，但不需要启动真正的服务器：@WebMvcTest(UserController.class) // 只加载Controller层 class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean // 注意这里用@MockBean，因为需要Spring容器 private UserService userService; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("GET /users/{id} - 查询用户成功") void testGetUser_Success() throws Exception { // Given User user = new User(1L, "张三", "zhangsan@test.com"); when(userService.getUserById(1L)).thenReturn(user); // When & Then mockMvc.perform(get("/users/1") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.name").value("张三")) .andExpect(jsonPath("$.email").value("zhangsan@test.com")); } @Test @DisplayName("GET /users/{id} - 用户不存在返回404") void testGetUser_NotFound() throws Exception { // Given when(userService.getUserById(999L)) .thenThrow(new UserNotFoundException("用户不存在")); // When & Then mockMvc.perform(get("/users/999")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value("用户不存在")); } @Test @DisplayName("POST /users - 创建用户") void testCreateUser() throws Exception { // Given CreateUserRequest request = new CreateUserRequest("李四", "lisi@test.com"); User createdUser = new User(2L, "李四", "lisi@test.com"); when(userService.createUser(any(CreateUserRequest.class))).thenReturn(createdUser); // When & Then mockMvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(2)) .andExpect(jsonPath("$.name").value("李四")); } @Test @DisplayName("POST /users - 参数校验失败") void testCreateUser_ValidationFailed() throws Exception { // Given: 空的name CreateUserRequest request = new CreateUserRequest("", "invalid-email"); // When & Then mockMvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } }六、Repository层测试Repository层用@DataJpaTest，它会自动配置内存数据库（H2）：@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 如果要用真实数据库配置 class UserRepositoryTest { @Autowired private UserRepository userRepository; @Autowired private TestEntityManager entityManager; @Test @DisplayName("测试自定义查询方法") void testFindByEmail() { // Given User user = User.builder() .name("测试用户") .email("test@example.com") .build(); entityManager.persistAndFlush(user); // When Optional<User> found = userRepository.findByEmail("test@example.com"); // Then assertTrue(found.isPresent()); assertEquals("测试用户", found.get().getName()); } @Test @DisplayName("测试复杂查询") void testFindActiveUsersByNameContaining() { // Given entityManager.persist(new User("张三丰", "zsf@test.com", true)); entityManager.persist(new User("张三", "zs@test.com", true)); entityManager.persist(new User("张三疯", "zsf2@test.com", false)); // 非活跃 entityManager.persist(new User("李四", "ls@test.com", true)); entityManager.flush(); // When List<User> users = userRepository.findByNameContainingAndActiveTrue("张三"); // Then assertEquals(2, users.size()); assertTrue(users.stream().allMatch(User::isActive)); } }七、测试覆盖率：别让你的代码裸奔测试写了多少？够不够？用覆盖率说话。推荐使用JaCoCo： org.jacoco jacoco-maven-plugin 0.8.10 prepare-agent report test report 运行mvn test后，在target/site/jacoco/index.html查看报告。pie showData title 理想的测试覆盖率分布 "行覆盖率 > 80%" : 80 "分支覆盖率 > 70%" : 70 "方法覆盖率 > 90%" : 90覆盖率的真相：80%覆盖率是个不错的目标100%覆盖率是个美丽的谎言（有些代码真的不用测）覆盖率高不代表测试质量高（你可以写一堆不带断言的测试）八、测试最佳实践8.1 命名规范// ❌ 不好的命名 @Test void test1() { } @Test void testUser() { } // ✅ 好的命名：被测方法_场景_期望结果 @Test void getUserById_WhenUserExists_ShouldReturnUser() { } @Test void createOrder_WhenStockInsufficient_ShouldThrowException() { } // ✅ 或者用中文@DisplayName @Test @DisplayName("创建订单 - 库存不足时应抛出异常") void testCreateOrderWithInsufficientStock() { }8.2 测试结构：Given-When-Then@Test void testTransferMoney() { // Given: 准备测试数据和Mock行为 Account from = new Account(1L, new BigDecimal("1000")); Account to = new Account(2L, new BigDecimal("500")); when(accountRepository.findById(1L)).thenReturn(Optional.of(from)); when(accountRepository.findById(2L)).thenReturn(Optional.of(to)); // When: 执行被测试的方法 accountService.transfer(1L, 2L, new BigDecimal("300")); // Then: 验证结果 assertEquals(new BigDecimal("700"), from.getBalance()); assertEquals(new BigDecimal("800"), to.getBalance()); verify(accountRepository, times(2)).save(any(Account.class)); }8.3 一个测试只测一件事// ❌ 测太多东西 @Test void testUserService() { // 测创建 User created = userService.create(request); assertNotNull(created); // 测查询 User found = userService.getById(created.getId()); assertEquals(created, found); // 测更新 userService.update(created.getId(), updateRequest); // ... // 测删除 userService.delete(created.getId()); // ... } // ✅ 每个测试专注一个场景 @Test void create_ValidRequest_Success() { } @Test void create_DuplicateEmail_ThrowException() { } @Test void getById_Exists_ReturnUser() { } @Test void getById_NotExists_ThrowException() { }8.4 测试数据构建器模式当测试数据复杂时，用Builder模式：public class UserTestDataBuilder { private Long id = 1L; private String name = "默认用户"; private String email = "default@test.com"; private boolean active = true; public static UserTestDataBuilder aUser() { return new UserTestDataBuilder(); } public UserTestDataBuilder withId(Long id) { this.id = id; return this; } public UserTestDataBuilder withName(String name) { this.name = name; return this; } public UserTestDataBuilder inactive() { this.active = false; return this; } public User build() { return new User(id, name, email, active); } } // 使用 User activeUser = aUser().withName("张三").build(); User inactiveUser = aUser().withName("李四").inactive().build();九、常见问题QAQ1: 测试代码也需要维护吗？是的！测试代码是一等公民。烂测试比没测试更可怕，因为它会给你虚假的安全感。Q2: 什么代码不需要测试？getter/setter（除非有特殊逻辑）简单的委托方法配置类纯粹的数据传输对象（DTO）Q3: Mock太多是不是有问题？如果一个类需要Mock 5个以上依赖，可能说明这个类职责太多了，考虑重构。Q4: 测试要不要测私有方法？不要。测试应该通过公有方法来覆盖私有方法。如果私有方法很难被覆盖，可能需要重构。十、总结graph LR A[写代码] --> B{有测试吗?} B -->|没有| C[裸奔中...] B -->|有| D[安心改代码] C --> E[改代码就心慌] D --> F[CI自动验证] E --> G[线上出Bug] F --> H[早点下班] G --> I[半夜改Bug] style C fill:#ff6b6b,color:#fff style D fill:#1dd1a1,color:#fff style G fill:#ff6b6b,color:#fff style H fill:#1dd1a1,color:#fff style I fill:#ff6b6b,color:#fff写单元测试确实会让你多花一些时间，但这是投资，不是成本。它会在未来某个深夜，当你需要重构一段祖传代码时，默默地保护你。它会在CI流水线上，帮你拦住那些"改了一行就爆炸"的提交。它会让你成为一个更好的程序员，因为可测试的代码，往往也是设计良好的代码。所以，从今天开始，让我们一起告别"能跑就行"，拥抱"敢改就改"！
前言：一个惨痛的教训话说那年，我写了一个"用户信息查询"接口，代码简洁优雅，自我感觉良好。上线后 QPS 一路飙升，数据库 CPU 直接拉满，DBA 小哥深夜给我打电话，语气中透着一丝疲惫和杀意。"兄弟，你这接口一秒钟查同一个用户几百次，图啥呢？"那一刻，我顿悟了：缓存，是程序员对数据库最基本的尊重。一、@Cacheable 是什么？@Cacheable 是 Spring Cache 提供的声明式缓存注解，属于"我不想写重复代码"系列的终极解决方案。它的核心思想很朴素：第一次调用方法 → 执行方法，结果存缓存后续相同参数调用 → 直接返回缓存，方法体压根不执行简单说就是："这活我干过了，答案在这，别烦我。"二、快速上手2.1 添加依赖 org.springframework.boot spring-boot-starter-cache org.springframework.boot spring-boot-starter-data-redis 2.2 开启缓存功能在启动类上加一个注解，仪式感要有：@SpringBootApplication @EnableCaching // 就这一行，缓存世界的大门向你敞开 public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }2.3 使用 @Cacheable@Service public class UserService { @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { // 这行日志是关键，用来验证缓存是否生效 System.out.println(">>> 真的去查数据库了，userId: " + userId); return userRepository.findById(userId).orElse(null); } }测试一下：userService.getUserById(1L); // 控制台输出日志，查了数据库 userService.getUserById(1L); // 静悄悄，缓存命中 userService.getUserById(1L); // 依然静悄悄 userService.getUserById(2L); // 输出日志，新用户要查库恭喜，你的数据库松了一口气。三、核心属性详解@Cacheable 的属性不多，但每个都值得细品。3.1 value / cacheNames —— 缓存的"收纳盒"@Cacheable(value = "userCache") @Cacheable(cacheNames = "userCache") // 两种写法等价 @Cacheable(value = {"cache1", "cache2"}) // 可以同时放多个盒子这玩意就是给缓存分类，就像你家的收纳盒：袜子放一个、内裤放一个，别混了。3.2 key —— 缓存的"门牌号"默认情况下，Spring 会用方法的所有参数生成 key。但很多时候我们需要自定义：// 使用 SpEL 表达式 @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { ... } // 多参数组合 @Cacheable(value = "userCache", key = "#userId + '_' + #type") public User getUserByIdAndType(Long userId, String type) { ... } // 使用对象的属性 @Cacheable(value = "userCache", key = "#user.id") public User updateUser(User user) { ... } // 使用方法名 + 参数（防止不同方法 key 冲突） @Cacheable(value = "userCache", key = "'getUser_' + #userId") public User getUserById(Long userId) { ... }踩坑预警：key 冲突是缓存的头号杀手。想象一下，getUserById(1L) 和 getOrderById(1L) 用了同一个 key，返回数据直接串台，这 bug 够你查三天。3.3 condition —— 有条件地缓存不是所有数据都值得缓存，比如查出来是 null 的情况：// 只有 userId > 0 才缓存 @Cacheable(value = "userCache", key = "#userId", condition = "#userId > 0") public User getUserById(Long userId) { ... } // 参数长度大于 3 才缓存 @Cacheable(value = "userCache", condition = "#name.length() > 3") public User getUserByName(String name) { ... }condition 在方法执行前判断，决定要不要走缓存逻辑。3.4 unless —— 有条件地不缓存和 condition 相反，unless 在方法执行后判断，可以根据返回值决定是否缓存：// 结果为 null 不缓存 @Cacheable(value = "userCache", key = "#userId", unless = "#result == null") public User getUserById(Long userId) { ... } // 结果状态异常不缓存 @Cacheable(value = "userCache", unless = "#result.status != 1") public User getUserById(Long userId) { ... }划重点：condition 是"要不要查缓存"，unless 是"查完了要不要存"。3.5 keyGenerator —— 自定义 key 生成器当 SpEL 表达式满足不了你的时候，可以自己写生成器：@Configuration public class CacheConfig { @Bean("myKeyGenerator") public KeyGenerator keyGenerator() { return (target, method, params) -> { StringBuilder sb = new StringBuilder(); sb.append(target.getClass().getSimpleName()); sb.append(":"); sb.append(method.getName()); sb.append(":"); for (Object param : params) { sb.append(param.toString()).append("_"); } return sb.toString(); }; } } // 使用 @Cacheable(value = "userCache", keyGenerator = "myKeyGenerator") public User getUserById(Long userId) { ... }四、先搞清楚：查一次数据要多久？在聊缓存实现之前，我们先看一组数据，感受一下不同存储介质的速度差异：存储类型单次查询耗时相对速度备注本地缓存（Caffeine）几十纳秒 ~ 几微秒⚡⚡⚡⚡⚡内存直接读取，快到离谱Redis（本机）0.1ms ~ 0.5ms⚡⚡⚡⚡走网络 + 序列化Redis（同机房）0.5ms ~ 2ms⚡⚡⚡网络延迟是大头Redis（跨机房）2ms ~ 10ms⚡⚡跨机房网络延迟MySQL（简单查询）1ms ~ 10ms⚡⚡走网络 + SQL 解析 + 磁盘 IOMySQL（复杂查询）10ms ~ 100ms+⚡关联、排序、没走索引...划重点：本地缓存比 Redis 快 100~1000 倍，比 MySQL 快 1000~10000 倍。这意味着什么？如果一个接口 QPS 是 1000，每次查 MySQL 耗时 10ms，光数据库查询就要消耗 10 秒的 CPU 时间换成本地缓存，同样的请求只需要 0.01 秒所以，能用本地缓存就别用 Redis，能用 Redis 就别查数据库。五、本地缓存：Caffeine 才是真的快很多同学一提缓存就想到 Redis，但其实对于单机场景或者读多写少的热点数据，本地缓存才是性能天花板。5.1 为什么选 Caffeine？Spring Boot 2.x 之后，官方钦定 Caffeine 作为本地缓存的首选实现（之前是 Guava Cache）。Caffeine 号称"地表最强本地缓存"，采用 W-TinyLFU 淘汰算法，命中率吊打一众竞品。5.2 引入依赖 com.github.ben-manes.caffeine caffeine 5.3 配置方式一：简单配置（application.yml）spring: cache: type: caffeine caffeine: spec: maximumSize=10000,expireAfterWrite=5mspec 参数说明：maximumSize=10000：最多缓存 10000 条数据expireAfterWrite=5m：写入后 5 分钟过期expireAfterAccess=5m：最后一次访问后 5 分钟过期5.4 配置方式二：精细控制（配置类）@Configuration @EnableCaching public class CaffeineCacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .initialCapacity(100) // 初始容量 .maximumSize(10000) // 最大条目数 .expireAfterWrite(5, TimeUnit.MINUTES) // 写入后过期时间 .recordStats()); // 开启统计（生产环境可关闭） return cacheManager; } }5.5 不同缓存不同配置用户信息缓存 30 分钟，配置信息缓存 24 小时？安排：@Configuration @EnableCaching public class CaffeineCacheConfig { @Bean public CacheManager cacheManager() { SimpleCacheManager cacheManager = new SimpleCacheManager(); List<CaffeineCache> caches = new ArrayList<>(); // 用户缓存：最多 5000 条，30 分钟过期 caches.add(new CaffeineCache("userCache", Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(30, TimeUnit.MINUTES) .build())); // 配置缓存：最多 1000 条，24 小时过期 caches.add(new CaffeineCache("configCache", Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(24, TimeUnit.HOURS) .build())); // 热点数据：最多 100 条，按访问时间过期 caches.add(new CaffeineCache("hotDataCache", Caffeine.newBuilder() .maximumSize(100) .expireAfterAccess(10, TimeUnit.MINUTES) .build())); cacheManager.setCaches(caches); return cacheManager; } }5.6 本地缓存的局限性本地缓存虽然快，但也有硬伤：问题说明多实例数据不一致A 机器更新了，B 机器还是旧数据内存有限缓存太多会 OOM重启即失效应用重启，缓存清空适用场景：单机应用数据量小、更新不频繁的配置信息可以容忍短时间不一致的场景极致性能要求的热点数据（配合 Redis 做二级缓存）六、配合 Redis 使用光有注解没有存储实现，就像光有嘴没有胃。下面配置 Redis 作为缓存后端：6.1 配置文件spring: redis: host: localhost port: 6379 password: your_password # 没密码就删掉这行 database: 0 cache: type: redis redis: time-to-live: 3600000 # 缓存过期时间，单位毫秒（1小时） key-prefix: "app:" # key 前缀 use-key-prefix: true cache-null-values: false # 是否缓存 null 值6.2 自定义 Redis 缓存配置想要更精细的控制？上配置类：@Configuration @EnableCaching public class RedisCacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { // 默认配置 RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1)) // 默认过期时间 1 小时 .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); // 不缓存 null // 针对不同缓存名称设置不同配置 Map<String, RedisCacheConfiguration> configMap = new HashMap<>(); configMap.put("userCache", defaultConfig.entryTtl(Duration.ofMinutes(30))); // 用户缓存 30 分钟 configMap.put("configCache", defaultConfig.entryTtl(Duration.ofHours(24))); // 配置缓存 24 小时 return RedisCacheManager.builder(factory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(configMap) .build(); } }七、缓存三兄弟：@Cacheable、@CachePut、@CacheEvict注解作用适用场景@Cacheable查缓存，没有就执行方法并缓存结果查询操作@CachePut无论如何都执行方法，并更新缓存更新操作@CacheEvict删除缓存删除/更新操作7.1 @CachePut：更新必备@CachePut(value = "userCache", key = "#user.id") public User updateUser(User user) { // 一定会执行，然后把返回值更新到缓存 return userRepository.save(user); }7.2 @CacheEvict：清理门户// 删除单个缓存 @CacheEvict(value = "userCache", key = "#userId") public void deleteUser(Long userId) { userRepository.deleteById(userId); } // 清空整个缓存（慎用！） @CacheEvict(value = "userCache", allEntries = true) public void clearAllUserCache() { // 核弹级操作 } // 方法执行前就删除缓存 @CacheEvict(value = "userCache", key = "#userId", beforeInvocation = true) public void deleteUser(Long userId) { // 即使方法抛异常，缓存也已经删了 }7.3 @Caching：组合技一个方法需要操作多个缓存？用 @Caching 打包：@Caching( cacheable = { @Cacheable(value = "userCache", key = "#userId") }, evict = { @CacheEvict(value = "userListCache", allEntries = true) } ) public User getUserById(Long userId) { ... }八、血泪踩坑实录坑 1：同类方法调用，缓存不生效@Service public class UserService { @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { ... } public User getUser(Long userId) { // 这样调用，缓存不生效！ return this.getUserById(userId); } }原因：Spring Cache 基于 AOP 代理，this.xxx() 是直接调用，不走代理。解决方案：注入自己（有点魔幻但有效）使用 AopContext.currentProxy()拆分到不同的 Service@Service public class UserService { @Autowired @Lazy private UserService self; // 注入自己 @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { ... } public User getUser(Long userId) { return self.getUserById(userId); // 通过代理调用 } }坑 2：缓存对象没实现 Serializable用 Redis 缓存时，对象必须可序列化：public class User implements Serializable { private static final long serialVersionUID = 1L; // ... }不然你会收获一个 SerializationException，并且怀疑人生。坑 3：缓存穿透查一个不存在的数据，每次都穿透到数据库：// 错误示范：null 不缓存 @Cacheable(value = "userCache", key = "#userId", unless = "#result == null") public User getUserById(Long userId) { return userRepository.findById(userId).orElse(null); }解决方案：缓存空对象或使用布隆过滤器// 缓存空对象，但设置较短过期时间 @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { User user = userRepository.findById(userId).orElse(null); if (user == null) { // 返回一个空对象标记，而不是 null return User.EMPTY; } return user; }坑 4：缓存雪崩大量缓存同时过期，请求全打到数据库。解决方案：过期时间加随机值@Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { return RedisCacheManager.builder(factory) .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1).plusMinutes(new Random().nextInt(30)))) .build(); }九、最佳实践总结缓存命名要有意义：userCache、orderCache，别用 cache1、cache2key 设计要唯一：加上业务前缀，避免冲突合理设置过期时间：热数据短一点，冷数据长一点null 值要处理：要么缓存空对象，要么用布隆过滤器更新和删除要同步：数据变了，缓存也要跟着变监控要到位：缓存命中率、过期淘汰数都要关注结语缓存用得好，下班回家早。@Cacheable 是 Spring 送给我们的礼物，用好它，你的数据库会感谢你，你的 DBA 会感谢你，你的用户也会感谢你（虽然他们并不知道发生了什么）。记住：每一次无意义的数据库查询，都是对服务器资源的犯罪。如果这篇文章帮你少写了几行代码，少挨了几次 DBA 的骂，记得点个赞。毕竟，独乐乐不如众乐乐，大家一起优雅，才是真的优雅。