Java 开发注意!本地缓存这 3 个坑,我替你踩过了(附避坑方案)

B站影视 内地电影 2025-10-29 11:58 1

摘要:作为 Java 开发,你是不是觉得本地缓存 “简单好用”,拿来就往项目里塞?前阵子我团队里的新人小王就是这么想的 —— 把 Guava Cache 随手集成到订单服务后,上线 3 天就出了大问题:用户付了钱却查不到订单,排查半天才发现是本地缓存 “数据不一致”

作为 Java 开发,你是不是觉得本地缓存 “简单好用”,拿来就往项目里塞?前阵子我团队里的新人小王就是这么想的 —— 把 Guava Cache 随手集成到订单服务后,上线 3 天就出了大问题:用户付了钱却查不到订单,排查半天才发现是本地缓存 “数据不一致” 搞的鬼。

其实不光小王,我接触过的很多开发同行,包括工作 5 年以上的老程序员,都在本地缓存上栽过跟头。今天就用 “问题 - 背景 - 方案” 的思路,跟你好好聊聊本地缓存那些容易踩的坑,帮你少走弯路。

先别急着说 “我不会犯这种错”,先看看这 3 个场景你有没有遇到过:

第一个坑是 “数据不一致,查不到最新结果”。就像小王遇到的订单问题:用户支付成功后,数据库里的订单状态已经更新为 “已支付”,但本地缓存里还是旧的 “待支付” 状态,导致用户刷新页面时一直显示 “未支付”。当时用户投诉量暴涨,运营同事追着我们改 bug,别提多狼狈了。

第二个坑是 “内存溢出,服务直接崩了”。去年我们做电商大促时,有个商品详情页服务用了本地缓存存商品信息,结果忘了设置缓存过期时间和容量上限。大促期间商品数据越存越多,最后 JVM 内存撑爆,服务直接宕机,重启后又很快崩掉,差点影响大促转化。

第三个坑是 “并发安全问题,缓存里存了脏数据”。我之前接手过一个老项目,本地缓存用的是 HashMap,没加锁。高并发场景下多个线程同时读写,结果缓存里出现了 “半个商品信息”—— 比如商品名称是 A 的,价格却是 B 的,最后只能紧急下线服务清理缓存。

你可能会问:“本地缓存不就是存数据的吗,怎么会有这么多问题?” 其实这些坑不是 “不小心犯的错”,而是没搞懂本地缓存的技术特性导致的。

先说说数据不一致的根源。本地缓存是 “单机存储” 的 —— 每个服务实例都有自己的缓存副本,不像 Redis 那样是集中式存储。比如你部署了 3 个订单服务实例,用户支付后只更新了实例 1 的缓存,实例 2 和 3 的缓存还是旧数据,这时候请求打到实例 2 或 3,就会出现 “查不到最新订单” 的情况。尤其是微服务架构下,服务实例越多,数据不一致的概率越高。

再看内存溢出的原因。本地缓存是存在 JVM 堆内存里的,而 JVM 堆内存是有上限的(比如我们常用的配置是 4G 或 8G)。如果你的缓存不设 “过期时间”(TTL),也不限制 “最大容量”,数据就会一直往堆里存,直到占满内存。这时候 JVM 会触发 Full GC,如果 GC 后内存还是不够,就会抛出 OutOfMemoryError,服务直接崩溃。

最后是并发安全问题的背景。很多开发图方便,用 HashMap 做本地缓存 —— 但 HashMap 本身是线程不安全的,在多线程同时 put 数据时,可能会出现 “链表环” 或 “数据覆盖” 的问题。就算用了 ConcurrentHashMap,虽然能保证线程安全,但如果缓存加载逻辑(比如从数据库查数据再存缓存)没做 “防缓存击穿” 处理,高并发下还是会有多个线程同时查数据库,导致数据库压力骤增。

知道了问题和背景,接下来就是最实用的解决方案 —— 这部分我会结合具体代码示例,你看完就能往项目里套。

针对单机缓存副本不一致的问题,核心思路是 “让缓存跟着数据变,同时加个过期兜底”。

首先是主动更新策略:当数据库数据发生变化时(比如订单状态更新),除了更新数据库,还要主动更新所有服务实例的本地缓存。具体怎么做?可以用 “消息队列广播”—— 比如用 RabbitMQ 或 Kafka 发一条 “订单状态更新” 的消息,所有订单服务实例订阅这个消息,收到消息后更新自己的本地缓存。

代码示例(Spring Boot + RabbitMQ):

// 1. 数据更新后发送消息@Servicepublic class OrderService { @Autowired private RabbitTemplate rabbitTemplate; @Autowired private OrderMapper orderMapper; @Autowired private LoadingCachelocalCache; // Guava Cache public void updateOrderStatus(Long orderId, String status) { // 更新数据库 orderMapper.updateStatus(orderId, status); // 发送缓存更新消息 rabbitTemplate.convertAndSend("cache-update-exchange", "order.status", orderId); // 先更新当前实例的缓存 localCache.put("order:" + orderId, orderMapper.getById(orderId)); }}// 2. 其他实例接收消息并更新缓存@Componentpublic class CacheUpdateListener { @Autowired private LoadingCachelocalCache; @Autowired private OrderMapper orderMapper; @RabbitListener(queues = "order-cache-queue") public void onCacheUpdate(Long orderId) { // 从数据库查最新数据,更新本地缓存 Order latestOrder = orderMapper.getById(orderId); localCache.put("order:" + orderId, latestOrder); }}

