Spring Cache 多线程环境的线程安全与并发控制

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/缓存空值 可缓解穿透,但要确保序列化器能正确处理(如使用 GenericJackson2JsonRedisSerializerStringRedisSerializer + JSON),避免反序列化异常。
  • 本地缓存的适用性ConcurrentMapCache 线程安全但不支持 TTL/淘汰 ,仅适合开发测试或小规模单机;生产建议 Caffeine/Redis
  • 监控与调优 :开启 DEBUG 日志观察命中/未命中;结合 Actuator /cache 端点或指标看命中率、加载耗时与异常,及时调大热点 key 的 TTL 或优化加载逻辑。
相关推荐
程序员-周李斌2 小时前
transmittable-thread-local[线程池跨线程值传递]
java·开发语言·算法·散列表
enjoy编程2 小时前
Spring Boot 4 如何使用Sentinel进行限流-II【基于Sentinel Spring MVC Adapter实现】
spring boot·spring·sentinel·服务限流·webmvc·servlet 6.x
亓才孓2 小时前
【homework1】彩票奖金问题(苛刻条件变松弛条件需要避免条件重复)
java·开发语言
Thanwind2 小时前
RBAC介绍以及如何设计一个简易且高可用的RBAC1的鉴权系统
java·架构
MX_93592 小时前
Spring的命名空间
java·后端·spring
没有bug.的程序员2 小时前
微服务网关:从“必选项”到“思考题”的深度剖析
java·开发语言·网络·jvm·微服务·云原生·架构
YJlio2 小时前
DiskView 学习笔记(13.3):用扇区视图看磁盘——热点盘块、碎片与健康排查
java·笔记·学习
通往曙光的路上2 小时前
GitGit
java
又是忙碌的一天2 小时前
Myvatis 动态查询及关联查询
java·数据库·mybatis