从内存到持久化:我的非遗 AI 对话系统架构升级实录

1. 背景与痛点(Why)

初始状态
最开始为了快速验证 AI 对接功能,我把对话历史直接存在 List<Map> 中,依托于 InMemoryChatMemory 的内存变量。这种方案在单机开发时非常爽快,但随着项目模拟多用户场景,三个致命问题暴露无遗:

  1. 数据“裸奔”与隐私风险:内存中没有严格的用户隔离,光靠一个 sessionId 难以完全防止不同用户间的隐私数据越界访问。
  2. 重启即丢失:服务一旦重启或部署,用户聊了一晚上的非遗知识瞬间清零,毫无持久性可言。
  3. 扩容死穴:如果未来做多实例部署,用户请求落到不同服务器,由于内存不共享,根本查不到之前的对话上下文。
  4. OOM 隐患:随着对话增多,内存占用直线飙升,随时可能触发 OOM(内存溢出)。

决策
必须引入 MySQL 做数据持久化(兜底),引入 Redis 做热点加速(性能),彻底重构存储层。

2. 方案设计(Design)

2.1 业务模型升级

首先支持多模态对话(文本 + 图文),并重构会话模型:

  • 强绑定用户:在会话元数据中强制增加 user_id,将会话与用户严格绑定,从根源杜绝恶意访问他人对话。
  • 分层存储策略
    • 热数据(最近对话):存入 Redis,设置 TTL(1-2 天),利用其高性能支撑高频读写。
    • 冷数据(全量归档):异步/同步落入 MySQL,用于长期留存、审计及后续的知识库推荐分析。

2.2 架构流程图

AI 聊天流程

AI 导游

文本

多模态

1. 先写缓存
2. 后写数据库

MainApp

用户操作

发起聊天

聊天类型

文本对话

图文对话

Redis 聊天记忆

AI 响应

保存消息

MySQL 数据库

2.3 选型与表结构设计

为什么用 Redis?
对话具有极强的“时序性”和“局部性”。用户通常只关注最近几轮对话。Redis 的 List 结构天然适合存储对话流,且读写速度是毫秒级,能极大降低 DB 压力。

为什么用 MySQL?
作为“单一事实来源(Source of Truth)”。我需要记录谁(user_id)、在什么时间(create_time)、问了什么(content),以便后面做智能推荐。

核心表结构:

  • ai_chat_session (会话元数据表):记录会话维度的信息。

    create table ai_chat_session (
        session_id       varchar(64) not null primary key,
        user_id          bigint      null, -- 关键:用户绑定
        current_location varchar(100)  null,
        session_context  json        null,
        message_count    int         default 0,
        start_time       datetime    default CURRENT_TIMESTAMP,
        last_active_time datetime    null,
        status           varchar(20) default 'active', -- 软删除标记
        constraint fk_user foreign key (user_id) references user (user_id)
    );
    -- 索引优化:快速查询某用户的最近会话
    create index idx_user_session on ai_chat_session (user_id asc, last_active_time desc);
    
  • ai_chat_message (对话消息表):记录具体的问答内容。

    create table ai_chat_message (
        chat_id      bigint     not null primary key,
        session_id   varchar(64) not null,
        role         varchar(20) not null, -- user/assistant
        content      text       null,
        message_type varchar(20) default 'text',
        tool_calls   json       null,
        metadata     json       null,
        create_time  datetime   default CURRENT_TIMESTAMP,
        constraint fk_session foreign key (session_id) references ai_chat_session (session_id)
    );
    -- 索引优化:快速拉取某会话下的所有消息
    create index idx_session_time on ai_chat_message (session_id, create_time);
    

3. 实施过程中的“痛苦”与解决(The Struggle)

这部分是本次重构最核心的价值,也是我踩坑最深的地方。

难点一:数据结构转换与序列化

问题:内存中是简单的 Java 对象,落库需要序列化,且 Redis 和 MySQL 的存储格式不一致。
解决
我引入了 JacksonObjectMapper 进行统一处理。

  • 定义了一个中间 DTO 对象 Msg,专门用于屏蔽底层 Message 对象的复杂性。
  • 在写入前,将 Message 转为 Msg 再序列化为 JSON 字符串存入 Redis List 或 MySQL 文本字段。
  • 在读取时,反序列化回 Msg 再还原为 Message
  • 心得:不要直接把实体类扔进缓存,中间层隔离能让后续重构更容易

