《C++ 项目:手写大模型接入 SDK》会话模块整合:内存 SessionManager 与 DataManager 持久化联动

🔥小叶-duck:个人主页

❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux系统从入门到实践》《Linux网络从入门到实践》

《Qt 方寸极境》 《MySQL》

✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一、Session 会话管理与 DataManager 数据持久化模块整合](#一、Session 会话管理与 DataManager 数据持久化模块整合)

[1.1 业务背景](#1.1 业务背景)

[1.2 模块基础概况](#1.2 模块基础概况)

[1.2.1 SessionManager 会话管理器](#1.2.1 SessionManager 会话管理器)

[1.2.2 DataManager SQLite 数据管理器](#1.2.2 DataManager SQLite 数据管理器)

[1.3 整合之后的同步逻辑简述](#1.3 整合之后的同步逻辑简述)

二、模块整合核心设计思想

[2.1 组合关系设计](#2.1 组合关系设计)

[2.2 锁策略与并发风险规避](#2.2 锁策略与并发风险规避)

[2.3 双向同步策略](#2.3 双向同步策略)

[2.4 消息驱动的会话更新时间联动](#2.4 消息驱动的会话更新时间联动)

[2.5 会话列表与数据一致性保证](#2.5 会话列表与数据一致性保证)

[三、SessionManager 成员函数改造实现](#三、SessionManager 成员函数改造实现)

[3.1 createSession 创建会话](#3.1 createSession 创建会话)

[3.2 getSession 获取会话(核心双向读取与并发安全)](#3.2 getSession 获取会话(核心双向读取与并发安全))

[3.2.1 优化背景](#3.2.1 优化背景)

[3.2.2 业务流程](#3.2.2 业务流程)

[3.2.3 完整代码及分析](#3.2.3 完整代码及分析)

[3.2.4 设计要点小结](#3.2.4 设计要点小结)

[3.3 addMessage 添加会话消息](#3.3 addMessage 添加会话消息)

[3.4 getHistoryMessages 获取会话全部历史消息](#3.4 getHistoryMessages 获取会话全部历史消息)

[3.5 getSessionLists 获取全部会话 ID 列表](#3.5 getSessionLists 获取全部会话 ID 列表)

[3.6 updateSessionTimestamp 更新会话时间戳](#3.6 updateSessionTimestamp 更新会话时间戳)

[3.7 deleteSession 删除指定会话](#3.7 deleteSession 删除指定会话)

[3.8 deleteAllSessions 清空全部会话](#3.8 deleteAllSessions 清空全部会话)

[3.9 getSessionsCount 获取会话总数](#3.9 getSessionsCount 获取会话总数)

四、单元测试:会话持久化增删查验证

[4.1 测试说明](#4.1 测试说明)

[4.2 测试入口 main 函数](#4.2 测试入口 main 函数)

[4.3 测试用例 1:创建会话与消息持久化](#4.3 测试用例 1:创建会话与消息持久化)

[4.4 测试用例 2:加载历史会话、查询消息、删除会话](#4.4 测试用例 2:加载历史会话、查询消息、删除会话)

[4.5 运行结果与核心结论](#4.5 运行结果与核心结论)

结束语


前言

在前面的开发中,我们已经完成了会话管理器SessionManager的基础内存版本,同时也掌握了 SQLite 数据库的原生 API 使用,实现了DataManager数据持久化模块。此时会话数据只保存在内存中,程序一旦退出,所有对话记录就会丢失,无法满足大模型 SDK 长期保存对话上下文的需求。

本章我们将把SessionManager会话管理器与DataManager SQLite 数据管理器进行整合,完成内存缓存与磁盘数据库的双向同步改造。我们会梳理模块之间的组合关系、并发锁策略、数据一致性方案,逐个改造会话创建、追加消息、查询、删除等成员函数,保证内存和数据库两边数据同步更新。

最后我们基于 GTest 编写单元测试,分两个用例闭环验证持久化能力:先写入会话和消息落盘,再重新启动程序从数据库加载历史对话、查询消息、删除会话,验证整套持久化链路稳定可用。

一、Session 会话管理与 DataManager 数据持久化模块整合

1.1 业务背景

我们之前单独实现的SessionManager仅能在内存中维护会话数据,一旦程序断电、重启,所有会话和消息数据都会直接丢失;

而DataManager基于 SQLite 实现了磁盘持久化能力,在上篇文章中DataManager的单元测试我们就发现所有的会话相关信息都是我们模拟出来用于测试接口是否正常的值,并不符合SessionManager的规范,因为两个模块在开发阶段本身就是是相互独立、解耦的。

所以,本篇文章我们就需要将这两个模块进行整合,本次整合的核心目标:会话、消息发生变更时,完成内存与 SQLite 数据库的双向同步,让聊天对话数据具备持久化能力。

1.2 模块基础概况

1.2.1 SessionManager 会话管理器

**职责:**负责内存侧会话集合的维护,对外提供会话创建、消息追加、会话查询、会话删除、会话列表获取等业务接口。 内部成员:

  • std::unordered_map<std::string, std::shared_ptr<Session>> _sessions:内存会话哈希表,key 为 sessionId,value 是会话智能指针。
  • mutable std::mutex _mutex:互斥锁,用来保护内存会话集合,保证多线程访问安全。
  • std::atomic<int64_t> _sessionCounter:会话 ID 生成计数器,原子类型保证多线程下 ID 生成安全。
  • DataManager _dataManager(新增):组合持有数据持久化对象,这也是两个模块整合的关键,所有内存变更同步到数据库的操作都由它完成。

1.2.2 DataManager SQLite 数据管理器

职责: 封装底层 SQLite 数据库操作,维护sessions会话表、messages消息表,提供会话、消息的增删改查持久化接口。 数据库表结构定义:

sql 复制代码
-- 会话表
CREATE TABLE IF NOT EXISTS sessions (
    session_id TEXT PRIMARY KEY,
    model_name TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 消息表
CREATE TABLE IF NOT EXISTS messages (
    message_id TEXT NOT NULL,
    session_id TEXT NOT NULL,
    role TEXT NOT NULL,
    content TEXT NOT NULL,
    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);

**关键约束说明:ON DELETE CASCADE级联删除。**当会话表内一条会话记录被删除时,数据库会自动删除该会话关联的全部消息记录,不需要手动调用接口清理消息,保证数据库数据一致性。

1.3 整合之后的同步逻辑简述

  1. 新建会话:SessionManager 在内存创建会话对象,生成唯一 sessionId,同时调用 DataManager 的插入会话接口,写入 sessions 表;
  2. 新增消息 :往会话追加消息时,内存消息列表更新,同步调用 DataManager 写入 messages 表,并且更新会话的updated_at时间戳;
  3. 删除会话:先在内存哈希表移除会话,再调用 DataManager 删除会话记录,依靠外键级联自动清理消息;
  4. 程序启动加载:SessionManager 初始化阶段,调用 DataManager 读取数据库中全部会话和消息,加载到内存哈希表,实现历史会话恢复;
  5. 查询会话:优先读取内存缓存,减少数据库查询压力;缓存不存在时再去数据库查询。

二、模块整合核心设计思想

2.1 组合关系设计

SessionManager 以成员对象的方式 持有 DataManager 实例,采用组合而非继承的思路 ,符合面向对象的设计原则。

cpp 复制代码
private:
    DataManager _dataManager;

构造函数声明:

cpp 复制代码
SessionManager(const std::string& dbName = "chatDB.db");

初始化流程:

  1. SessionManager构造时初始化_dataManager,打开 SQLite 数据库,自动创建会话、消息两张数据表;
  2. 程序启动阶段,调用_dataManager.getAllSessions(),加载数据库中全部会话元信息到内存_sessions哈希表,完成冷启动加载。

构造函数实现:

cpp 复制代码
SessionManager::SessionManager(const std::string &dbName)
    : _dataManager(dbName)
{
    // 从数据库加载所有会话
    if (auto res = _dataManager.getAllSessions())
    {
        std::vector<std::shared_ptr<Session>> sessions = res.value();
        for (const auto &session : sessions)
        {
            _sessions[session->_session_id] = session;
            _sessionCounter.fetch_add(1);
        }
    }
}

构造函数执行时不加锁 ------ 此时对象尚未对外暴露 ,不存在并发访 问,这是可以省去锁开销的合理性判断。加载完成后 _sessionCounter 自增,是为了避免后续新生成的会话 ID 不会与历史 ID 冲突。

2.2 锁策略与并发风险规避

SessionManager 和 DataManager 各自持有独立的互斥锁:

  • SessionManager::_mutex 保护内存哈希表 _sessions
  • DataManager::_dbMutex 保护 SQLite 的读写访问

需要特别澄清一个容易混淆的点:这两把锁互相独立 ,并不是共享同一把锁 ,所以 "持有内存锁再调数据库接口" 并不必然触发死锁。但只要存在这种调用方式,就一定会产生另一个后果 ------I/O 锁等待。

磁盘数据库操作属于典型的慢 I/O,耗时不确定且可能远超内存操作。如果我们在持有 SessionManager 内存锁的情况下调用 DataManager 的接口,那么在整个数据库读写期间,这把内存锁都被占用,其他所有想访问会话表的线程全部阻塞排队。

结果就是:一次落盘操作把整个 SDK 的会话访问能力拖停,并发性能严重下降。

基于这个风险,我们定下统一编码规范:

操作内存集合时加锁,内存数据改完之后立刻释放锁,再调用 DataManager 的持久化接口。写入磁盘这类耗时动作绝不在DataManager的锁内进行。

为此,这一层不再单纯依赖 std::lock_guard 的作用域自动释放,而是在需要拆分 "内存操作" 与 "数据库 I/O" 的场景下,手动调用 lock() / unlock() 精确控制锁的边界。

具体可以归纳为两条原则:

  • 细粒度锁管理: std::mutex 只保护内存映射表 _sessions,数据库访问由 DataManager 内部锁单独管控,职责彻底拆开
  • I/O 避锁: 执行 insertMessage 等落盘操作前主动释放内存锁,避免磁盘延时长时间卡住内存表,保证其他线程仍能正常读取会话

2.3 双向同步策略

写操作,包括创建会话、新增消息、更新会话时间戳、删除会话,统一遵循 "先改内存、后落盘" 的顺序:内存修改完成后立即释放锁,再调用 DataManager 写库。

读操作,包括获取会话、读取历史消息,统一遵循 "内存优先":先查内存缓存,未命中再访问数据库,并且将查询结果回填到内存,后续请求直接复用缓存,减少数据库查询的开销。

冷启动加载策略上,程序启动时只把会话元信息载入内存,消息采用懒加载 ------ 只有真正访问某个会话时才去读它的消息,避免一次性把全部历史消息读进内存造成占用膨胀。

2.4 消息驱动的会话更新时间联动

addMessage 追加消息时,除了把消息写进数据库的 messages 表,还要同步更新该会话在 sessions 表中的 updated_at。这样会话的活跃时间始终跟随最新一次交互变化,前端会话列表按 updated_at 倒序展示时,最近聊过的会话自然排在最上面。

消息 ID 的生成采用时间戳加会话内消息序号的组合格式,保证同一会话内消息顺序稳定,也避免多线程下 ID 重复。

2.5 会话列表与数据一致性保证

getSessionLists 整合两个数据源:数据库中的持久会话和当前内存中的活跃会话,合并后统一按 updated_at 降序排列返回给上层,保证列表顺序反映真实的交互活跃度。

deleteSession 删除会话时,先移除内存哈希表中的会话记录并释放锁,再调用 DataManager 删除数据库会话记录;消息的清理完全交给数据库外键的级联删除,不在业务代码里重复遍历,既简化逻辑也避免出现 "内存已删、库里残留" 的不一致状态。

三、SessionManager 成员函数改造实现

3.1 createSession 创建会话

业务逻辑:

  1. 生成唯一 sessionId。
  2. 构造 Session 对象,初始化 _session_id、_model_name、_created_at、_updated_at,创建时间与更新时间初始值相等。
  3. 加锁,完成内存 _sessions 哈希表写入,紧接着释放锁。
  4. 锁释放之后,调用 _dataManager.insertSession(*session),把会话信息持久化写入 SQLite。
  5. 持久化成功返回 sessionId;持久化失败返回空字符串。

核心设计要点: 内存修改和数据库 I/O 做锁边界拆分。_mutex 只保护内存哈希表的读写;数据库操作属于耗时磁盘 I/O,放到锁范围之外执行,避免长时间持有内存锁阻塞其他线程访问会话表。

完整代码:

cpp 复制代码
// 创建会话:根据模型名称创建一个新的会话
std::string SessionManager::createSession(const std::string &modelName)
{
    // 加锁保护,确保在多线程环境下创建会话、写入内存哈希表的过程线程安全
    _mutex.lock();

    // 创建会话对象智能指针
    std::shared_ptr<Session> session = std::make_shared<Session>(modelName);
    // 生成唯一会话ID
    std::string sessionId = generateSessionId();
    // 将会话ID与会话对象绑定
    session->_session_id = sessionId;
    // 初始化创建时间、更新时间,新建会话两个时间戳相等
    session->_created_at = session->_updated_at = std::time(nullptr);
    // 将会话对象存入内存哈希表
    _sessions[sessionId] = session;

    // 关键:内存操作完成,立刻释放内存锁
    // 数据库写入是磁盘I/O,耗时不确定,不能放在锁内执行
    _mutex.unlock();

    // 持久化会话信息到SQLite数据库
    if (!_dataManager.insertSession(*session))
    {
        // 数据库写入失败,返回空字符串代表创建失败
        return "";
    }
    return sessionId;
}

代码分析:

  1. _mutex.lock():上锁,保护 _sessions 哈希表,防止并发插入造成容器异常。
  2. 构造 Session 对象、生成 sessionId、赋值时间戳,存入哈希表,完成内存侧会话注册。
  3. _mutex.unlock():重点,内存操作完成就释放锁,不持有锁执行数据库操作。
  4. _dataManager.insertSession(*session):解锁后执行持久化,DataManager 自带独立锁管理数据库并发。

3.2 getSession 获取会话(核心双向读取与并发安全)

3.2.1 优化背景

旧版本接口仅查询内存哈希表,存在两个明显缺陷:

  1. 内存数据易失,程序重启后会话全部丢失;
  2. 忽略磁盘持久化,内存不存在不代表数据库没有对应的会话。

优化后采用**旁路缓存(Cache-Aside)**双层缓存架构:优先读取内存缓存,缓存未命中再查询数据库,从数据库加载成功后回填内存,兼顾查询速度与持久化能力。

注意: 该函数不能加 const 。因为缓存未命中、从数据库加载会话后,需要修改成员变量 _sessions 往内存缓存写入数据 ,const 成员函数禁止修改成员。

3.2.2 业务流程

  1. 加锁访问内存 _sessions 哈希表;
  2. 内存命中:解锁,懒加载会话历史消息,返回会话智能指针;
  3. 内存未命中:解锁,调用 _dataManager.getSession(sessionId) 查询数据库;
  4. 数据库查到会话:再次加锁,双重检查内存,确认内存仍然不存在才写入缓存;解锁、懒加载消息,返回会话;
  5. 数据库无该会话,返回 nullptr。

3.2.3 完整代码及分析

cpp 复制代码
// 获取会话:根据会话ID获取会话对象的智能指针
std::shared_ptr<Session> SessionManager::getSession(const std::string &sessionId)
{
    // 加锁保护,确保在多线程环境下对获取会话是线程安全的
    _mutex.lock();

    // 先在会话映射表(内存)中查找指定会话ID
    auto it = _sessions.find(sessionId);
    // 检查会话映射表中是否存在指定会话ID,如果存在则直接返回会话对象指针
    if (it != _sessions.end())
    {
        // 在返回会话对象指针之前,需要释放会话管理的锁,否则会导致死锁
        _mutex.unlock();
        return it->second;
    }
    // 如果会话映射表中不存在指定会话ID,只是说明本次程序运行时可能还没有加载该会话到内存中,
    // 所以需要从数据库中进行查询,如果数据库中存在该会话,则加载该会话到内存中再返回会话对象指针
    // 从数据库中查询会话信息:操作数据库之前还是一样需要释放会话管理的锁,以确保数据库操作的线程安全
    _mutex.unlock();
    std::shared_ptr<Session> session = _dataManager.getSession(sessionId);
    if (session)
    {
        _mutex.lock();
        // 关键点:双重检查逻辑
        it = _sessions.find(sessionId);
        if (it == _sessions.end())
        {
            // 当我们从数据库中查询到会话信息后,此时会话映射表中可能还没有该会话,
            // 所以需要将该会话添加到会话映射表中,以便后续快速获取会话对象指针
            _sessions[session->_session_id] = session;
            _mutex.unlock();
            return session;
        }
        else
        {
            // 如果会话映射表中已经存在指定会话ID,则直接返回会话对象指针
            // 注意:如果已经存在,则不能重复添加到会话映射表中,这个操作会覆盖掉之前的会话对象指针,否则可能会导致部分数据丢失
            _mutex.unlock();
            // 直接返回已存在会话对象指针,不能返回session
            return it->second;
        }
    }
    else
    {
        // 如果数据库中不存在指定会话ID,则返回空指针,说明获取的会话不存在
        WARN("sessionId: {} not found in database", sessionId);
        return nullptr;
    }
}

重点代码分析:

  1. 先上锁查询内存哈希表,命中直接解锁返回,利用内存缓存加速查询;

  2. 内存没找到,解锁之后再访问数据库,数据库 I/O 不在锁内,避免阻塞其他线程;

  3. 双重检查 Double-Checked Locking(重中之重) 从数据库查询拿到会话快照之后,重新上锁,再次检查内存:

    • 如果内存仍然没有该会话:回填缓存;
    • 如果内存已经存在:说明在查库、等锁的间隙,别的线程已经加载 / 更新了会话,直接复用内存里最新对象,丢弃自己手上的旧快照。

    并发下存在两个危险时间窗口:查库 I/O 阻塞、等待锁阻塞。这两段时间里其他线程可以修改内存,直接覆盖会发生更新丢失、指针偷换,导致已经新增的消息被旧数据覆盖。

为什么需要"双重检查"(Double-Checked Locking)?

在多线程环境下,原代码在**"发现缓存未命中"到"重新加载进内存"** 的过程中,存在两个极其危险的时间窗口,这也是必须进行二次检查的根本原因。

单线程视角:看似多余

如果系统只有单线程,线程在判断内存没有后,去查库。这期间没有任何人能修改内存,所以第二次检查(if (it == _sessions.end()))绝对是成立的,看起来完全多余。

多线程视角:生死攸关的两个"时间窗口"

并发环境下,线程 C(发消息接口)随时可能插足,导致线程 B 拿回的数据变成"过期旧快照"。

  • 时间窗口 1:查库耗时(I/O 阻塞)

    • 场景 :线程 B 发现内存没有,发起 SELECT 查库。在这个几十毫秒的过程中,线程 C 发来一条新消息,写入磁盘并更新了内存。

    • 结果:线程 B 查完返回,手里拿到的是"没包含新消息的旧数据"。

  • 时间窗口 2:等锁阻塞(并发抢锁)

    • 场景 :线程 B 拿着查回来的旧数据,准备执行 _mutex.lock() 写入内存。但在这一瞬间,线程 C 恰好抢先拿到了锁,处理完发消息操作后解锁。线程 B 只能阻塞。

    • 结果 :线程 B 拿到锁时,内存里的数据其实已经被线程 C 更新为最新了,但线程 B 手里的数据依然是旧的。

3.2.4 设计要点小结

  • **架构:**旁路缓存模式,内存优先、磁盘兜底;
  • **锁策略:**内存锁只保护哈希表,数据库查询放在锁外;
  • **双重检查的目的:**保证同一个会话只会加载一次,规避并发覆盖、数据回退问题,内存的数据作为权威数据源;
  • **懒加载:**消息不在启动时全量载入,仅访问会话时加载,控制内存占用。

3.3 addMessage 添加会话消息

业务流程:

  1. 加锁查找内存中的会话,会话不存在则解锁返回 false;
  2. 生成消息 ID,构造消息对象,追加到会话内存消息列表,同步更新会话的更新时间戳;
  3. 释放内存锁;
  4. 调用数据库接口持久化消息,数据库内部自动更新会话更新时间。
cpp 复制代码
// 添加消息:将一条消息添加到指定会话的历史消息中
bool SessionManager::addMessage(const std::string &sessionId, const Message &message)
{
    // 加锁保护,确保在多线程环境下对添加消息是线程安全的
    _mutex.lock();

    auto it = _sessions.find(sessionId);
    // 检查会话映射表中是否存在指定会话ID
    if (it != _sessions.end())
    {
        // 会话存在,直接添加消息,并且将消息存储到数据库中
        // 创建消息
        Message msg(message._role, message._content);
        // 生成消息ID
        std::string messageId = generateMessageId(it->second->_messages.size());
        // 将消息ID添加到消息对象中
        msg._message_id = messageId;
        // 设置消息的时间戳为当前时间
        msg._timestamp = std::time(nullptr);
        // 将消息对象添加到会话的历史消息中
        it->second->_messages.push_back(msg);
        // 添加消息同时-更新会话的更新时间戳为消息的时间戳
        it->second->_updated_at = msg._timestamp;
        INFO("add message success, messageId: {}, messageContent: {}", messageId, msg._content);

        // 将会话信息添加到数据库中进行持久化存储
        _mutex.unlock();
        if (!_dataManager.insertMessage(sessionId, msg))
        {
            // 数据库操作失败,返回false
            return false;
        }
        return true;
    }
    else
    {
        _mutex.unlock();
        return false;
    }
}

重点代码分析:

  1. 锁只保护内存哈希表与会话消息容器的读写,数据库操作放到锁外执行,防止慢数据库 IO 阻塞其他线程;
  2. 先更新内存缓存,再落盘数据库;内存作为权威数据源;
  3. 自动生成消息 ID、填充时间戳,同步更新会话的_updated_at;
  4. 会话不存在直接返回 false;数据库写入失败向上返回 false,便于上层感知持久化失败。

3.4 getHistoryMessages 获取会话全部历史消息

业务逻辑:

  1. 加锁优先查询内存哈希表,如果会话存在,解锁后直接返回内存中的消息列表;
  2. 内存未找到会话,解锁后调用数据库接口读取消息;
  3. 数据库查到消息:再次上锁,双重检查内存,确认内存仍然不存在,则把会话回填进内存缓存,再返回消息;
  4. 数据库无对应消息,返回空 std::optional。
cpp 复制代码
// 获取会话历史消息:根据会话ID获取指定会话的所有历史消息
std::optional<std::vector<Message>> SessionManager::getSessionMessages(const std::string &sessionId)
{
    // 加锁保护,确保在多线程环境下对获取会话历史消息是线程安全的
    _mutex.lock();

    auto it = _sessions.find(sessionId);
    // 先从内存中获取指定会话的历史消息
    if (it != _sessions.end())
    {
        // 会话存在,直接返回会话的所有历史消息
        _mutex.unlock();
        return it->second->_messages;
    }
    else
    {
        BUG("sessionId: {} not found in memory", sessionId);
    }

    // 如果会话映射表中不存在指定会话ID,说明在内存中可能没有加载该会话
    // 所以需要从数据库中进行查询,如果数据库中存在该会话,则加载该会话到内存中再返回会话的所有历史消息
    _mutex.unlock();
    if (auto res = _dataManager.getSessionMessages(sessionId))
    {
        _mutex.lock();
        std::vector<Message> messages = res.value();
        // 关键点:双重检查逻辑
        it = _sessions.find(sessionId);
        if (it == _sessions.end())
        {
            // 当我们从数据库中查询到会话信息后,此时会话映射表中可能还没有该会话,
            // 所以需要将该会话添加到会话映射表中,以便后续快速获取会话历史消息
            _sessions[sessionId] = _dataManager.getSession(sessionId);
            _mutex.unlock();
            return messages;
        }
        _mutex.unlock();
        return it->second->_messages;
    }
    else
    {
        // 说明获取会话历史消息失败,返回空opt
        return std::nullopt;
    }
}

重点代码分析:

  1. 锁只保护内存哈希表查询,数据库查询放在锁外,避免阻塞;
  2. 沿用旁路缓存 + 双重检查的并发思路:查库之后再次校验内存,防止并发覆盖问题;
  3. 从数据库读到消息后,同步将会话对象写入内存缓存,后续访问直接走内存;
  4. 使用 std::optional 作为返回值,区分 "查到空消息" 和 "查询失败" 两种场景。

3.5 getSessionLists 获取全部会话 ID 列表

业务逻辑:

**场景:**前端侧边栏渲染历史会话列表,只返回 sessionId,消息按需加载。

  1. 先从数据库拿到全部会话集合;
  2. 加锁,遍历内存中已加载的会话,把会话 ID、更新时间戳存入临时数组;
  3. 遍历数据库会话,将内存中不存在的会话补充进来,同时同步加载到内存缓存;
  4. 按照会话更新时间戳降序排序,最新会话排在前面;
  5. 提取所有会话 ID,组装成列表返回;数据库查询失败返回空 std::optional。
cpp 复制代码
// 获取所有会话ID列表:返回所有当前存在的会话ID
std::optional<std::vector<std::string>> SessionManager::getSessionLists()
{
    // 从数据库中获取所有会话ID列表
    auto sessions = _dataManager.getAllSessions();

    // 加锁保护,确保在多线程环境下对获取会话列表是线程安全的
    _mutex.lock();

    // 首先在内存中获取已有的所有会话ID列表
    std::vector<std::pair<std::string, std::time_t>> temp;
    temp.reserve(_sessions.size()); // 预留足够的空间,避免多次内存分配
    // 注意:不能直接使用带参数的构造函数,因为会立即构造对象,导致temp已经包含了_sessions实际的元素个数
    // 如果再使用插入接口,会导致temp的大小再次增加,与_sessions的大小不一致!并且前面的元素都是默认的空元素
    for (auto pair : _sessions)
    {
        temp.emplace_back(pair.first, pair.second->_updated_at);
    }

    // 再从数据库中获取内存中没有加载的会话ID列表,并且同步加载到内存中,
    // 以便后续从内存快速对会话进行操作,而无需再次查询数据库
    if (sessions.has_value())
    {
        for (auto &session : sessions.value())
        {
            // 检查内存会话映射表中是否存在该会话ID,如果不存在则将其添加到会话映射表中并且同步加载到内存中,如果存在则跳过
            if (_sessions.find(session->_session_id) == _sessions.end())
            {
                temp.emplace_back(session->_session_id, session->_updated_at);
                // 将会话对象同步添加到内存的会话映射表中
                _sessions[session->_session_id] = session;
            }
        }
        // 将内存和数据库拿到的所有会话,按照会话的更新时间戳从大到小排序,确保最新的会话在列表的前面
        std::sort(temp.begin(), temp.end(), [](const auto &a, const auto &b)
                  { return a.second > b.second; });

        std::vector<std::string> sessionIds;
        sessionIds.reserve(temp.size());
        for (const auto &pair : temp)
        {
            sessionIds.push_back(pair.first);
        }

        _mutex.unlock();
        return sessionIds;
    }
    else
    {
        // 数据库操作失败,返回空opt
        _mutex.unlock();
        return std::nullopt;
    }
}

重点代码分析:

  1. 使用 reserve 预分配内存 ,规避之前踩过的坑:禁止用 vector(n) 带参构造,防止出现空元素;
  2. 合并两处来源:内存已有会话 + 数据库新增会话,数据库里不在内存的会话会自动载入缓存;
  3. 统一按更新时间降序排序,保证前端列表顺序符合预期;
  4. 锁保护内存哈希表的读写,数据库查询放在锁之前;
  5. 返回 std::optional,区分数据库查询失败和正常空列表。

3.6 updateSessionTimestamp 更新会话时间戳

业务逻辑:

  1. 加锁查找内存会话,如果会话存在,更新内存里的_updated_at为当前时间,解锁后同步更新数据库;
  2. 内存不存在会话,解锁后去数据库查询;
  3. 数据库查到会话:再次上锁,将会话载入内存缓存,更新时间戳,解锁后同步落库;
  4. 数据库也不存在该会话,打印警告日志,直接返回。
cpp 复制代码
// 更新会话时间戳:根据会话ID更新指定会话的更新时间戳
void SessionManager::updateSessionTimestamp(const std::string &sessionId)
{
    // 加锁保护,确保在多线程环境下对更新会话时间戳是线程安全的
    _mutex.lock();

    auto it = _sessions.find(sessionId);
    // 先检测内存会话映射表中是否存在指定会话ID
    if (it != _sessions.end())
    {
        // 内存中存在则直接更新会话的更新时间戳为当前时间
        it->second->_updated_at = std::time(nullptr);
        _mutex.unlock();
        // 并且同步更新数据库中的会话更新时间戳
        _dataManager.updateSessionTimestamp(sessionId, it->second->_updated_at);
    }
    else
    {
        _mutex.unlock();
        // 内存中不存在该会话ID,说明会话可能在数据库中,所以需要从数据库中查询
        if (auto session = _dataManager.getSession(sessionId))
        {
            _mutex.lock();
            // 数据库中存在该会话,将其加载到内存会话映射表中
            _sessions[sessionId] = session;
            // 更新会话的更新时间戳为当前时间
            _sessions[sessionId]->_updated_at = std::time(nullptr);
            _mutex.unlock();
            // 并且同步更新数据库中的会话更新时间戳
            _dataManager.updateSessionTimestamp(sessionId, _sessions[sessionId]->_updated_at);
        }
        else
        {
            WARN("updateSessionTimestamp-sessionId: {} not found in database", sessionId);
        }
    }
}

重点代码分析:

  1. 内存命中场景:先改缓存、释放锁,再执行数据库更新,避免锁持有阻塞;
  2. 内存未命中场景:回源数据库查询,查到之后自动加载进内存缓存,做到懒加载;
  3. 兼顾缓存和持久化,保证内存与数据库的更新时间戳一致;
  4. 数据库找不到会话时打印警告,方便问题排查。

3.7 deleteSession 删除指定会话

业务逻辑:

  1. 加锁,在内存哈希表查找会话;找不到则解锁,返回 false;
  2. 找到会话后,从内存映射表中擦除该会话,释放锁;
  3. 调用数据库删除接口,删除数据库会话,依靠外键级联删除自动清理该会话下所有关联消息;
  4. 将数据库删除接口的返回值作为函数最终返回值。
cpp 复制代码
// 删除会话:根据会话ID删除指定会话
bool SessionManager::deleteSession(const std::string &sessionId)
{
    // 加锁保护,确保在多线程环境下对删除会话是线程安全的
    _mutex.lock();

    auto it = _sessions.find(sessionId);
    // 检查会话映射表中是否存在指定会话ID,如果不存在则返回false
    if (it == _sessions.end())
    {
        _mutex.unlock();
        return false;
    }
    // 从会话映射表中删除指定会话ID
    _sessions.erase(it);

    _mutex.unlock();
    // 同步删除数据库中的会话
    return _dataManager.deleteSession(sessionId);
}

重点代码分析:

  1. 先操作内存缓存,移除对应会话,锁只保护内存哈希表操作,数据库删除放在锁外,减少锁持有时间;
  2. 内存删除成功不代表整体成功,最终结果由数据库删除接口返回值决定;
  3. 依赖数据库外键级联特性,删除会话时自动清除关联消息,业务层不需要单独删消息;
  4. 线程安全由互斥锁保证,防止并发删除、读取内存会话容器产生异常。

3.8 deleteAllSessions 清空全部会话

业务逻辑:

  1. 加锁,清空内存会话映射表,同时将会话计数器重置为 0;
  2. 释放互斥锁;
  3. 调用数据库接口清空全部会话记录,依靠外键级联自动删除所有关联消息。
cpp 复制代码
// 删除所有会话:删除所有当前存在的会话
void SessionManager::deleteAllSessions()
{
    // 加锁保护,确保在多线程环境下对删除所有会话是线程安全的
    _mutex.lock();

    // 清空会话映射表,删除所有会话
    _sessions.clear();
    // 重置会话总数为0
    _sessionsCounter = 0;

    _mutex.unlock();
    // 同步删除数据库中的所有会话
    _dataManager.deleteAllSessions();
}

重点代码分析:

  1. 锁只保护内存容器和计数器的修改,数据库操作放到锁外面,缩短锁持有时间,降低并发阻塞;
  2. 除了_sessions哈希表clear(),额外重置会话计数器,保证内存状态完全干净;
  3. 数据库侧依靠外键级联,会话表清空后,关联消息表自动清理,业务层无需单独处理消息;
  4. 和前面删除单个会话的缓存优先逻辑保持一致,先清内存缓存,再落库删除。

3.9 getSessionsCount 获取会话总数

业务逻辑:

这里提供两种等价的实现思路:

  1. 读取内存哈希表 _sessions.size():项目冷启动时把数据库会话全部加载进内存,新增、删除会话时同步维护内存容器,保证内存计数和数据库一致,读取速度更快;
  2. 直接调用 _dataManager.getSessionCount() 实时查询数据库,数据绝对准确,但会产生数据库 IO 开销。

当前我们选用第一种方案:读取内存缓存计数。

cpp 复制代码
// 获取会话总数:返回当前存在的会话总数
size_t SessionManager::getSessionsCount() const
{
    std::lock_guard<std::mutex> lock(_mutex);
    return _sessions.size();
}

重点代码分析:

  1. 函数标记为 const,符合只读查询接口的语义;
  2. 使用 lock_guard 自动加锁、自动释放锁,RAII 机制避免忘记解锁;
  3. 只读取内存容器大小,没有数据库访问,性能开销很低;
  4. 前提:整个 SessionManager 的新增、删除、清空逻辑都同步维护内存容器,保证内存计数和数据库一致。

四、单元测试:会话持久化增删查验证

4.1 测试说明

包含两组 GTest 测试用例,用来验证SessionManager与DataManager的协同持久化能力:

第一个用例负责创建会话、添加消息,数据同时写入内存与 SQLite 数据库;程序结束后内存数据释放,但数据保存在磁盘库文件中。

第二个用例重新启动、加载数据库内历史数据到内存,完成会话查询、消息读取与会话删除,验证重启后数据恢复、删除双向同步(内存 + 数据库)。

4.2 测试入口 main 函数

cpp 复制代码
int main(int argc, char **argv)
{
    // 初始化Google Test框架
    testing::InitGoogleTest(&argc, argv);
    // 初始化日志系统
    Logger::logger_init("testLLM", "stdout", spdlog::level::info);
    // 运行所有测试用例
    return RUN_ALL_TESTS();
}

入口是 GTest 标准启动流程,先完成测试框架与日志组件初始化,再自动执行全部测试用例,日志输出到控制台,方便我们观察数据库操作过程。

4.3 测试用例 1:创建会话与消息持久化

cpp 复制代码
// 单元测试:创建会话并插入消息,同步持久化到数据库
TEST(DataManagerAndSessionManagerTest, createSession)
{
    // 1、创建SessionManager对象,并且连接数据库
    SessionManager sessions(dbName: "chat_testDB.db");
    // 2、创建多个会话
    std::cout << "----------------创建多个会话----------------" << std::endl;
    std::string deepseek_id = sessions.createSession(modelName: "deepseek-flash");
    ASSERT_TRUE(deepseek_id != "");
    sleep(seconds(1));

    std::string gemini_id = sessions.createSession(modelName: "gemini-3.6-flash");
    ASSERT_TRUE(gemini_id != "");
    std::cout << "deepseek_id: " << deepseek_id << std::endl;
    std::cout << "gemini_id: " << gemini_id << std::endl;
    std::cout << "sessionsCount: " << sessions.getSessionsCount() << std::endl;

    sleep(seconds(1));

    // 3、添加消息到会话
    std::cout << "----------------添加消息到会话----------------" << std::endl;
    bool res;
    res = sessions.addMessage(sessionId: deepseek_id, message: {role: "user", content: "hello"});
    ASSERT_TRUE(res);
    res = sessions.addMessage(sessionId: deepseek_id, message: {role: "user", content: "deepseek-flash"});
    ASSERT_TRUE(res);

    res = sessions.addMessage(sessionId: gemini_id, message: {role: "user", content: "hello"});
    ASSERT_TRUE(res);
    res = sessions.addMessage(sessionId: gemini_id, message: {role: "user", content: "gemini-3.6-flash"});
    ASSERT_TRUE(res);
}

代码分析:

  1. 实例化SessionManager,自动完成数据库连接;
  2. 调用createSession生成两个不同模型的会话,会话对象存入内存容器,同时调用DataManager写入数据库;
  3. 调用addMessage追加对话消息,消息同步存入内存和数据库;
  4. 用断言校验操作成功。

程序执行结束后,SessionManager对象销毁,内存会话数据清空,但是数据库文件chat_testDB.db保留全部记录。

运行结果展示:

4.4 测试用例 2:加载历史会话、查询消息、删除会话

cpp 复制代码
// 单元测试:重启后从数据库加载会话,查询消息、删除会话
TEST(DataManagerAndSessionManagerTest, getSessionLists)
{
    // 1、重建SessionManager,连接数据库,自动加载库中历史会话到内存
    SessionManager sessions(dbName: "chat_testDB.db");

    // 2、获取所有会话ID列表
    std::cout << "----------------获取所有会话ID列表----------------" << std::endl;
    auto sessionsIds = sessions.getSessionLists();
    for (auto &session_id : sessionsIds.value())
    {
        std::cout << session_id << std::endl;
    }

    // 3、遍历会话,读取每条历史消息
    std::cout << "----------------获取所有会话消息----------------" << std::endl;
    for (auto &session_id : sessionsIds.value())
    {
        auto messages = sessions.getSessionMessages(sessionId: session_id.value());
        std::cout << "session_id: " << session_id << std::endl;
        for (auto &message : messages.value())
        {
            std::cout << "message_id: " << message.message_id << std::endl;
            std::cout << "role: " << message.role << std::endl;
            std::cout << "content: " << message.content << std::endl;
            std::cout << "timestamp: " << message.timestamp << std::endl;
            std::cout << "----------------" << std::endl;
        }
    }
    sleep(seconds(2));

    // 4、删除指定会话
    std::cout << "----------------删除指定会话----------------" << std::endl;
    bool res = sessions.deleteSession(sessionId: sessionsIds.value()[0]);
    ASSERT_TRUE(res);

    // 5、删除后再次查询,校验删除效果
    std::cout << "----------------删除指定会话后,再获取所有会话的历史消息----------------" << std::endl;
    auto newSessionsIds = sessions.getSessionLists();
    for (auto &session_id : newSessionsIds.value())
    {
        auto messages = sessions.getSessionMessages(sessionId: session_id.value());
        std::cout << "session_id: " << session_id << std::endl;
        for (auto &message : messages.value())
        {
            std::cout << "message_id: " << message.message_id << std::endl;
            std::cout << "role: " << message.role << std::endl;
            std::cout << "content: " << message.content << std::endl;
            std::cout << "timestamp: " << message.timestamp << std::endl;
            std::cout << "----------------" << std::endl;
        }
    }
}

代码分析:

  1. 重新创建SessionManager,构造阶段自动从 SQLite 读取上一轮保存的会话、消息,加载进内存;
  2. getSessionLists获取全部会话 ID,getSessionMessages读取会话内历史消息,核对数据和上一轮写入一致;
  3. deleteSession同时清理内存容器内会话,并且删除数据库中对应会话与关联消息;
  4. 删除后再次查询会话列表,验证被删除会话不再出现,剩余会话数据完好。

4.5 运行结果与核心结论

  • 第一轮测试:创建会话、消息,内存 + 数据库双写;程序退出内存清空,磁盘数据留存;
  • 第二轮测试:冷启动自动加载数据库数据,历史对话正常恢复;
  • 删除操作同步作用于内存与数据库,保证两边数据一致;
  • 整套链路实现:内存做高速访问缓存,SQLite 作为持久化存储底座,满足 SDK 重启后对话不丢失的基础需求。

结束语

到这里,SessionManager与会话持久化模块的改造与验证工作就全部完成了。我们将内存会话管理和 SQLite 数据管理器做了整合,明确了双向同步的规则、并发锁防护方案,对会话创建、消息追加、查询、删除等接口逐一改造,保证内存缓存与磁盘数据库的数据一致性。

两组 GTest 测试用例形成了完整验证闭环:会话数据写入落盘、程序重启后自动加载历史对话、删除操作同步更新内存与数据库。这套设计以内存作为高速访问缓存,SQLite 作为持久化底座,兼顾接口访问速度与数据不丢失的需求,也和我们 SDK 整体的分层架构保持一致。

会话持久化是大模型 SDK 贴近实际业务的关键一环,后续我们就可以带着这套存储能力,把会话上下文接入到 LLMManager 的调用链路中,实现带历史对话的模型请求。

相关推荐
思麟呀33 分钟前
背包DP(01背包+完全背包)
c++·算法
AllData公司负责人1 小时前
AI大模型数据开发底座系列说明书:AllData集成DB-GPT开源项目建设 AI 多模态分析底座,涵盖模态解析/智能大屏/MCP 扩展!
大数据·数据库·人工智能·gpt·数据采集·数据接入·模态解析
2601_967987371 小时前
Sqlite参考手册
数据库·python·sqlite·api·教程
Funlang1 小时前
龙芯上的 Fun:从跑起来到机器码
开发语言·龙芯
总要学点什么2 小时前
第104篇:三维特效-流动墙体效果——让贴图沿墙流动,它却只肯走一条对角线
开发语言·javascript·ecmascript·gis·webgl·材质·cesium
隔窗听雨眠2 小时前
Oracle日志体系分层结构与故障定位路径
数据库·oracle·故障排查
为思念酝酿的痛2 小时前
C语言操作MySQL
数据库·mysql
旖旎夜光2 小时前
【C++ 基础入门】C++11 新特性(中):右值引用、移动语义与完美转发
开发语言·c++·笔记·学习
ShineWinsu2 小时前
对于Qt:QTabWidget常见属性的解析
开发语言·数据库·c++·qt·浏览器·qtabwidget·标签页