分布式微服务系统架构第116集:设备网关,处理字节的大数据,过亿缓存

加群联系作者vx:xiaoda0423

仓库地址:https://webvueblog.github.io/JavaPlusDoc/

https://1024bat.cn/

好的,咱们直接讲清楚重点:

在 Apache Cassandra 中,Session 是你跟数据库交互的核心对象
它主要负责干两件事:

  1. 管理连接
    Session 代表着和 Cassandra 集群 建立好的一组连接(通常是连接池),而不是一个简单的 TCP 连接。
    你可以理解成:Session 帮你打通了集群里多个节点之间的路,准备好随时发请求。

  2. 执行 CQL(Cassandra Query Language)语句
    通过 Session,你可以执行查询(SELECT)、插入(INSERT)、更新(UPDATE)、删除(DELETE)等操作。
    Session 会帮你把这些 CQL 命令发给合适的节点去处理。

简单来说:

Session 是 Cassandra 客户端和集群之间进行读写交互的通道,且是线程安全的,整个应用通常只创建一个或者少量几个 Session。

举个实际开发里的例子:

// 1. 创建连接集群
CqlSession session = CqlSession.builder()
    .withKeyspace("my_keyspace")
    .build();

// 2. 用 Session 执行一条查询
ResultSet rs = session.execute("SELECT * FROM users WHERE id = 123");

// 3. 遍历查询结果
for (Row row : rs) {
    System.out.println(row.getString("name"));
}

// 4. 用完关闭(重要,避免连接泄漏)
session.close();

这里的 CqlSession 是新版 Java Driver 4.x 之后引入的标准(比老的 Session 更强大一些)。

常见认知误区

误区

正解

Session 是单一连接

❌ 不是,它内部有连接池,可以高并发请求

每次用完都要新建 Session

❌ 不要,Session 是重量级对象,应该复用

Session 只能操作一个 keyspace

❌ 可以动态切换,也可以一开始就绑定某个 keyspace

🛠 【Kafka消费者配置流程】详细过程说明

