缓存与数据库数据一致性:旁路缓存、读写穿透和异步写入模式解析

旁路缓存模式、读写穿透模式和异步缓存写入模式是三种常见的缓存使用模式,以下是对三种经典缓存使用模式在缓存与数据库数据一致性方面更全面的分析:

一、旁路缓存模式(Cache - Aside Pattern)

1.数据读取流程

  • 应用程序首先向缓存发送读取请求,检查所需数据是否在缓存中。
  • 如果缓存命中,直接从缓存中获取数据并返回给应用程序,这能极大提高读取速度,减少数据库的负载。
  • 若缓存未命中,应用程序接着向数据库发送读取请求,从数据库获取数据。获取到数据后,一方面将数据返回给应用程序,另一方面把数据写入缓存,同时可以设置缓存数据的过期时间,以便在数据更新后能及时从数据库重新获取最新数据。

2.数据写入流程

  • 当应用程序要更新数据时,首先更新数据库中的数据,确保数据库作为数据的可靠来源得到及时更新。
  • 在数据库更新成功后,立即删除缓存中对应的旧数据。这样做是为了让下次读取该数据时,能从数据库获取到最新数据并更新到缓存中,保证缓存数据的时效性。

3.一致性分析

  • 优点
    • 实现相对简单,在正常情况下能较好地保证数据一致性。以数据库为数据的权威来源,缓存主要用于加速读取,通过先更新数据库再删除缓存的操作顺序,多数情况下能确保缓存数据要么是最新的,要么不存在,等待下次读取时更新。
    • 读性能优化明显,缓存命中时能快速响应读取请求,减轻数据库压力,适用于读多写少的场景。
  • 缺点
    • 在高并发场景下可能出现数据不一致问题。例如,两个并发更新操作同时对同一数据进行修改,若操作 A 先更新数据库但在删除缓存前,操作 B 更新数据库并先于操作 A 删除缓存,接着操作 A 再删除缓存,此时缓存中无最新数据,读取请求可能获取到旧数据,直到下次缓存更新。
    • 缓存数据的过期时间设置较为关键,若设置过长,可能导致缓存数据长时间不一致;设置过短,则会增加数据库的读取压力。

4.代码实例

java 复制代码
import redis.clients.jedis.Jedis;
import java.sql.*;

public class CacheAsidePattern {
    private static final String REDIS_HOST = "localhost";
    private static final int REDIS_PORT = 6379;
    private static final String DB_URL = "jdbc:mysql://localhost:3306/testdb";
    private static final String DB_USER = "root";
    private static final String DB_PASSWORD = "password";

