当前位置: 代码网 > it编程>编程语言>Java > SpringBoot实现缓存与数据库双写策略的详细代码

SpringBoot实现缓存与数据库双写策略的详细代码

2026年04月22日 Java 我要评论
引言在springboot企业开发中,为了提升系统性能,我们都会给高频查询接口加上缓存(比如redis、caffeine),把热点数据缓存起来,减少数据库查询压力,让接口响应速度从几十毫秒提升到几毫秒

引言

在springboot企业开发中,为了提升系统性能,我们都会给高频查询接口加上缓存(比如redis、caffeine),把热点数据缓存起来,减少数据库查询压力,让接口响应速度从几十毫秒提升到几毫秒。

但缓存的引入,也带来了一个核心难题——缓存一致性:当数据库中的数据发生修改(新增、更新、删除)时,缓存中的数据如果没有及时同步,就会出现“缓存数据与数据库数据不一致”的问题,导致用户查询到旧数据、错误数据,引发业务异常。

举个真实场景:用户修改了自己的昵称,数据库中的昵称已经更新,但缓存中还是旧昵称,用户再次查询个人信息时,看到的还是旧昵称,体验极差;更严重的是,订单状态更新后缓存未同步,可能导致运营人员误判订单状态,造成损失。

很多同学一开始处理缓存,只懂“查询时查缓存,没有就查数据库再存缓存”(即cache-aside策略),但忽略了数据修改时的缓存同步,导致缓存一致性问题频发。

一、缓存一致性的核心问题

想要解决缓存一致性问题,首先要明白:问题的根源不是“缓存”或“数据库”本身,而是数据修改时,缓存与数据库的操作顺序、同步时机,以及“并发场景下的竞态条件”。

1. 双写顺序与并发竞态

当数据发生修改时,我们需要同时操作“数据库”和“缓存”,但这两个操作无法做到“原子性”(要么同时成功,要么同时失败),因此会出现两种核心问题:

  • 双写顺序错误:比如先更新缓存、再更新数据库,若更新数据库失败,缓存中是新数据,数据库中是旧数据,导致不一致;
  • 并发竞态问题:比如一个更新操作(改数据库+删缓存)和一个查询操作(查缓存+查数据库)并发执行,查询操作可能在更新操作删除缓存后、更新数据库前,查询到旧数据并重新写入缓存,导致缓存一直是旧数据。

2. 缓存一致性的目标

我们追求的缓存一致性,不是“绝对一致性”(成本极高,没必要),而是最终一致性:在合理的时间范围内(比如1秒内),缓存数据能同步为数据库的最新数据,满足业务需求即可。

比如用户修改昵称后,100毫秒内缓存同步更新,用户再次查询就能看到新昵称,这种“最终一致性”完全能满足绝大多数业务场景,且实现成本低、性能影响小。

面试必背总结:缓存一致性的核心是“解决双写顺序和并发竞态问题”,企业级落地优先追求“最终一致性”,而非“绝对一致性”,平衡性能与数据准确性。

二、三大主流双写策略

目前业界解决缓存一致性的双写策略主要有3种,各有优缺点和适用场景,没有最优方案,只有最适合业务的方案,下面逐一拆解,包含实现代码、细节说明,直接复制就能用。

前置准备:springboot 2.7.x + redis + spring cache(简化缓存操作),核心依赖如下(已包含spring cache和redis整合):

<!-- springboot web -->
<dependency>
    <groupid>org.springframework.boot</groupid>
    <artifactid>spring-boot-starter-web</artifactid>
</dependency><!-- spring cache 核心依赖 -->
<dependency>
    <groupid>org.springframework.boot</groupid>
    <artifactid>spring-boot-starter-cache</artifactid>
</dependency>
<!-- redis 依赖(分布式缓存) -->
<dependency>
    <groupid>org.springframework.boot</groupid>
    <artifactid>spring-boot-starter-data-redis</artifactid>
</dependency>
<!-- caffeine 依赖(单机缓存,可选) -->
<dependency>
    <groupid>com.github.ben-manes.caffeine</groupid>
    <artifactid>caffeine</artifactid>
    <version>3.1.2</version>
