Spring Cache 多线程环境的线程安全与并发控制
一 核心原则与总体思路
- Spring 缓存抽象不处理并发 ,并发控制由具体的缓存实现承担;例如本地缓存 ConcurrentMapCache 基于 ConcurrentHashMap 是线程安全的,而 Redis 等分布式缓存的并发正确性依赖其客户端与服务端实现。开发时需先明确所用 CacheManager/实现 的并发能力,再决定是否需要额外同步策略。对于读多写少的热点场景,优先选择具备高并发与过期淘汰能力的实现(如 Caffeine/Redis)。
二 读多写少场景的并发控制
- 使用 @Cacheable(sync = true) :同一 key 的并发未命中时,仅让一个线程 执行方法体,其余线程阻塞等待并复用缓存结果,天然防止"缓存击穿"。适用于热点 key 高并发读。示例:
java
@Service
public class ProductService {
// sync=true 仅对未命中加"类 JDK 同步"的互斥,避免并发重建同一 key
@Cacheable(value = "products", key = "#id", sync = true)
public Product getById(Long id) {
// 可能耗时的 DB/远程调用
return productRepository.findById(id).orElse(null);
}
}
- 注意边界:
- sync 只作用于未命中;命中时直接返回缓存,不会加锁。
- 事务与缓存顺序 :若方法带 @Transactional ,默认缓存切面通常在事务提交后执行(Spring Boot 2.1.6+ 已是"缓存优先于事务"的顺序),避免读到未提交的旧数据;若业务需要"先更 DB 再删缓存",可将 @CacheEvict(beforeInvocation = false) 并结合事务边界使用。
三 写路径与数据一致性
- 采用 Cache-Aside(旁路缓存) 模式:读"先缓存后 DB",写"先更 DB,后删缓存 "。删除比更新缓存更安全,能降低并发写导致脏数据的概率;为兜底可给缓存设置合理 TTL。示例:
java
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product getById(Long id) {
return productRepository.findById(id).orElse(null);
}
@Transactional
public Product update(Product p) {
Product saved = productRepository.save(p);
// 事务提交后再删缓存,避免读到未提交数据
// 若使用 Redis,可配置 RedisCacheManager 的序列化与 TTL
cacheEvictService.evictProduct(saved.getId());
return saved;
}
@CacheEvict(value = "products", key = "#id")
public void evictProduct(Long id) {
// 空方法体即可触发缓存删除
}
}
- 极端一致性补充:若业务对一致性要求极高,可在"先更 DB 后删缓存"的基础上引入延迟双删 或消息队列做最终一致矫正。
四 分布式与热点并发的强化手段
- 分布式锁兜底:当热点 key 失效且并发压力大时,使用 Redis 分布式锁(如 Redisson) 保证只有一个实例重建缓存,其他实例等待回填。示例:
java
@Service
public class ProductService {
@Autowired private RedissonClient redisson;
public Product getByIdWithLock(Long id) {
String key = "products:" + id;
Product v = (Product) redisTemplate.opsForValue().get(key);
if (v != null) return v;
RLock lock = redisson.getLock("lock:products:" + id);
try {
lock.lock();
v = (Product) redisTemplate.opsForValue().get(key);
if (v != null) return v;
v = productRepository.findById(id).orElse(null);
if (v == null) {
// 防穿透:短期缓存空值
redisTemplate.opsForValue().set(key, NullValue.INSTANCE, 60, TimeUnit.SECONDS);
} else {
// 防雪崩:TTL 加随机抖动
int ttl = 600 + ThreadLocalRandom.current().nextInt(120);
redisTemplate.opsForValue().set(key, v, ttl, TimeUnit.SECONDS);
}
return v;
} finally {
lock.unlock();
}
}
}
- 防穿透与防雪崩要点:
- 对查询结果为 null 的 key 也缓存一个短期空值 (如 60 秒),避免大量请求穿透到 DB。
- 给缓存 TTL 增加随机抖动 (如基准 10 分钟 ± 2 分钟),避免同一时刻大量 key 同时失效引发雪崩。
🔥 关注公众号【云技纵横】,获取更多 Vue/React/分布式缓存进阶性能优化干货与代码示例!
五 常见坑与排查清单
- AOP 内部调用失效 :同类内方法直接调用带 @Cacheable/@CacheEvict 的方法不会走代理,导致缓存不生效;应通过 ApplicationContext 获取代理 Bean 或拆分为不同 Bean。
- 单例 Bean 的成员变量竞态:Service/Repository 默认单例,若在成员变量上做可变共享状态,需加锁或改为局部变量/ThreadLocal,避免多线程数据污染。
- 空值策略与序列化 :启用 allowNullValues/缓存空值 可缓解穿透,但要确保序列化器能正确处理(如使用 GenericJackson2JsonRedisSerializer 或 StringRedisSerializer + JSON),避免反序列化异常。
- 本地缓存的适用性 :ConcurrentMapCache 线程安全但不支持 TTL/淘汰 ,仅适合开发测试或小规模单机;生产建议 Caffeine/Redis。
- 监控与调优 :开启 DEBUG 日志观察命中/未命中;结合 Actuator /cache 端点或指标看命中率、加载耗时与异常,及时调大热点 key 的 TTL 或优化加载逻辑。