① Spring Boot 项目启动阶段

  1. Spring 启动时,扫描到 @Configuration 标注的 EventConsumerConfig 类。

  2. Spring 自动执行 @Bean 方法:

  • 调用 batchRecordConsumerFactory() 方法,注册名字叫 "BRConsumerFactory" 的 批量消费容器工厂。

  • 调用 oneByOneRecordConsumerFactory() 方法,注册名字叫 "OBORConsumerFactory" 的 单条消费容器工厂。

  • 在 getConsumerFactory(true/false) 方法里:

    • 创建 ConcurrentKafkaListenerContainerFactory

    • 设置关联的 ConsumerFactory(封装了 Kafka 连接参数)。

    • 配置并发数量(即几条消费线程同时消费)。

    • 配置是否是批量消费。

    • 配置消费完消息后,手动提交 offset (AckMode.MANUAL_IMMEDIATE)。

    整体链路简化图(文字版)

    Spring Boot 启动
        ↓
    加载 EventConsumerConfig
        ↓
    注册 KafkaListenerContainerFactory (批量/单条)
        ↓
    应用内 @KafkaListener 启动监听
        ↓
    Kafka 推送消息
        ↓
    KafkaListener 容器拉取消息
        ↓
    调用 onMessage 处理逻辑
        ↓
    手动提交 offset (ack.acknowledge())
        ↓
    继续拉取下一批消息

    Kafka 消费者配置(EventConsumerConfig)操作流程说明


    1. 项目启动时,Spring 加载配置类 EventConsumerConfig

    • @Configuration 注解告诉 Spring:这是一个配置类,会被容器扫描。

    • Spring 创建 EventConsumerConfig 实例,并注入需要的配置属性(通过 @Value)。

    2. 注入配置属性到对象字段

    通过 @Value("${xxx}"),从配置文件(比如 application.yml)读取Kafka消费者的基础属性,比如:

    • Kafka集群地址 (kafka.consumer.servers)

    • 是否自动提交 (kafka.consumer.enable.auto.commit)

    • 超时时间 (kafka.consumer.session.timeout)

    • 消费者组 ID (kafka.consumer.group.id)

    • 并发线程数 (kafka.consumer.concurrency) 等等。

    如果某些配置没配,带默认值的 @Value 注解会保证程序不因空值报错。


    3. 创建 Kafka 消费者参数 Map (consumerConfigs 方法)

    调用 consumerConfigs() 方法时,会组装一份消费者所需的配置参数 Map<String, Object>

    主要包含:

    • 连接Kafka服务器 (BOOTSTRAP_SERVERS_CONFIG)

    • 禁用自动提交offset (ENABLE_AUTO_COMMIT_CONFIG = false)

    • 配置session超时

    • 配置key/value反序列化器

    • 动态组装消费者GroupId(带主机名后缀)

    • 配置拉取最大消息数

    • 配置初始offset位置(auto.offset.reset

    🔔 特别注意
    此处强制手动提交offset,保证业务处理完成后再提交,避免消息丢失。

    Kafka Producer 数据流程步骤

    从应用代码调用 kafkaTemplate.send() 到 Kafka Broker 落盘,整体分成下面 7 个主要步骤:


    1. KafkaTemplate 发送消息

    • 应用程序调用 kafkaTemplate.send(topic, key, value) 方法发起发送。

    • KafkaTemplate 封装了 Producer API,异步地把消息交给 Kafka Client。


    2. Producer 将消息序列化

    • KeySerializer 和 ValueSerializer(这里用的是 StringSerializer)把 key 和 value 转换成字节数组(byte[])。

    • 序列化是必须的,因为网络传输需要字节流。


    3. 将消息分配到 Partition

    • 根据发送时指定的 key(如果有),使用 Kafka 的 分区器(Partitioner) 算法计算出具体的 partition。

    • 如果没指定 key,则使用轮询或随机分配 partition。


    4. 消息写入 RecordAccumulator(本地缓存池)

    • Kafka Producer 不会立刻发送每一条消息,而是先放到内存中的 RecordAccumulator 缓冲池中。

    • 什么时候触发发送?

      • 累积到一定 batch.size 大小。

      • 或者等待时间超过 linger.ms

    (👉 你的配置:batch.sizelinger.ms就是在控制这里的行为。)


    5. Sender 线程异步发送数据

    • Producer 后台有一个 Sender线程,专门不断从 RecordAccumulator 拉取数据打包,异步发送到 Kafka Broker。

    • 通过 TCP 网络连接(socket)发送 ProduceRequest。


    6. Broker 收到请求并应答(ACK)

    • Kafka Broker Leader Partition 接收消息。

    • 内存中先缓存,然后立即返回 ACK 应答给 Producer(不一定等落盘! ,Kafka快的原因之一)。

    • 是否等待 ISR 集群同步完副本,取决于 acks 配置(你的代码里目前是默认 acks=1)。


    7. Producer Callback 回调处理

    • Producer 收到 Broker 的 ACK。

    • 如果发送成功,触发成功回调(onSuccess)。

    • 如果失败(比如网络中断、broker不可用),触发异常回调(onFailure),可以重试或记录异常。

    Kafka Producer 采用了 "内存批处理 + 异步发送 + 硬件顺序写" 模式,极大提高了消息发送性能与吞吐量。

    发送数据(Send Process)

    发送一条消息到 Kafka:

    ProducerRecord<String, String> record = new ProducerRecord<>("topic-name", "key", "value");
    producer.send(record, (metadata, exception) -> {
        if (exception == null) {
            System.out.println("发送成功, topic: " + metadata.topic() + ", offset: " + metadata.offset());
        } else {
            exception.printStackTrace();
        }
    });

    过程细节

    步骤

    说明

    构造 ProducerRecord

    封装消息主题、key、value 等信息

    调用 send() 方法

    异步将消息发送到消息累加器(RecordAccumulator)

    判断是否触发发送

    满批量(batch.size)或逗留时间(linger.ms)后,真正打包发送

    选择 Partition

    Kafka 内部根据 key 的 hash 算法,或随机分配 Partition

    网络线程 IO

    由 KafkaProducer 的 I/O 线程异步发送数据包到 Kafka Broker

    Broker 处理

    Kafka Broker 持久化写入、返回应答(根据 acks 决定是否需要副本确认)

    回调函数执行

    成功/失败都会触发回调(Callback)逻辑

    刷新与关闭(Flush & Close)

    保证缓冲区内所有数据都发送出去,并优雅关闭资源:

    producer.flush(); // 强制立即发送缓冲区内所有数据
    producer.close(); // 关闭生产者,释放连接资源

    Java 实操总结要点

    • 异步发送,提升吞吐量。

    • 批量累加,减少网络请求次数。

    • 配置合理 acks、retries,兼顾性能和可靠性。

    • 合理使用 callback,捕捉异常与记录日志。

    • flush() + close() ,确保优雅退出。

    // 使用线程池处理异步任务
    private static final BlockingQueue<Runnable> blockingQueue = new LinkedBlockingQueue<>();
    private static final ExecutorService fixedThreadPool = new ThreadPoolExecutor(
            2, 6, 60L, TimeUnit.SECONDS, blockingQueue
    );
    • try-catch范围精简,异常日志更规范(避免只打e.getMessage()导致漏掉栈信息)。

    • 入参判空 (null 校验) 增强,防止 NPE。

    • JSON转换加上了异常保护。

    • BlockingQueue 泛型加了 <Runnable>,防止编译警告。

    • 统一使用 @PostMapping 代替 @RequestMapping(method=POST),风格更一致。

    • 日志参数化 (logger.info("xxx {}", value)),性能更优。

    • 定义了一个静态常量阻塞队列,用来存储等待执行的任务(Runnable) 。

    • 是给下面这个线程池:

      ExecutorService fixedThreadPool = new ThreadPoolExecutor(..., blockingQueue);

      用的。

    • 本质是作为线程池任务缓冲区
      当线程池的线程忙不过来时,新来的任务(Runnable)就先塞进 blockingQueue,排队等执行。

    说白了,就是让线程池不丢任务、顺序排队的地方。

    线程池 + 阻塞队列配合流程:

    顺着你的代码理解是这样:

    1. 线程池初始化

    • 核心线程数:2

    • 最大线程数:6

    • 队列:blockingQueue

  • 提交任务时(比如异步提交一个 Runnable)

    • 如果当前活跃线程数 < 核心线程数(2个): ➔ 直接新建线程执行任务

    • 如果活跃线程数 ≥ 核心线程数: ➔ 任务塞到 blockingQueue 里面排队

    • 如果blockingQueue 满了,并且活跃线程数 < 最大线程数(6): ➔ 继续扩容线程去处理。

    • 如果最大线程数也满了,且队列也满了: ➔ 执行拒绝策略(默认抛异常,可以自定义处理)。

  • 线程空闲60秒后,如果是非核心线程,会被回收销毁

  • 简单流转:

    提交任务 Runnable
           ↓
    活跃线程数 < 核心线程数(2)?
           └→ 是:直接新建线程处理
           ↓ 否
    blockingQueue 队列未满?
           └→ 是:入队排队等待线程处理
           ↓ 否
    活跃线程数 < 最大线程数(6)?
           └→ 是:再创建新线程处理
           ↓ 否
    执行拒绝策略(抛异常或自定义处理)

    数据库示例(以 Cassandra 为例)

    表结构(示例) :

    假设我们在 Cassandra 中有这样一张表:

    CREATE TABLE IF NOT EXISTS logs (
      id text PRIMARY KEY,
      partition_key text,
      sort_key text,
      log_time text,
      info text
    );

    或者业务数据表:

    CREATE TABLE IF NOT EXISTS user_data (
      id text PRIMARY KEY,
      user_id text,
      device_id text,
      info text
    );
    • id:唯一主键(如 MongoDB ObjectId)

    • partition_key, sort_key:用于分区和排序

    • log_time:日志时间戳

    • info:对象数据序列化成的 JSON 字符串

    🧱 数据结构设计(详细)

    字段

    类型

    描述

    clientId

    varchar

    客户端 ID(设备或租户编号)

    day

    varchar

    日期(格式如 20240420)

    dir

    varchar

    消息方向(如 "up"=上行, "down"=下行)

    objId

    varchar

    对象 ID,自动生成唯一值(MongoDB 风格)

    自动清理过期表 + 自动导出数据备份

    • 节省存储成本

    • 保证系统长期稳定

    • 支持灾备和离线分析需求

    目标拆分

    1. 定时清理过期表

    • 找出超过 N 个月的历史表(比如保留最近 6 个月)

    • 自动 DROP TABLE

    2. 自动导出备份

    • 先把即将删除的表数据导出到文件(比如 JSON 或 CSV)

    • 备份到对象存储、NAS,或者直接推到 Kafka 等离线系统

    • 确认备份完成后再删除表

    总体思路

    步骤

    描述

    1

    分页查询大表数据(防止一次性读完爆内存)

    2

    多线程并发导出(加速 IO,提升速率)

    3

    分批写文件(比如每1000条落一次磁盘)

    4

    最后合并小文件(optional,根据需要)

    private static final int PAGE_SIZE = 1000; // 每页查1000条
    private static final int THREAD_COUNT = 4; // 4个线程并发导出
    
    private void exportTableData(String tableName) {
        ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
    
        String baseDir = "/data/backup/" + tableName;
        new File(baseDir).mkdirs();
    
        List<Future<File>> futures = new ArrayList<>();
        AtomicInteger fileCounter = new AtomicInteger(0);
    
        try {
            Statement stmt = new SimpleStatement(String.format("SELECT * FROM %s.%s;", KEYSPACE, tableName))
                    .setFetchSize(PAGE_SIZE);
    
            ResultSet resultSet = session.execute(stmt);
            Iterator<Row> iterator = resultSet.iterator();
    
            List<Row> batch = new ArrayList<>(PAGE_SIZE);
    
            while (iterator.hasNext()) {
                batch.add(iterator.next());
    
                if (batch.size() >= PAGE_SIZE) {
                    List<Row> batchToExport = new ArrayList<>(batch);
                    batch.clear();
    
                    futures.add(executor.submit(() -> exportBatch(batchToExport, baseDir, fileCounter.incrementAndGet())));
                }
            }
    
            // 导出最后剩余不足 PAGE_SIZE 的数据
            if (!batch.isEmpty()) {
                futures.add(executor.submit(() -> exportBatch(batch, baseDir, fileCounter.incrementAndGet())));
            }
    
            // 等待所有导出任务完成
            for (Future<File> future : futures) {
                future.get();
            }
    
            System.out.println("[Exporter] Exported table " + tableName + " in " + futures.size() + " parts.");
        } catch (Exception e) {
            throw new RuntimeException("Batch export failed for " + tableName, e);
        } finally {
            executor.shutdown();
        }
    }
    
    private File exportBatch(List<Row> rows, String baseDir, int partNumber) {
        File file = new File(baseDir, "part_" + partNumber + ".json");
    
        try (PrintWriter writer = new PrintWriter(file)) {
            ObjectMapper objectMapper = new ObjectMapper();
            for (Row row : rows) {
                Map<String, Object> rowMap = new HashMap<>();
                row.getColumnDefinitions().forEach(col -> {
                    rowMap.put(col.getName(), row.getObject(col.getName()));
                });
                writer.println(objectMapper.writeValueAsString(rowMap));
            }
        } catch (IOException e) {
            throw new RuntimeException("Failed to export part " + partNumber, e);
        }
    
        return file;
    }

    导出机制总结

    做法

    作用

    分页拉取

    每次只拉取1000条

    不会 OOM

    多线程导出

    4线程并行

    提升 IO 速度

    分文件存储

    每批数据一个小文件

    易于管理,防止文件过大

    可横向扩展

    调整 PAGE_SIZE、线程数

    灵活适配不同规模数据

    高并发 + 防爆内存 + 可扩展 + 可落盘备份

    1. 高并发(High Concurrency)

    目标:
    多线程并行导出,提升大表数据备份速度。

    具体做法:

    技术

    描述

    ExecutorService

     线程池

    控制固定数量线程同时工作,防止CPU过载

    每批数据异步提交导出任务

    每拉取一页数据(如1000行),提交一个异步导出任务

    Future / CompletableFuture

    异步任务结果收集,统一等待完成

    写磁盘操作并发执行

    提升磁盘 I/O 吞吐量

    效果:
    最大化利用 CPU、内存、磁盘带宽,数据量再大也能稳定快速导出。

    2. 防爆内存(Prevent OOM)

    目标:
    无论数据量多大,始终保持内存使用在合理范围内。

    具体做法:

    技术

    描述

    Cassandra 分页查询(FetchSize)

    每次只拉一小批(如1000条)数据进内存

    按批处理

    拉一批、写一批、清理一批,避免累积太多对象

    异步批次分批导出

    每个线程只维护自己那一小批数据

    效果:
    即使单表百万行、千万行,也不会一次性把内存吃满,保证系统稳定运行。

    可落盘备份(Durable Backup)

    目标:
    导出的数据安全持久保存,不丢失、不破坏。

    具体做法:

    技术

    描述

    按批次小文件保存

    每批导出一个小 JSON 文件,如 part_1.json

    可选压缩(gzip)

    小文件自动压缩,节省空间

    完成后合并小文件(可选)

    也可以保留分片,方便增量恢复

    目录规范化管理

    每张表一个目录,按表名+日期组织

    增加导出校验

    导出完成后记录总行数、校验码(MD5)防止误差

    🚀 实现方案(Java 控制台多线程进度条)

    ✨ Step 1:核心工具类 ProgressBar

    public class ProgressBar {
        private final int total;
        private final AtomicInteger current = new AtomicInteger(0);
        private final String taskName;
        private final int barWidth = 20;
    
        public ProgressBar(int total, String taskName) {
            this.total = total;
            this.taskName = taskName;
        }
    
        public void step(int count) {
            current.addAndGet(count);
            print();
        }
    
        public void print() {
            int now = current.get();
            double percent = now * 1.0 / total;
            int len = (int)(barWidth * percent);
    
            String bar = "[" +
                "=".repeat(len) +
                "-".repeat(barWidth - len) +
                "] " + String.format("%3d%%", (int)(percent * 100)) +
                " (" + now + " / " + total + ")" +
                "  表:" + taskName +
                (now >= total ? " ✔ Done" : "");
    
            synchronized (System.out) {
                System.out.print("\r" + bar); // 输出当前行
            }
        }
    }

    在程序中缓存到本地的数据时,通常会使用本地持久化存储(如文件、数据库、内存缓存等)来存储数据。这样,即使程序重新启动,也能恢复之前缓存的数据。以下是几种常见的实现方案:

    1. 使用文件缓存

    将缓存数据保存到本地文件中(如 JSON、XML、或二进制文件),在程序启动时读取文件恢复缓存。

    示例:使用 JSON 文件存储缓存
    import com.fasterxml.jackson.databind.ObjectMapper;
    import java.io.File;
    import java.io.IOException;
    import java.util.Map;
    import java.util.HashMap;
    
    public class FileCache {
        private static final ObjectMapper objectMapper = new ObjectMapper();
        private static final String CACHE_FILE_PATH = "cache_data.json";  // 缓存文件路径
    
        private static Map<String, String> cache = new HashMap<>();
    
        // 读取缓存数据
        public static void loadCache() {
            File cacheFile = new File(CACHE_FILE_PATH);
            if (cacheFile.exists()) {
                try {
                    cache = objectMapper.readValue(cacheFile, Map.class);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    
        // 将数据缓存到文件
        public static void saveCache() {
            try {
                objectMapper.writeValue(new File(CACHE_FILE_PATH), cache);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        // 获取缓存
        public static String getCache(String key) {
            return cache.get(key);
        }
    
        // 设置缓存
        public static void setCache(String key, String value) {
            cache.put(key, value);
            saveCache();
        }
    
        public static void main(String[] args) {
            // 加载缓存
            loadCache();
            
            // 设置缓存
            setCache("user_123", "John Doe");
    
            // 获取缓存
            String user = getCache("user_123");
            System.out.println("User: " + user);
        }
    }

    优点:

    • 简单易实现。

    • 可以在文件中持久化数据,程序重启后可以恢复缓存。

    缺点:

    • 如果数据量非常大,使用文件缓存可能会变得缓慢。

    • 文件存储的安全性较差,容易丢失或被篡改。

    2. 使用数据库缓存

    可以使用嵌入式数据库(如 SQLite)将缓存数据存储在本地数据库中。程序启动时从数据库中恢复缓存。

    示例:使用 SQLite 存储缓存
    import java.sql.*;
    
    public class DatabaseCache {
        private static final String DB_URL = "jdbc:sqlite:cache.db";  // SQLite数据库文件路径
        private static Connection connection;
    
        static {
            try {
                connection = DriverManager.getConnection(DB_URL);
                Statement stmt = connection.createStatement();
                stmt.execute("CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT)");
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    
        // 获取缓存
        public static String getCache(String key) {
            try {
                PreparedStatement stmt = connection.prepareStatement("SELECT value FROM cache WHERE key = ?");
                stmt.setString(1, key);
                ResultSet rs = stmt.executeQuery();
                if (rs.next()) {
                    return rs.getString("value");
                }
            } catch (SQLException e) {
                e.printStackTrace();
            }
            return null;
        }
    
        // 设置缓存
        public static void setCache(String key, String value) {
            try {
                PreparedStatement stmt = connection.prepareStatement("REPLACE INTO cache (key, value) VALUES (?, ?)");
                stmt.setString(1, key);
                stmt.setString(2, value);
                stmt.executeUpdate();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    
        public static void main(String[] args) {
            // 设置缓存
            setCache("user_123", "John Doe");
    
            // 获取缓存
            String user = getCache("user_123");
            System.out.println("User: " + user);
        }
    }

    优点:

    • 数据持久化到数据库,可以进行复杂的查询操作。

    • 数据可靠性较高,支持事务。

    缺点:

    • 相比内存缓存,数据库的读写性能较慢。

    • 需要依赖数据库引擎。

    3. 使用内存缓存+定期持久化(如 Redis)

    Redis 是一个非常流行的内存缓存系统,通常用于存储快速读取的数据。如果需要缓存并持久化,可以使用 Redis 提供的持久化选项(RDB 或 AOF)。

    Redis 持久化方式:

    • RDB(快照) :在指定时间间隔内生成数据的快照进行持久化。

    • AOF(追加文件) :记录每次修改操作,保证数据的持久化。

    示例:Redis 缓存(Spring Data Redis)
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.data.redis.core.ValueOperations;
    import org.springframework.stereotype.Component;
    
    @Component
    public class RedisCache {
        @Autowired
        private RedisTemplate<String, String> redisTemplate;
    
        // 获取缓存
        public String getCache(String key) {
            ValueOperations<String, String> ops = redisTemplate.opsForValue();
            return ops.get(key);
        }
    
        // 设置缓存
        public void setCache(String key, String value) {
            ValueOperations<String, String> ops = redisTemplate.opsForValue();
            ops.set(key, value);
        }
    }

    优点:

    • 高性能,支持分布式缓存。

    • 可以持久化缓存数据,提供高可用性。

    缺点:

    • 需要额外的 Redis 服务部署。

    • 如果 Redis 配置不当,可能会丢失部分数据。

    4. 使用本地内存缓存 + 恢复机制

    如果数据量较小且只需要在内存中缓存,可以选择将数据存储在内存中,在程序启动时加载缓存数据。使用一个简单的文件或数据库同步机制,将内存数据持久化到文件或数据库中。

    5. 程序重启后的恢复:

    无论使用哪种缓存方案,在程序重启时,都需要加载之前存储的缓存数据。常见的做法是:

    • 在启动时读取文件、数据库或 Redis 中存储的数据并恢复到内存缓存中。

    • 根据具体的需求选择是否清理缓存,或是恢复历史数据。

    本地缓存恢复机制就是:程序启动时读取缓存快照(文件、数据库)==> 恢复到内存中。

    1. 缓存保存

    比如你的应用里有个 Map<String, Object> localCache,要在程序运行时,把它保存到硬盘,通常在两个时机:

    • 定时保存,比如每5分钟存一次

    • 关闭程序(Shutdown Hook)时保存一次,避免数据丢失

    保存方法可以是:

    • JSON 文件

    • 本地数据库(如 SQLite)

    // 定时保存示例
    ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
    executor.scheduleAtFixedRate(() -> {
        saveLocalCacheToFile();
    }, 5, 5, TimeUnit.MINUTES);
    
    // 程序关闭时保存
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        saveLocalCacheToFile();
    }));

    2. 缓存恢复

    • 程序启动时,先去读本地文件,把之前保存的缓存数据加载进来。

    @PostConstruct
    public void loadCache() {
        Map<String, Object> cacheData = loadLocalCacheFromFile();
        if (cacheData != null) {
            localCache.putAll(cacheData);
        }
    }

    简单总结流程:

    场景

    处理方式

    程序运行中

    定时保存到文件

    程序退出时

    保存一次到文件

    程序启动时

    读取文件恢复

    二、使用Redis缓存时,如何做恢复?(Redis本身的持久化机制)

    Redis 本身是内存数据库,为了防止宕机数据丢失,有两种主流持久化方式:

    1. RDB 持久化(快照方式)

    • 定时(比如每隔5分钟、或者每有100条写入)保存整个内存快照到磁盘(dump.rdb)。

    • Redis 宕机、重启后,会自动加载 dump.rdb 文件,把数据恢复到内存。

    特点
    • 轻量级,适合大部分普通应用。

    • 有可能丢失最近一小段时间(最后一次保存后)修改的数据。

    配置示例(redis.conf
    save 900 1    # 900秒(15分钟)内有1次修改就保存快照
    save 300 10   # 300秒内有10次修改就保存快照
    save 60 10000 # 60秒内有10000次修改就保存快照

    2. AOF 持久化(日志方式)

    • 每次写命令(如 SET、HSET、LPUSH)追加到 AOF 文件。

    • Redis 宕机、重启后,按日志回放的方式重新执行这些指令,恢复数据。

    特点
    • 数据最完整,可以做到几乎不丢数据。

    • 缺点是AOF文件增长快,需要后台定期压缩(重写)。

    配置示例(redis.conf
    appendonly yes             # 打开AOF持久化
    appendfsync everysec       # 每秒钟同步一次
    # appendfsync always       # 每次写操作都同步,最安全但最慢
    # appendfsync no           # 不主动同步,依赖系统(性能高但可能丢数据)

    3. RDB vs AOF 总结对比

    持久化方式

    优点

    缺点

    RDB

    占用小,恢复快,适合冷备份

    宕机时丢失最后一次快照后的数据

    AOF

    数据最完整,适合核心数据保护

    写入频繁,占用磁盘大,恢复速度慢一点

    实际项目里一般是:

    • RDB+AOF同时开启,互为备份。Redis支持同时打开两种模式。

    • Redis重启时,优先用 AOF 恢复,AOF坏了再用RDB。

    三、如果是你写的应用,想结合Redis缓存做恢复,一般这样做

    1. 正常用Redis做缓存

    2. Redis配置了RDB或AOF持久化,保障数据存储。

    3. 应用层做好容错,比如读Redis失败时,从数据库兜底,或者重新补缓存。

    4. 应用程序启动时,可以适当做一次缓存预热(预加载常用数据到缓存,提高命中率)。

    示例:

    @PostConstruct
    public void preloadCache() {
        // 程序启动时,把常用的设备状态、配置,提前加载到Redis里
        cabinetsService.preloadCabinetsInfo();
    }

    程序启动时,要把之前保存的缓存数据恢复回来,也就是常说的 ——

    缓存恢复 / 缓存预热(Application Start Cache Recovery)

    🛠 一、【本地缓存】启动恢复

    假设你程序有个本地 ConcurrentHashMap<String, Object> localCache
    那你启动的时候,应该做 这三步

    1. 读取磁盘文件(比如 JSON 文件 / 本地数据库 SQLite)

    2. 反序列化成对象

    3. putAll到内存缓存中

    示例代码(假设用 JSON 文件保存的)

    @Component
    public class LocalCacheManager {
    
        private static final Logger logger = LoggerFactory.getLogger(LocalCacheManager.class);
    
        private final ConcurrentHashMap<String, Object> localCache = new ConcurrentHashMap<>();
    
        private static final String CACHE_FILE = "/tmp/local_cache.json";
    
        @PostConstruct
        public void loadLocalCache() {
            File file = new File(CACHE_FILE);
            if (!file.exists()) {
                logger.warn("缓存文件不存在,跳过加载");
                return;
            }
            try (FileReader reader = new FileReader(file)) {
                Type type = new TypeToken<Map<String, Object>>(){}.getType();
                Map<String, Object> cacheFromFile = new Gson().fromJson(reader, type);
                if (cacheFromFile != null) {
                    localCache.putAll(cacheFromFile);
                    logger.info("本地缓存加载完成,条数:" + cacheFromFile.size());
                }
            } catch (Exception e) {
                logger.error("本地缓存恢复失败", e);
            }
        }
    
        public Object get(String key) {
            return localCache.get(key);
        }
    }

    📌 注意:

    • @PostConstruct:Spring容器初始化后自动调用

    • 如果是复杂对象(比如设备状态类),反序列化的时候需要注意泛型

    • 启动时检查Redis关键数据是否存在

    • 缺失的话,要去数据库/接口补充加载到Redis里

    示例代码(Redis缓存预热)

    @Component
    public class RedisCachePreloader {
    
        private static final Logger logger = LoggerFactory.getLogger(RedisCachePreloader.class);
    
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        @Autowired
        private CabinetsService cabinetsService; // 自己的业务服务,查DB
    
        @PostConstruct
        public void preloadCache() {
            logger.info("启动时预热Redis缓存...");
            List<CabinetsInfo> cabinetsList = cabinetsService.queryAllCabinets();
            for (CabinetsInfo info : cabinetsList) {
                if (!Boolean.TRUE.equals(redisTemplate.hasKey(info.getCabinetId()))) {
                    redisTemplate.opsForHash().put("cabinetInfo", info.getCabinetId(), info.getServerAddr());
                }
            }
            logger.info("Redis缓存预热完成,数量:" + cabinetsList.size());
        }
    }

    (缓存恢复完整流程)

    [程序启动] 
        ↓
    【本地缓存】
        - 读取缓存快照文件(JSON、SQLite)
        - 反序列化
        - 填充ConcurrentHashMap
    【Redis缓存】
        - Redis自动恢复(RDB/AOF)
        - 程序检测关键数据
        - 缓存缺失时补充预热
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值