</dependency>
<dependency>
    <groupid>org.projectlombok</groupid>
    <artifactid>lombok</artifactid>
    <optional>true</optional>
</dependency>

基础配置(application.yml):

spring:
  # redis 配置(分布式缓存)
  redis:
    host: localhost
    port: 6379
    password: 123456
    database: 0
    lettuce:
      pool:
        maximum-pool-size: 10
        minimum-idle: 2
  # 缓存配置
  cache:
    type: redis # 默认使用redis缓存(单机可改为caffeine)
    redis:
      time-to-live: 3600000 # 缓存过期时间(1小时,根据业务调整)
      cache-null-values: false # 不缓存null值,避免缓存穿透
    caffeine:
      time-to-live: 3600000 # 单机缓存过期时间
      initial-capacity: 100 # 初始缓存容量
      maximum-size: 1000 # 最大缓存数量(避免内存溢出)
# 开启spring cache注解支持
spring.cache.type: redis

策略1:cache-aside(旁路缓存)

cache-aside 是最主流、最易落地的双写策略,核心逻辑:查询走缓存,更新走数据库+删除缓存,不直接更新缓存,避免双写顺序错误。

很多人也称其为“cache-aside pattern”,是企业开发中最常用的缓存策略,兼顾性能和一致性,实现简单。

1. 核心流程

查询操作:先查缓存 → 缓存有数据,直接返回;缓存无数据,查数据库 → 将数据库数据写入缓存 → 返回数据;

更新操作:先更新数据库 → 再删除缓存(而非更新缓存);

删除操作:先删除数据库 → 再删除缓存。

2. 为什么是“删除缓存”,而非“更新缓存”?

这是很多同学最常问的问题,核心原因有2点:

  • 避免双写顺序错误:如果先更新缓存、再更新数据库,数据库更新失败,缓存是新数据、数据库是旧数据,直接不一致;
  •  减少冗余操作:如果多条更新操作连续执行,每次都更新缓存,会造成不必要的性能开销;而删除缓存,只需在最后一次更新后删除一次,后续查询再重新写入缓存,更高效。

3. 完整代码

使用spring cache的@cacheable(查询缓存)、@cacheevict(删除缓存)注解,无需手动操作redis,简化开发。

import org.springframework.cache.annotation.cacheevict;
import org.springframework.cache.annotation.cacheable;
import org.springframework.stereotype.service;
import javax.annotation.resource;
import java.util.optional;
/**
 * 商品服务(cache-aside策略实现)
 */
@service
public class productservice {
    @resource
    private productmapper productmapper;
    /**
     * 查询商品:先查缓存,无则查数据库,再写入缓存
     * value:缓存名称(自定义)
     * key:缓存key(用商品id,确保唯一)
     */
    @cacheable(value = "product", key = "#id")
    public product getproductbyid(long id) {
        // 缓存没有时,查询数据库(实际项目可加日志)
        optional<product> product = productmapper.selectbyid(id);
        return product.orelse(null);
    }
    /**
     * 更新商品:先更新数据库,再删除缓存
     * @cacheevict:删除缓存,allentries=false表示只删除当前key的缓存
     */
    @cacheevict(value = "product", key = "#product.id")
    public void updateproduct(product product) {
        // 1. 先更新数据库
        productmapper.updatebyid(product);
        // 2. 注解自动删除缓存(无需手动操作redis)
    }
    /**
     * 删除商品:先删除数据库,再删除缓存
     */
    @cacheevict(value = "product", key = "#id")
    public void deleteproduct(long id) {
        // 1. 先删除数据库
        productmapper.deletebyid(id);
        // 2. 注解自动删除缓存
    }
}

4. 优缺点与适用场景

优点:实现简单、无侵入(依赖spring cache注解)、性能好(查询走缓存,更新仅多一次删除缓存操作)、一致性有保障(最终一致性);

缺点:存在轻微的并发竞态问题(下文会讲解决方案);