其次是加过期时间兜底:就算主动更新偶尔失败(比如消息丢失),也要给缓存设置合理的过期时间(比如订单缓存设 5 分钟),到期后自动清除,下次请求时从数据库重新加载最新数据。用 Guava Cache 的话,配置起来很简单:

// 配置Guava Cache,设置过期时间和最大容量@Beanpublic LoadingCacheorderLocalCache { return CacheBuilder.newBuilder .expireAfterWrite(5, TimeUnit.MINUTES) // 写入后5分钟过期 .maximumSize(10000) // 最大容量1万条 .build(new CacheLoader{ @Override public Order load(String key) throws Exception { // 缓存不存在时,从数据库加载 Long orderId = Long.parseLong(key.split(":")[1]); return orderMapper.getById(orderId); } });}

要避免内存溢出,关键是 “不让缓存无限制存数据”,这就需要三个配置配合:

第一,设置最大容量(maximumSize):根据服务的堆内存大小,估算出缓存能存多少条数据。比如堆内存给 4G,每条缓存数据平均 1KB,那最大容量可以设 20 万条左右(留一部分内存给业务逻辑)。

第二,设置过期时间(expireAfterWrite/expireAfterAccess):根据数据的 “新鲜度需求” 选合适的过期策略。比如商品详情数据变化少,可以设 1 小时过期;用户会话数据变化快,设 30 分钟过期。

第三,设置淘汰策略:当缓存达到最大容量时,需要淘汰旧数据。主流的本地缓存框架(如 Guava Cache、Caffeine)默认是 “LRU 策略”(最近最少使用),也就是淘汰最久没被访问的数据,这个策略适合大多数场景。

这里推荐用Caffeine 缓存代替 Guava Cache—— 因为 Caffeine 的性能更好,还支持 “异步加载” 和 “刷新策略”。比如配置 Caffeine 缓存:

// Caffeine缓存配置,支持LRU淘汰和异步加载@Beanpublic AsyncLoadingCacheproductLocalCache { return Caffeine.newBuilder .maximumSize // 最大容量20万条 .expireAfterWrite(1, TimeUnit.HOURS) // 1小时过期 .evictionListener((key, value, cause) -> { // 缓存淘汰时可以打印日志,方便排查 log.info("缓存淘汰:key={}, 原因={}", key, cause); }) .buildAsync(new AsyncCacheLoader { @Override public CompletableFuture

asyncLoad(String key, Executor executor) { // 异步加载数据,不阻塞主线程 Long productId = Long.parseLong(key.split(":")[1]); return CompletableFuture.supplyAsync( -> productMapper.getById(productId), executor); } });}

要避免并发安全问题,需要从 “缓存实现” 和 “加载逻辑” 两方面入手:

首先,选线程安全的缓存实现:别用 HashMap,优先用 ConcurrentHashMap、Guava Cache 或 Caffeine—— 这些框架内部已经做了线程安全处理。比如 Caffeine 的所有方法都是线程安全的,多线程同时读写也不会出问题。

其次,处理 “缓存击穿” 问题:缓存击穿是指 “缓存里没有数据,多个线程同时查数据库” 的情况。解决办法是 “加锁加载”,比如用 Guava Cache 的get方法(内部有锁),或自己加 Reentrantlock。

代码示例(防缓存击穿处理):

@Servicepublic class ProductService { @Autowired private AsyncLoadingCacheproductLocalCache; // 用ConcurrentHashMap存锁,避免一个key一把锁导致锁过多 private final ConcurrentHashMaplockMap = new ConcurrentHashMap; public Product getProductById(Long productId) throws ExecutionException, InterruptedException { String cacheKey = "product:" + productId; try { // 先查缓存 return productLocalCache.get(cacheKey).get; } catch (Exception e) { // 缓存加载失败(比如数据库查不到),加锁重试 ReentrantLock lock = lockMap.computeIfAbsent(cacheKey, k -> new ReentrantLock); try { lock.lock; // 加锁后再查一次缓存,避免其他线程已经加载成功 Product product = productLocalCache.get(cacheKey).get; if (product != null) { return product; } // 缓存还是没有,查数据库 Product dbProduct = productMapper.getById(productId); if (dbProduct != null) { // 存到缓存 productLocalCache.put(cacheKey, CompletableFuture.completedFuture(dbProduct)); return dbProduct; } else { // 数据库也没有,存个null到缓存(设短过期时间),避免一直查数据库 productLocalCache.put(cacheKey, CompletableFuture.completedFuture(null)); return null; } } finally { lock.unlock; lockMap.remove(cacheKey); // 释放锁 } } }}

聊了这么多,最后跟你总结 3 个核心原则,帮你用好本地缓存:

“不裸奔”:别用 HashMap 做缓存,别不设过期时间和容量 —— 一定要用成熟的框架(Caffeine 优先),并配置好过期时间、最大容量和淘汰策略,避免内存溢出。“数据要对齐”:本地缓存是单机的,一定要处理数据不一致问题 —— 要么用 “消息队列主动更新”,要么用 “短过期时间兜底”,尤其是订单、支付这类核心数据,千万别忽略一致性。“防并发坑”:高并发场景下,一定要做防缓存击穿处理 —— 用线程安全的缓存框架,加锁加载数据,避免数据库被打垮。

其实本地缓存是个 “双刃剑”,用好了能让接口响应时间从几百毫秒降到几十毫秒,用不好就会出各种线上问题。今天分享的这些方案,都是我和团队踩过坑后总结出来的,你可以直接用到项目里。

如果你在本地缓存使用中遇到过其他问题,或者有更好的解决方案,欢迎在评论区留言 —— 咱们一起交流,帮更多 Java 开发避坑!

来源:从程序员到架构师

相关推荐