本节摘要:不是每次请求都值得跑一趟数据库。缓存抽象让热数据在内存里挡掉大部分读;事件机制把"必须做但不必须现在做"的工作挪到请求路径之外。本节给两套机制各配一个可运行示例,重点讲缓存的失效策略与事件的异步化、事务绑定。
商品详情这类读多写少的数据,加两个注解就能让第二次访问不再触库:
@Service public class ProductService { @Cacheable("products") // 命中即返回,未命中执行方法并缓存 public Product detail(Long id) { return productRepo.findById(id).orElseThrow(); } @CacheEvict(key = "#id") // 数据变更时逐出 public void updatePrice(Long id, BigDecimal price) { productRepo.updatePrice(id, price); } }
启用只需在配置类加一个注解 @EnableCaching。运行机理与 AOP 同源:@Cacheable 是一个切面,方法调用先被拦截,查缓存、未命中才放行到目标方法——第 4 章的代理知识在这里直接复用,也因此继承同样的限制:自调用不走缓存,非 public 方法不生效。
缓存的三种常用模式与适用面:
| 模式 | 写法组合 | 特点 | 适用 |
|---|---|---|---|
| 旁路缓存 | 可缓存加逐出 | 简单可控,依赖逐出及时 | 绝大多数场景 |
| 写入即刷新 | 可缓存加放入 | 读永远新鲜,写放大 | 强一致要求 |
| 多级逐出 | 变更方法逐出多组 | 维护成本升高 | 一数据多视图 |
默认的内存缓存适合单实例与开发期;多实例部署下各节点内存独立,逐出互不知晓,需要换集中式缓存实现(Redis 一类)——好在抽象的价值就在这里:注解不动,底层实现换配置即可。
下单成功后要发通知、加积分、更新推荐。这些事与"下单"无因果关系上的同步必要,写在下单方法里是三倍耗时与三倍故障面。拆法是发布领域事件:
public record OrderCreatedEvent(Long orderId, Long userId) {} @Service public class CheckoutService { private final ApplicationEventPublisher publisher; public void checkout(OrderCmd cmd) { var order = save(cmd); publisher.publishEvent(new OrderCreatedEvent(order.getId(), cmd.userId())); // 请求到此即可返回,后续工作由监听器接手 } } @Component public class OrderCreatedListeners { @Async @EventListener public void addPoints(OrderCreatedEvent e) { pointsService.grant(e.userId(), 10); } }
监听器与发布方解耦:发布者不知道谁在听,加一个后续动作等于加一个监听器,主流程一行不改。事件默认同步执行(就在发布调用处展开,共享事务);标 @Async 后挪到独立线程池,两段各走各的事务。这里有个微妙取舍值得写明:同步监听共享发布方事务,监听器抛异常会连累主流程回滚;异步监听独立,主流程不受影响但失效要另想办法补偿。通知类动作选异步(失败可重发),账务类动作选同步(必须与主业务同生共死)。

⚠️ 事件不是免费午餐:监听器散落各处后,"下单之后到底发生了什么"需要靠检索事件类型来拼图。团队里约定事件命名与登记清单,别让暗流变成考古现场。
背景:商品详情是读多写少的典型,大促前要确认缓存到底省了多少数据库往返。操作三步。第一步,给详情方法加缓存注解并启用缓存(前文代码)。第二步,压测前先预热:循环请求一百个热门商品各一次,把它们填进缓存。第三步,用压测工具打详情接口一分钟,同时观察数据库连接池的活跃数与语句日志。
结果:加缓存前,同等压力下活跃连接贴近池上限,语句日志滚屏;加缓存后,活跃连接几乎归零,日志安静。解读:缓存切面挡在方法之前,未命中才放行——对于命中率为百分之九十几的热点数据,数据库压力近似只剩零头。变式一:压测中途调用改价接口再打同一个商品,验证逐出生效(下一条读请求重新触库一次并回填)。变式二:把缓存换成带过期时间的配置,观察"过期风暴"——同一热点键过期瞬间大量请求同时穿透到数据库,生产上用"回源加锁"或"逻辑过期"防它,这是缓存抽象之上的进阶话题。
配套还有一个值得做的对比实验:把积分监听器临时改成同步(去掉异步注解),对比下单接口的响应耗时——同步时通知、积分的耗时全部叠加在请求路径上,异步时几乎消失。两种模式各量一次,5.3 节"拆写"的价值就成了具体毫秒数而不是概念。
其一,缓存键的设计要在一开始就定规矩:键里必须包含影响结果的全部维度(用户、版本、区域),漏一维就会出现"A 用户看到 B 的数据"这类严重事故;键规范一旦上线再改,就要全员清缓存,代价极高。其二,事件的载荷只放标识不放实体:监听器需要什么自己回查,事件对象保持瘦小且向后兼容——往事件里加字段安全,改老字段的语义危险。两条红线都是"抽象好用但边界要立"的实例,绕道机制的价值建立在纪律之上。
两道收束题:同样的缓存注解为什么在自调用时不生效;积分与通知两类后续动作分别该用同步还是异步监听,判断依据是什么。答完这两问,绕道机制的选择逻辑就清楚了。