适用场景:绝大多数业务场景,尤其是查询频率高、更新频率中等的场景(比如商品详情、用户信息、订单列表),是企业级落地的首选。

策略2:write-through

write-through 策略的核心逻辑:更新操作时,先更新数据库,再同步更新缓存;查询操作和cache-aside一致(先查缓存,无则查数据库)。

这种策略的特点是“写入即同步”,缓存和数据库的数据几乎是一致的(接近绝对一致性),但性能稍弱(多一次缓存更新操作)。

1. 核心流程

  • 查询操作:和cache-aside一致(先缓存 → 再数据库 → 写缓存);
  • 更新操作:先更新数据库 → 再更新缓存(覆盖旧缓存);
  •  删除操作:先删除数据库 → 再删除缓存(和cache-aside一致)。

2. 完整代码

write-through 不适合用spring cache注解(注解无法实现“更新数据库后同步更新缓存”的逻辑),需手动操作redistemplate。

import org.springframework.data.redis.core.redistemplate;
import org.springframework.stereotype.service;
import javax.annotation.resource;
import java.util.optional;
import java.util.concurrent.timeunit;
@service
public class productservice {
    @resource
    private productmapper productmapper;
    @resource
    private redistemplate<string, object> redistemplate;
    // 缓存key前缀(避免key冲突)
    private static final string cache_key_prefix = "product:";
    /**
     * 查询商品(和cache-aside一致)
     */
    public product getproductbyid(long id) {
        string cachekey = cache_key_prefix + id;
        // 1. 先查缓存
        product product = (product) redistemplate.opsforvalue().get(cachekey);
        if (product != null) {
            return product;
        }
        // 2. 缓存无,查数据库
        optional<product> dbproduct = productmapper.selectbyid(id);
        if (dbproduct.ispresent()) {
            // 3. 写入缓存(设置过期时间,避免缓存雪崩)
            redistemplate.opsforvalue().set(cachekey, dbproduct.get(), 1, timeunit.hours);
            return dbproduct.get();
        }
        return null;
    }
    /**
     * 更新商品:先更数据库,再更缓存(write-through策略核心)
     */
    public void updateproduct(product product) {
        // 1. 先更新数据库
        productmapper.updatebyid(product);
        // 2. 同步更新缓存(覆盖旧数据)
        string cachekey = cache_key_prefix + product.getid();
        redistemplate.opsforvalue().set(cachekey, product, 1, timeunit.hours);
    }
    /**
     * 删除商品:先删数据库,再删缓存
     */
    public void deleteproduct(long id) {
        // 1. 先删除数据库
        productmapper.deletebyid(id);
        // 2. 再删除缓存
        string cachekey = cache_key_prefix + id;
        redistemplate.delete(cachekey);
    }
}

3. 优缺点与适用场景

优点:缓存与数据库一致性强(接近绝对一致),查询时不会出现旧数据,适合对数据一致性要求高的场景;

缺点:性能稍弱(更新操作多一次缓存写入),存在双写顺序错误风险(若更新缓存失败,数据库是新数据、缓存是旧数据);

适用场景:对数据一致性要求高、更新频率低的场景(比如金融数据、核心配置数据),不适合高频更新场景。

策略3:write-back(写回)

write-back 策略的核心逻辑:更新操作时,先更新缓存,不立即更新数据库,而是将缓存标记为“脏数据”,在一定时机(比如缓存过期、缓存满了、定时任务)再批量同步到数据库。

这种策略的特点是“写入性能极高”(只需更新缓存,无需立即操作数据库),但一致性最弱(缓存更新后,数据库可能还是旧数据),实现复杂,很少在业务系统中使用。

1. 核心流程

  • 查询操作:和前两种策略一致(先缓存 → 再数据库 → 写缓存);
  • 更新操作:先更新缓存 → 标记缓存为“脏数据” → 异步/定时同步到数据库;
  • 删除操作:先删除缓存 → 标记为“脏数据” → 异步/定时删除数据库数据。

2. 简化实现代码

write-back 实现复杂,需结合定时任务、脏数据标记,以下是简化版核心逻辑(实际落地需完善异常处理、重试机制):