    public static void main(String[] args) {
        try (Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
             Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)) {

            // 创建表
            createTable(conn);
            // 插入示例数据
            insertSampleData(conn);

            // 测试读取
            String userName = getUser(jedis, conn, 1);
            System.out.println("用户姓名: " + userName);

            // 测试更新
            updateUser(jedis, conn, 1, "Bob");
            userName = getUser(jedis, conn, 1);
            System.out.println("更新后用户姓名: " + userName);

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    private static void createTable(Connection conn) throws SQLException {
        String createTableSQL = "CREATE TABLE IF NOT EXISTS users (" +
                "id INT PRIMARY KEY, " +
                "name VARCHAR(255))";
        try (Statement stmt = conn.createStatement()) {
            stmt.executeUpdate(createTableSQL);
        }
    }

    private static void insertSampleData(Connection conn) throws SQLException {
        String insertSQL = "INSERT IGNORE INTO users (id, name) VALUES (1, 'Alice')";
        try (PreparedStatement pstmt = conn.prepareStatement(insertSQL)) {
            pstmt.executeUpdate();
        }
    }

    private static String getUser(Jedis jedis, Connection conn, int userId) {
        String key = "user:" + userId;
        String user = jedis.get(key);
        if (user != null) {
            System.out.println("从缓存中获取数据");
            return user;
        } else {
            try (PreparedStatement pstmt = conn.prepareStatement("SELECT name FROM users WHERE id = ?")) {
                pstmt.setInt(1, userId);
                ResultSet rs = pstmt.executeQuery();
                if (rs.next()) {
                    user = rs.getString("name");
                    jedis.set(key, user);
                    System.out.println("从数据库中获取数据并写入缓存");
                    return user;
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
            return null;
        }
    }

    private static void updateUser(Jedis jedis, Connection conn, int userId, String newName) {
        String key = "user:" + userId;
        try (PreparedStatement pstmt = conn.prepareStatement("UPDATE users SET name = ? WHERE id = ?")) {
            pstmt.setString(1, newName);
            pstmt.setInt(2, userId);
            pstmt.executeUpdate();
            jedis.del(key);
            System.out.println("数据库更新并删除缓存");
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}    

二、读写穿透模式(Write - Through Pattern)

1.数据读取流程

  • 应用程序向缓存发送读取请求,尝试从缓存中获取数据。
  • 如果缓存命中,直接从缓存中返回数据给应用程序。
  • 若缓存未命中,应用程序从数据库读取数据,读取到数据后,将数据返回给应用程序,同时将数据写入缓存,且写入缓存操作是同步进行的,确保数据在缓存和数据库中同时更新。

2.数据写入流程

  • 当应用程序执行写操作时,会同时向缓存和数据库发送更新请求。先将数据写入缓存,然后由缓存负责将数据同步到数据库,通常通过缓存的写入操作触发对数据库的写入,保证缓存和数据库数据的实时同步。

3.一致性分析

  • 优点
    • 能严格保证数据一致性,每次读写操作都确保缓存和数据库的数据同步更新,两者就像一个整体,任何一方的更新立即反映到另一方,不存在数据延迟或不一致的情况。
    • 对于读写操作较为均衡的场景,该模式能较好地适应,不会出现因写操作频繁导致缓存与数据库数据不一致的问题。
  • 缺点
    • 由于缓存和数据库是不同存储系统,其写入性能和可靠性存在差异,可能出现缓存写入成功但数据库写入失败的情况,导致数据不一致。
    • 为保证一致性引入的补偿机制,如重试机制或事务机制,会增加系统复杂性和开发成本。同时,同步写入操作可能会降低写操作的性能,因为需要等待数据库写入完成才能返回结果。

4.代码实例

java 复制代码
import redis.clients.jedis.Jedis;
import java.sql.*;

public class WriteThroughPattern {
    private static final String REDIS_HOST = "localhost";
    private static final int REDIS_PORT = 6379;
    private static final String DB_URL = "jdbc:mysql://localhost:3306/testdb";
    private static final String DB_USER = "root";
    private static final String DB_PASSWORD = "password";

    public static void main(String[] args) {
        try (Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
             Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)) {

            // 创建表
            createTable(conn);
            // 插入示例数据
            insertSampleData(conn);

            // 测试读取
            String userName = getUser(jedis, conn, 1);
            System.out.println("用户姓名: " + userName);

            // 测试更新
            updateUser(jedis, conn, 1, "Bob");
            userName = getUser(jedis, conn, 1);
            System.out.println("更新后用户姓名: " + userName);

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    private static void createTable(Connection conn) throws SQLException {
        String createTableSQL = "CREATE TABLE IF NOT EXISTS users (" +
                "id INT PRIMARY KEY, " +
                "name VARCHAR(255))";
        try (Statement stmt = conn.createStatement()) {
            stmt.executeUpdate(createTableSQL);
        }
    }

    private static void insertSampleData(Connection conn) throws SQLException {
        String insertSQL = "INSERT IGNORE INTO users (id, name) VALUES (1, 'Alice')";
        try (PreparedStatement pstmt = conn.prepareStatement(insertSQL)) {
            pstmt.executeUpdate();
        }
    }

    private static String getUser(Jedis jedis, Connection conn, int userId) {
        String key = "user:" + userId;
        String user = jedis.get(key);
        if (user != null) {
            System.out.println("从缓存中获取数据");
            return user;
        } else {
            try (PreparedStatement pstmt = conn.prepareStatement("SELECT name FROM users WHERE id = ?")) {
                pstmt.setInt(1, userId);
                ResultSet rs = pstmt.executeQuery();
                if (rs.next()) {
                    user = rs.getString("name");
                    jedis.set(key, user);
                    System.out.println("从数据库中获取数据并写入缓存");
                    return user;
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
            return null;
        }
    }

    private static void updateUser(Jedis jedis, Connection conn, int userId, String newName) {
        String key = "user:" + userId;
        try {
            conn.setAutoCommit(false);
            jedis.set(key, newName);
            try (PreparedStatement pstmt = conn.prepareStatement("UPDATE users SET name = ? WHERE id = ?")) {
                pstmt.setString(1, newName);
                pstmt.setInt(2, userId);
                pstmt.executeUpdate();
            }
            conn.commit();
            System.out.println("缓存和数据库同时更新");
        } catch (SQLException e) {
            try {
                conn.rollback();
            } catch (SQLException rollbackEx) {
                rollbackEx.printStackTrace();
            }
            System.out.println("更新失败: " + e.getMessage());
        }
    }
}    

三、异步缓存写入模式(Write - Behind Caching Pattern)

1.数据读取流程

  • 与前两种模式类似,应用程序首先从缓存中读取数据。
  • 若缓存命中,直接返回数据。
  • 缓存未命中时,从数据库读取数据并返回给应用程序,同时将数据写入缓存。

2.数据写入流程

  • 写操作发生时,应用程序只将数据写入缓存,然后由缓存负责在后台异步地将数据批量写入数据库。可以根据一定的策略,如达到一定的写入次数或经过一定的时间间隔,将缓存中的数据批量刷写到数据库。

3.一致性分析

  • 优点
    • 写性能极高,应用程序无需等待数据库写入完成即可快速响应写请求,能显著提高系统吞吐量,适用于写操作频繁的场景,如日志记录、实时数据采集等。
    • 通过批量写入数据库,减少了数据库的写入次数,降低了数据库的 I/O 压力,有助于提高数据库的性能和稳定性。
  • 缺点
    • 数据一致性问题较为严重。由于数据是异步写入数据库的,在写入缓存后到写入数据库之前的时间段内,若发生系统故障、缓存数据丢失或缓存服务崩溃等情况,可能导致数据丢失,破坏数据一致性。
    • 为保证数据一致性采取的措施,如持久化缓存、合理设置缓存刷写策略、系统恢复时的数据恢复操作等,增加了系统的复杂性和运维成本。同时,还需考虑数据库写入的并发控制,避免数据冲突和不一致。

4.代码实例

java 复制代码
import redis.clients.jedis.Jedis;
import java.sql.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class WriteBehindCachingPattern {
    private static final String REDIS_HOST = "localhost";
    private static final int REDIS_PORT = 6379;
    private static final String DB_URL = "jdbc:mysql://localhost:3306/testdb";
    private static final String DB_USER = "root";
    private static final String DB_PASSWORD = "password";
    private static final int FLUSH_INTERVAL = 5; // 每 5 秒刷写一次

    public static void main(String[] args) {
        try (Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
             Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)) {

            // 创建表
            createTable(conn);
            // 插入示例数据
            insertSampleData(conn);

            // 启动异步刷写任务
            ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
            executor.scheduleAtFixedRate(() -> flushCacheToDB(jedis, conn), 0, FLUSH_INTERVAL, TimeUnit.SECONDS);

            // 测试读取
            String userName = getUser(jedis, conn, 1);
            System.out.println("用户姓名: " + userName);

            // 测试更新
            updateUser(jedis, conn, 1, "Bob");
            userName = getUser(jedis, conn, 1);
            System.out.println("更新后用户姓名: " + userName);

            // 关闭线程池
            executor.shutdown();

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    private static void createTable(Connection conn) throws SQLException {
        String createTableSQL = "CREATE TABLE IF NOT EXISTS users (" +
                "id INT PRIMARY KEY, " +
                "name VARCHAR(255))";
        try (Statement stmt = conn.createStatement()) {
            stmt.executeUpdate(createTableSQL);
        }
    }

    private static void insertSampleData(Connection conn) throws SQLException {
        String insertSQL = "INSERT IGNORE INTO users (id, name) VALUES (1, 'Alice')";
        try (PreparedStatement pstmt = conn.prepareStatement(insertSQL)) {
            pstmt.executeUpdate();
        }
    }

    private static String getUser(Jedis jedis, Connection conn, int userId) {
        String key = "user:" + userId;
        String user = jedis.get(key);
        if (user != null) {
            System.out.println("从缓存中获取数据");
            return user;
        } else {
            try (PreparedStatement pstmt = conn.prepareStatement("SELECT name FROM users WHERE id = ?")) {
                pstmt.setInt(1, userId);
                ResultSet rs = pstmt.executeQuery();
                if (rs.next()) {
                    user = rs.getString("name");
                    jedis.set(key, user);
                    System.out.println("从数据库中获取数据并写入缓存");
                    return user;
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
            return null;
        }
    }

    private static void updateUser(Jedis jedis, Connection conn, int userId, String newName) {
        String key = "user:" + userId;
        jedis.set(key, newName);
        System.out.println("数据写入缓存,等待异步刷写数据库");
    }

    private static void flushCacheToDB(Jedis jedis, Connection conn) {
        try {
            conn.setAutoCommit(false);
            // 模拟获取所有用户缓存数据
            // 实际应用中需要根据业务逻辑获取待刷写的数据
            String keyPattern = "user:*";
            for (String key : jedis.keys(keyPattern)) {
                int userId = Integer.parseInt(key.split(":")[1]);
                String userName = jedis.get(key);
                try (PreparedStatement pstmt = conn.prepareStatement("UPDATE users SET name = ? WHERE id = ?")) {
                    pstmt.setString(1, userName);
                    pstmt.setInt(2, userId);
                    pstmt.executeUpdate();
                }
            }
            conn.commit();
            System.out.println("缓存数据刷写到数据库");
        } catch (SQLException e) {
            try {
                conn.rollback();
            } catch (SQLException rollbackEx) {
                rollbackEx.printStackTrace();
            }
            System.out.println("刷写失败: " + e.getMessage());
        }
    }
}    

三种经典缓存使用模式在缓存与数据库数据一致性方面各有优劣。在实际应用中,需要根据业务对数据一致性的严格程度、读写操作的频率和性能要求等因素,综合权衡选择合适的缓存模式,并通过相应的技术手段和策略来最大程度地保障数据一致性。

相关推荐
axinawang30 分钟前
springboot整合redis实现缓存
spring boot·redis·缓存
Spring小子33 分钟前
黑马点评商户查询缓存--缓存更新策略
java·数据库·redis·后端
溜溜刘@♞2 小时前
数据库之mysql优化
数据库·mysql
for622 小时前
本地缓存大杀器-Caffeine
缓存·caffeine·本地缓存
uwvwko3 小时前
ctfhow——web入门214~218(时间盲注开始)
前端·数据库·mysql·ctf
柯3493 小时前
Redis的过期删除策略和内存淘汰策略
数据库·redis·lfu·lru
Tiger_shl3 小时前
【Python语言基础】24、并发编程
java·数据库·python
0509153 小时前
测试基础笔记第十一天
java·数据库·笔记
A charmer3 小时前
【MySQL】数据库基础
数据库·mysql
pjx9874 小时前
应用的“体检”与“换装”:精通Spring Boot配置管理与Actuator监控
数据库·spring boot·oracle