难点二:双写顺序与一致性陷阱(核心)

问题:先写 Redis 还是先写 MySQL?如果一边成功一边失败怎么办?
我的决策与实现
我采用了 “先 Redis 后 MySQL” 的策略,以保证用户感知的响应速度。

  • 会话索引写入逻辑 (DatabaseChatHistoryRepository.save):

    @Override
    public void save(String type, String sessionId, Long userId) {
        // 1. 先写 Redis:确保用户立刻能在列表中看到新会话
        redisUtils.save(type, sessionId, userId); 
        
        // 2. 后写 MySQL:确保持久化
        saveOrUpdateSession(sessionId, userId); 
    }
    
  • 对话内容写入逻辑 (RedisChatMemory.add):
    为了极致性能,我决定对话详情(Message)暂时只存 Redis,不实时同步到 MySQL(通过定时任务异步归档),避免频繁 IO 阻塞 AI 回复。

新的痛点:事务边界失效
在实现删除功能时,我遇到了大坑。我使用了 Spring 的 @Transactional 注解,原本指望它能保证 Redis 和 MySQL 要么都成功,要么都失败。

@Override
@Transactional(rollbackFor = Exception.class)
public void delete(String type, String sessionId, Long userId) {
    // ⚠️ 陷阱:Redis 操作不在 Spring 事务管理范围内!
    redisUtils.delete(type, sessionId, userId); 
    
    // 只有这里的 MySQL 操作受事务保护
    chatSessionMapper.deleteById(...); 
}

风险推演:如果 Redis 删除成功了,但后续 MySQL 更新因为权限校验失败抛出了异常,Spring 会回滚 MySQL 操作,但Redis 的数据已经删了,回滚不了! 这会导致“数据库里有记录,但缓存里没了”的数据不一致。

我的修正方案

  1. 前置校验:在操作 Redis 之前,先查数据库校验权限,尽量让异常在“污染”Redis 之前就抛出。
  2. 承认不足与规划:我在代码注释中明确标记了此风险,并规划了后续引入**“本地消息表”“MQ 最终一致性”**方案来彻底解决跨源事务问题。这让我明白,@Transactional 不是万能药,它管不了 Redis。

难点三:AI 辅助的得与失

实话实说
刚开始重构时,我没有头绪然后依赖 AI 生成代码。

  • AI 的坑:它生成的 Redis Key 没有规范前缀(如 project:chat:userId:),导致 Key 杂乱无章;它忽略了双写时的原子性问题,直接给出了看似完美实则有隐患的代码。
  • 我的修正
    • 手动重构了所有 Key 的生成策略,统一命名空间。
    • 审查了每一处双写逻辑,识别出事务失效的风险点。
    • 结论:AI 能帮我写 代码,但架构决策、边界检查、异常处理必须由人来把控。这次重构让我从“调包侠”变成了真正的“设计者”。

4. 最终效果与反思(Result & Reflection)

效果

  1. 数据持久化:服务重启数据不再丢失,用户会话安全隔离。
  2. 性能提升:利用 Redis 承载高频对话读写,数据库压力显著降低。
  3. 架构清晰:形成了“Redis 抗流量 + MySQL 存底线”的标准后端架构。

不足与展望

  1. 同步写库阻塞:目前仍是同步写 MySQL,高并发下可能影响接口 RT。计划:后续学习 RocketMQ,将落库操作异步化。
  2. 数据一致性隐患:删除操作的跨源事务问题尚未完美解决。计划:实现“本地消息表 + 定时对账”机制,保证最终一致性。
  3. 历史数据归档:Redis 容量有限。计划:开发定时任务,将超过 2 天的 Redis 对话“冷备”到 MySQL,释放缓存空间。

总结

这次重构让我深刻体会到:好的架构不是设计出来的,是演进出来的。
从内存到 Redis+MySQL,不仅仅是换了个存储介质,更是对数据一致性、事务边界、性能权衡的一次深度思考。我不再盲目相信 AI 生成的代码,而是学会了审视每一个技术决策背后的代价。这或许就是工程化的魅力所在。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