import org.springframework.data.redis.core.redistemplate;
import org.springframework.scheduling.annotation.scheduled;
import org.springframework.stereotype.service;
import javax.annotation.resource;
import java.util.hashmap;
import java.util.map;
import java.util.optional;
import java.util.concurrent.timeunit;
@service
public class productservice {
    @resource
    private productmapper productmapper;
    @resource
    private redistemplate<string, object> redistemplate;
    private static final string cache_key_prefix = "product:";
    // 存储脏数据(key:缓存key,value:商品对象)
    private final map<string, product> dirtydatamap = new hashmap<>();
    /**
     * 查询商品
     */
    public product getproductbyid(long id) {
        string cachekey = cache_key_prefix + id;
        product product = (product) redistemplate.opsforvalue().get(cachekey);
        if (product != null) {
            return product;
        }
        optional<product> dbproduct = productmapper.selectbyid(id);
        if (dbproduct.ispresent()) {
            redistemplate.opsforvalue().set(cachekey, dbproduct.get(), 1, timeunit.hours);
            return dbproduct.get();
        }
        return null;
    }
    /**
     * 更新商品:先更缓存,标记脏数据(write-back核心)
     */
    public void updateproduct(product product) {
        string cachekey = cache_key_prefix + product.getid();
        // 1. 更新缓存
        redistemplate.opsforvalue().set(cachekey, product, 1, timeunit.hours);
        // 2. 标记为脏数据
        dirtydatamap.put(cachekey, product);
    }
    /**
     * 定时同步脏数据到数据库(每5分钟执行一次,可调整)
     */
    @scheduled(cron = "0 0/5 * * * ?")
    public void syncdirtydatatodb() {
        if (dirtydatamap.isempty()) {
            return;
        }
        // 批量同步脏数据到数据库
        for (product product : dirtydatamap.values()) {
            productmapper.updatebyid(product);
        }
        // 清空脏数据
        dirtydatamap.clear();
    }
}

3. 优缺点与适用场景

优点:写入性能极高(无需立即操作数据库),适合高频写入、对一致性要求低的场景;

缺点:一致性最弱(缓存更新后,数据库可能延迟同步,若系统崩溃,脏数据会丢失),实现复杂(需处理脏数据、定时同步、异常重试);

适用场景:高频写入、对数据一致性要求低的场景(比如日志缓存、浏览记录、临时统计数据),业务系统核心数据不推荐使用。

三、解决双写策略的并发竞态问题

前面提到,cache-aside 策略存在轻微的并发竞态问题,这是新手落地时最容易踩的坑,也是面试常问的点,下面拆解问题场景,并给出两种企业级解决方案。

1. 并发竞态问题场景

假设两个线程同时执行:线程a(更新操作)、线程b(查询操作),执行顺序如下:

1. 线程a:更新数据库(成功);

2. 线程a:准备删除缓存(还未执行);

3. 线程b:查询缓存(缓存中还有旧数据?不,此时缓存还未删除,线程b查到旧数据,准备返回);

4. 线程a:删除缓存(成功);

5. 线程b:将查到的旧数据,重新写入缓存;

最终结果:数据库是新数据,缓存是旧数据,出现一致性问题,且后续查询都会拿到旧数据(直到缓存过期)。

2. 解决方案1:延迟删除缓存

核心逻辑:更新数据库后,延迟一段时间(比如100毫秒)再删除缓存,确保线程b在查询时,能查到数据库的新数据,而不是旧数据后写入缓存。

实现方式:使用线程池异步延迟删除,不影响主线程性能。

import org.springframework.cache.annotation.cacheable;
import org.springframework.scheduling.concurrent.threadpooltaskexecutor;
import org.springframework.stereotype.service;
import javax.annotation.resource;
import java.util.optional;
import java.util.concurrent.timeunit;
@service
public class productservice {
    @resource
    private productmapper productmapper;
    @resource
    private threadpooltaskexecutor taskexecutor;
    /**
     * 查询商品(不变)
     */
    @cacheable(value = "product", key = "#id")
    public product getproductbyid(long id) {
        optional<product> product = productmapper.selectbyid(id);
        return product.orelse(null);
    }
    /**
     * 更新商品:延迟删除缓存,解决并发竞态
     */
    public void updateproduct(product product) {
        // 1. 先更新数据库
        productmapper.updatebyid(product);
        // 2. 异步延迟100毫秒删除缓存(延迟时间可调整)
        long productid = product.getid();
        taskexecutor.schedule(() -> {
            // 手动删除缓存(替代@cacheevict注解)
            redistemplate.delete("product:" + productid);
        }, 100, timeunit.milliseconds);
    }
}

✅ 关键说明:延迟时间建议设置为“业务接口的最大响应时间”(比如100-500毫秒),确保线程b的查询操作能在缓存删除前完成数据库查询,避免旧数据写入缓存。

3. 解决方案2:分布式锁

核心逻辑:在查询和更新操作中,给“缓存key”加分布式锁(比如redis分布式锁),确保同一时间,只有一个线程能执行“查询+写缓存”或“更新+删缓存”操作,彻底解决竞态问题。

实现方式:使用redisson分布式锁(简化锁的操作,避免死锁),适合分布式系统场景。

import org.redisson.api.rlock;
import org.redisson.api.redissonclient;
import org.springframework.data.redis.core.redistemplate;
import org.springframework.stereotype.service;
import javax.annotation.resource;
import java.util.optional;
import java.util.concurrent.timeunit;
@service
public class productservice {
    @resource
    private productmapper productmapper;
    @resource
    private redistemplate<string, object> redistemplate;
    @resource
    private redissonclient redissonclient;
    private static final string cache_key_prefix = "product:";
    private static final string lock_key_prefix = "product:lock:";
    /**
     * 查询商品:加分布式锁,避免竞态
     */
    public product getproductbyid(long id) {
        string cachekey = cache_key_prefix + id;
        string lockkey = lock_key_prefix + id;
        rlock lock = redissonclient.getlock(lockkey);
        try {
            // 加锁(10秒自动释放,避免死锁)
            lock.lock(10, timeunit.seconds);
            // 1. 先查缓存
            product product = (product) redistemplate.opsforvalue().get(cachekey);
            if (product != null) {
                return product;
            }
            // 2. 查数据库,写缓存
            optional<product> dbproduct = productmapper.selectbyid(id);
            if (dbproduct.ispresent()) {
                redistemplate.opsforvalue().set(cachekey, dbproduct.get(), 1, timeunit.hours);
                return dbproduct.get();
            }
            return null;
        } finally {
            // 释放锁
            if (lock.isheldbycurrentthread()) {
                lock.unlock();
            }
        }
    }
    /**
     * 更新商品:加分布式锁,避免竞态
     */
    public void updateproduct(product product) {
        string cachekey = cache_key_prefix + product.getid();
        string lockkey = lock_key_prefix + product.getid();
        rlock lock = redissonclient.getlock(lockkey);
        try {
            lock.lock(10, timeunit.seconds);
            // 1. 更新数据库
            productmapper.updatebyid(product);
            // 2. 删除缓存
            redistemplate.delete(cachekey);
        } finally {
            if (lock.isheldbycurrentthread()) {
                lock.unlock();
            }
        }
    }
}

✅ 关键说明:分布式锁会增加一定的性能开销,适合对一致性要求高的分布式系统;如果是单机系统,可用本地锁(synchronized)替代,更高效。

四、文末小结

重点:优先掌握 cache-aside 策略(最易落地、最常用),先实现“查询查缓存、更新删缓存”的基础逻辑,再添加延迟删除缓存解决竞态问题,配合缓存过期时间、异常重试,就能满足绝大多数业务场景的缓存一致性需求。

实际项目中,无需过度追求复杂的策略,根据业务场景选择合适的双写方案:查询高频、更新中等 → cache-aside;一致性要求高 → write-through;高频写入、一致性要求低 → write-back。

以上就是springboot实现缓存与数据库双写策略的详细代码的详细内容,更多关于springboot缓存与数据库双写策略的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com