数据库与缓存核心概念

数据库与缓存核心概念:AI 应用开发岗(初级)面试突击指南

目标岗位 :AI 应用开发工程师(初级)

内容聚焦 :围绕 AI 应用后端开发中高频使用的数据库优化、缓存设计、高并发处理等场景,讲解 MySQL/PostgreSQL 索引优化与事务、Redis 数据结构与缓存问题解决、缓存策略、分布式锁与限流,以及用 Redis 管理对话 session 和 Prompt 配置热更新。

每个模块均包含 :面试常见问题 → 类比理解 → 原理与术语说明 → 详细注释的 AI 场景伪代码 → 完整运行结果 → 常见面试题及参考回答。


一、MySQL/PostgreSQL:索引优化与事务(新手友好版,含详细注释)

【面试问题】

"用户的提问历史表越来越大,根据用户 ID 和对话时间查询越来越慢,怎么办?如何用 EXPLAIN 分析一条 SQL 为什么慢?事务隔离级别对并发读写有什么影响?"

【类比】

一本书的目录

想象一本没有目录的 1000 页的书,你要找一个知识点,只能从第 1 页一直翻到最后------这就是全表扫描 ,极慢。

如果在书的最前面加上目录,按章节标题排序,你只要十几秒就能定位到具体页码------这就是索引

联合索引 就像在目录里同时按"作者 + 书名"排序,但如果你只看书名不看作者,目录对你没用,还是得逐页翻------这就是最左前缀原则

EXPLAIN 就是让图书管理员告诉你:"你刚才找书的时候翻了哪些书架、用了目录吗、翻了多少页",帮你分析为什么慢。

事务隔离就像你在图书馆借阅一本正在被作者修改的书:不同借阅规则决定了你是能借到最新版本,还是只能看到之前版本,或者干脆被拒。

【原理与术语】

什么是索引?

数据库里的索引,就像书的目录。它单独维护一个数据结构,记录了某列(或多列)的值和对应行的物理位置。查询时,数据库先查索引找到位置,再去取整行数据,避免一行行扫描。

B+Tree 索引(通俗理解)

MySQL InnoDB 引擎默认使用 B+Tree 索引。你可以把它想象成一个多层的、自动按顺序排序的目录:

  • 叶子节点 (最底层)存的是完整的数据或指向数据的位置,它们像书页一样顺序排列,并且页与页之间有双向链接(像链条),这使得范围查询(比如"最近 7 天的记录")非常高效。
  • 非叶子节点(上层)只存"路标",比如"第 1 页到第 50 页的概要是 A~H,第 51 页到第 100 页的概要是 I~Q"等等,这样查找时能快速跳到目标区域,不需要翻遍每一页。
联合索引与最左前缀原则

当你在多个列上建立联合索引时,比如 (user_id, created_at),目录会先按 user_id 排序,对于同一个 user_id 再按 created_at 排序 。这就导致了一个规则:查询条件必须包含索引的最左列 user_id 才能利用这个目录

  • ✅ 查 WHERE user_id = 'u1' → 能用索引。
  • ✅ 查 WHERE user_id = 'u1' AND created_at > '2025-01-01' → 也能用索引。
  • ❌ 查 WHERE created_at > '2025-01-01'不能用这个索引,因为目录不是按时间全局排序的,只能全表扫描。

这就是最佳左前缀原则

EXPLAIN 分析慢查询

在 SQL 前面加上 EXPLAIN,数据库不会真正执行查询,而是告诉你它打算怎么执行。重点看这几个字段:

  • type :访问类型,从好到差依次是 const > eq_ref > ref > range > index > ALLALL 代表全表扫描,需要优化。
  • key:实际使用的索引名称。如果为 NULL,说明没用索引。
  • rows:估算需要扫描的行数,越少越好。
  • Extra :额外信息,出现 Using filesort 表示需要额外排序(可能慢),Using temporary 表示用了临时表(通常更慢)。
事务与 ACID

事务是一个不可分割的操作序列,银行转账就是经典例子:扣钱和加钱必须同时成功或同时失败。事务具备 ACID 特性:

  • 原子性:要么全做,要么全不做。
  • 一致性:事务前后数据必须满足业务规则。
  • 隔离性:并发事务之间互不干扰。
  • 持久性:一旦提交,数据永久保存。
事务隔离级别与并发问题(脏读、不可重复读、幻读)

多个事务同时操作同一行数据时,可能产生三种不一致现象,隔离级别就是解决这些问题的方案。

1. 脏读(Dirty Read)

一个事务读到了另一个事务尚未提交 的修改。如果那个事务后来回滚了,读到的数据就是"脏"的,实际上从未真正存在过。

例子:事务 A 更新用户余额为 100,事务 B 读到了 100,然后事务 A 回滚,余额还是原来的 50。事务 B 读到的 100 就是脏数据。

2. 不可重复读(Non-Repeatable Read)

同一个事务内,两次读取同一行数据,结果不一样。因为中间有其他事务修改并提交 了该行。

例子:事务 A 第一次读取余额为 100,事务 B 修改余额为 200 并提交,事务 A 再次读取余额变成了 200。事务 A 的两次读不一致。

3. 幻读(Phantom Read)

同一个事务内,两次查询同一批数据,结果的行数不一样。因为中间有其他事务插入或删除了符合条件的数据 并提交。重点在于"多出来或少了几行"。

例子:事务 A 查询所有余额大于 100 的用户,有 5 条。事务 B 插入了一个余额 200 的新用户并提交,事务 A 再次查询变成了 6 条。

MySQL InnoDB 的四种隔离级别对这三种现象的容忍度:

隔离级别 脏读 不可重复读 幻读
读未提交 可能 可能 可能
读已提交 不可能 可能 可能
可重复读(默认) 不可能 不可能 不可能(InnoDB 通过 Next-Key Lock 解决)
序列化 不可能 不可能 不可能

简单理解:隔离级别越高,并发能力越差,但数据一致性越好。大多数场景用默认的可重复读即可满足需求。

InnoDB 的行锁、间隙锁与 Next-Key Lock

为了在可重复读级别下实现高性能并发控制,InnoDB 使用了多种锁定算法。结合 AI 应用开发中可能遇到的高并发场景,我们把这些锁讲清楚。

类比:停车场的车位管理

  • 行锁:你停进了一个车位,这个车位就被你独占了,别人不能再用这个车位。这就是"锁定某一行数据"。
  • 间隙锁:你不仅要停车,还告诉管理员:"我停好后,我车的前后两个空位也给我留着,不许新车停进来"。这就锁住了"不存在的空位",也就是数据行之间的间隙。
  • Next-Key Lock :上面两步一起做------既锁住你停的车位,也锁住前后空位。这就是 InnoDB 处理幻读的默认武器:行锁 + 间隙锁

详细解释

  • 行锁(Record Lock) :直接锁住某一行索引记录。例如,锁住 id = 5 的那条对话记录。它防止其他事务在同一时刻修改或删除这一行,解决脏读不可重复读
  • 间隙锁(Gap Lock) :锁住一个范围,但不包括记录本身。例如,在对话表中,锁住 (user_id 在 'user1' 到 'user5') 之间的间隙,但不锁具体行。这意味着其他事务不能在这个范围内插入新行 ,但可以修改已有的行。它专门用来防止幻读
  • Next-Key Lock :行锁和间隙锁的组合,锁住一个左开右闭 的区间。它是 InnoDB 在可重复读隔离级别下默认的锁算法,同时阻止修改和插入,彻底解决幻读。

结合 AI 应用场景理解

假设你在做一个大模型对话的批量任务系统:

  1. 事务 A 查询今天所有 status = 'pending' 的任务,准备分配给空闲的推理 worker。
  2. 与此同时,你的后台调度器(事务 B)正在插入新任务,恰好也 status = 'pending'

如果没有间隙锁,事务 A 第二次检查时,会凭空多出几条任务(幻读),可能导致任务被重复领取或计算错数量。有了 Next-Key Lock,事务 B 在事务 A 提交前,无法在事务 A 检查的 status 索引范围内插入新行,必须等待。

【AI 场景伪代码:对话记录表索引优化与 EXPLAIN 分析】(详细注释)

假设我们有一张保存用户和 AI 对话记录的表,建表语句如下(使用 MySQL 语法):

sql 复制代码
CREATE TABLE conversations (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,   -- 主键,自动递增
  user_id VARCHAR(32) NOT NULL,           -- 用户ID
  session_id VARCHAR(64) NOT NULL,        -- 会话ID
  message TEXT,                           -- 对话内容
  created_at DATETIME NOT NULL            -- 创建时间
);

现在这个表数据量大后,根据 user_id 查询最近对话变得很慢。我们需要建立一个联合索引来加速。

sql 复制代码
-- 创建联合索引:按用户 ID 和时间排序
-- 名称:idx_user_time
-- 列:(user_id, created_at)
-- 作用:先按 user_id 归类,同一用户内按 created_at 排序
CREATE INDEX idx_user_time ON conversations(user_id, created_at);

接下来我们用 Python 代码模拟 EXPLAIN 分析和优化后的查询(使用 pymysql 连接 MySQL):

python 复制代码
import pymysql

# 连接数据库(请确保 MySQL 服务已启动,且存在 ai_chat 库)
conn = pymysql.connect(
    host='localhost',
    user='root',
    password='your_password',
    database='ai_chat'
)

def explain_query(cursor, sql):
    """
    执行 EXPLAIN 语句并打印结果
    EXPLAIN 会返回查询计划,不会真正执行查询
    """
    # 在 sql 前加上 EXPLAIN 关键字
    cursor.execute(f"EXPLAIN {sql}")
    result = cursor.fetchall()  # 获取所有计划行
    print("EXPLAIN 结果:")
    for row in result:
        print(row)  # 打印每一列的详细信息

def get_recent_messages(cursor, user_id, limit=20):
    """
    查询某个用户最近 limit 条消息
    利用 idx_user_time 联合索引快速定位
    """
    sql = """
    SELECT message, created_at
    FROM conversations
    WHERE user_id = %s              -- 使用联合索引最左列
    ORDER BY created_at DESC        -- 利用索引的有序性,无需额外排序
    LIMIT %s                        -- 只取最近 limit 条
    """
    # 执行参数化查询,防止 SQL 注入
    cursor.execute(sql, (user_id, limit))
    return cursor.fetchall()  # 返回所有匹配的行

with conn.cursor() as cur:
    # ---------- 步骤 1:模拟没有索引时的全表扫描 ----------
    # 暂时删除索引(如果存在),制造慢查询环境
    cur.execute("DROP INDEX IF EXISTS idx_user_time ON conversations")

    bad_sql = """
    SELECT * FROM conversations
    WHERE user_id = 'user123'
    ORDER BY created_at DESC
    LIMIT 20
    """
    print("=== 没有索引时的 EXPLAIN ===")
    explain_query(cur, bad_sql)
    # 预期输出:type 列可能是 ALL,key 列为 NULL,rows 接近全表总行数
    # Extra 可能包含 Using filesort,表示需要额外排序

    # ---------- 步骤 2:创建索引并再次分析 ----------
    cur.execute("CREATE INDEX idx_user_time ON conversations(user_id, created_at)")
    print("\n=== 创建索引后的 EXPLAIN ===")
    explain_query(cur, bad_sql)
    # 预期输出:type 变为 ref,key 显示 idx_user_time,rows 大幅减少

    # ---------- 步骤 3:执行实际查询 ----------
    print("\n=== 实际查询结果 ===")
    messages = get_recent_messages(cur, 'user123')
    for msg, timestamp in messages:
        print(f"[{timestamp}] {msg}")

【运行结果】

没有索引时的 EXPLAIN 输出示例:

复制代码
EXPLAIN 结果:
(1, 'SIMPLE', 'conversations', 'ALL', None, None, None, None, 100000, 'Using where; Using filesort')
  • type: ALL 表示全表扫描,遍历了所有行。
  • key: None 表示没有使用任何索引。
  • rows: 100000 估算要扫描 10 万行数据。
  • Extra: Using filesort 说明额外进行了排序操作,非常耗时。

创建索引后的 EXPLAIN 输出示例:

复制代码
EXPLAIN 结果:
(1, 'SIMPLE', 'conversations', 'ref', 'idx_user_time', 'idx_user_time', '32', 'const', 15, 'Using index condition')
  • type: ref 表示使用了非唯一索引的查找,性能远好于 ALL。
  • key: idx_user_time 正是我们创建的联合索引。
  • rows: 15 只扫描了 15 行,因为索引直接定位到了目标用户的数据区块。
  • Extra: Using index condition 表示使用索引条件下推,进一步过滤。

实际查询结果:

复制代码
=== 实际查询结果 ===
[2026-05-31 14:23:00] 用户: 什么是索引?
[2026-05-31 14:22:55] AI: 索引是...

结论 :联合索引 (user_id, created_at) 将原本需要扫描 10 万行的查询优化到了只扫描 15 行,效率提升数千倍。


【常见面试题】

Q1:联合索引 (A, B, C) 能用于哪些查询?

  • 参考回答 :必须从最左列 A 开始使用。能用于 WHERE A=1WHERE A=1 AND B=2WHERE A=1 AND B=2 AND C=3,以及 WHERE A=1 AND C=3(C 部分用不上但 A 能用)。不能用于跳过 A 的查询,如 WHERE B=2WHERE C=3

Q2:用 EXPLAIN 看到 type=ALL 就一定要加索引吗?

  • 参考回答 :不一定。如果表本身很小(如几百行),全表扫描可能比索引更快。或者对全表扫描有专门优化。但大表出现 ALL 通常需要优化。

Q3:可重复读隔离级别如何防止幻读?

  • 参考回答 :InnoDB 在可重复读级别下使用了 Next-Key Lock(行锁 + 间隙锁),不仅锁定已存在的行,还锁住索引记录之间的间隙,防止其他事务插入符合条件的新行,从而避免幻读。

Q4:行锁、间隙锁、Next-Key Lock 分别解决什么问题?

  • 参考回答:行锁防止同一行被同时修改,解决脏读和不可重复读;间隙锁防止在范围内插入新行,解决部分幻读问题;Next-Key Lock 是两者的组合,是 InnoDB 在可重复读隔离级别下解决幻读的默认机制。在 AI 应用的高并发任务调度、并发写入同一会话等场景中,理解这些锁有助于避免数据错乱。

二、Redis 数据结构与缓存三大问题

【面试问题】

"当大量用户同时向大模型请求时,如何避免每次都去查询模型配置库?Redis 有哪些数据结构适合存储对话历史?缓存穿透、击穿、雪崩分别是什么,怎么解决?"

【类比】

速取柜 vs 仓库

  • 内存缓存就像食堂门口的速取柜,常用餐品放这里,伸手就拿,不用每次都去后厨(数据库)现做。
  • 缓存穿透:有人不断点菜单上没有的菜,后厨每次都要查一遍,白忙活。解决办法:把"没有"也记下来(空值缓存)。
  • 缓存击穿:某道热门菜刚好卖完,速取柜空了,瞬间大量订单涌向后厨,把后厨压垮。解决办法:加互斥锁,只让一个人去后厨补货,其他人等着。
  • 缓存雪崩:速取柜整体断电,所有订单同一时刻都涌向后厨,后厨崩。解决办法:设置不同的过期时间,或做集群高可用。

【原理与术语】

  • Redis 五大数据结构:String(存序列化对象、计数)、Hash(存用户信息、配置)、List(消息队列、最新N条)、Set(去重、标签)、Sorted Set(排行榜、延迟队列)。
  • 缓存穿透:查询一个不存在的数据,缓存和数据库都没有,大量请求直接打到 DB。解决方案:缓存空值、布隆过滤器。
  • 缓存击穿:热点数据过期瞬间,大量并发请求同时查询 DB。解决方案:互斥锁(SET NX)、永不过期 + 异步更新。
  • 缓存雪崩:大量缓存同时过期,或 Redis 宕机,导致流量直接到 DB。解决方案:随机过期时间、多级缓存、Redis 集群。

【AI 场景伪代码:Redis 缓存模型配置防穿透】

python 复制代码
import redis
import time
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def get_model_config(model_name: str):
    """
    从缓存获取模型配置,若不存在则查 DB 并写入缓存
    防止缓存穿透:DB 查询不到也缓存一个特殊空值
    """
    cache_key = f"model_config:{model_name}"
    # 1. 尝试从 Redis 获取
    cached = r.get(cache_key)
    if cached is not None:
        if cached == "NULL":
            return None          # 证明 DB 中也不存在,直接返回
        return json.loads(cached)

    # 2. 缓存未命中,查询 DB(模拟)
    db_data = query_db_for_model(model_name)

    # 3. 写回缓存,设置随机过期时间防止雪崩
    if db_data:
        # 随机 5~10 分钟过期
        ttl = 300 + (int(time.time()) % 300)
        r.setex(cache_key, ttl, json.dumps(db_data))
    else:
        # 缓存空值,过期时间短(1分钟)
        r.setex(cache_key, 60, "NULL")
    return db_data

def query_db_for_model(model_name):
    """模拟数据库查询,仅 'gpt-4' 存在"""
    if model_name == "gpt-4":
        return {"name": "gpt-4", "max_tokens": 8192, "price": 0.03}
    return None

# 测试
print(get_model_config("gpt-4"))       # 第一次查 DB,之后走缓存
print(get_model_config("gpt-5"))       # 返回 None,并缓存空值
print(get_model_config("gpt-5"))       # 再次查询直接返回 None,不查 DB

【运行结果】

复制代码
{'name': 'gpt-4', 'max_tokens': 8192, 'price': 0.03}
None
None

第一次查询 gpt-4 时缓存无数据,查 DB 后写入 Redis;第二次则直接从 Redis 返回。gpt-5 不存在,查 DB 后缓存 "NULL",后续请求不再穿透到 DB。


【常见面试题】

Q1:Redis 的 String 和 Hash 在存储用户信息时怎么选?

  • 参考回答:如果字段固定且频繁整体读写,用 String 存 JSON 序列化简单;如果字段需要单独更新(如只改昵称),用 Hash 更灵活,避免频繁序列化整体对象。

Q2:缓存穿透和缓存击穿有什么区别?

  • 参考回答:穿透是查"根本不存在"的数据,请求绕过缓存直达 DB;击穿是"热点数据过期",大量请求瞬间同时打到 DB。前者解决靠空值缓存或布隆过滤器,后者靠互斥锁或永不过期。

Q3:缓存雪崩如何预防?

  • 参考回答:设置不同的过期时间(加随机抖动),使用 Redis 集群或哨兵模式保证高可用,本地缓存(如 guava)做二级缓存,甚至限流降级。

三、缓存策略与多轮对话上下文缓存设计

【面试问题】

"用户的对话上下文需要在多次请求之间保持,每次从数据库加载太慢,怎么设计缓存?旁路缓存是什么?如何保证缓存和数据库的数据一致性?"

【类比】

笔记本与档案室

旁路缓存就像你工作时的笔记本:需要资料时,先翻笔记本(缓存),没有就去档案室(DB)调出来并记到笔记本上;修改资料时直接更新档案室并撕掉笔记本那一页(删除缓存),下次用时重新记录。多轮对话上下文就是每次对话你都保留笔记本上的前几页,不用每次都去档案室搬出全部历史。

【原理与术语】

  • 旁路缓存(Cache-Aside):应用负责缓存读写。读:先查缓存,命中直接返回,未命中查 DB 并写入缓存。写:先更新 DB,再删除缓存(或更新)。
  • 多轮对话上下文缓存 :将每个 session_id 对应的最近 N 条消息存在 Redis List 或 ZSet(按时间排序),读取时直接取尾部,无需每次查 DB。可设置过期时间(如 30 分钟),无活动自动清理。
  • 一致性处理:通常采用"先更新 DB,再删除缓存"策略,延迟双删可进一步降低并发导致的不一致概率。

【AI 场景伪代码:旁路缓存 + 对话历史缓存】

python 复制代码
import redis
import json

r = redis.Redis(decode_responses=True)

def get_chat_history(session_id: str, limit=10):
    """
    从 Redis List 中获取最近对话历史
    key: session:{session_id}:history
    """
    cache_key = f"session:{session_id}:history"
    # 从列表尾部取 limit 条(保留顺序)
    messages = r.lrange(cache_key, -limit, -1)
    if messages:
        return [json.loads(msg) for msg in messages]

    # 缓存未命中,从 DB 加载最近对话并写入缓存
    db_messages = load_history_from_db(session_id, limit)
    if db_messages:
        # 将消息序列化后推入 List,并设置过期时间
        for msg in db_messages:
            r.rpush(cache_key, json.dumps(msg))
        r.expire(cache_key, 1800)   # 30 分钟过期
    return db_messages

def add_message_to_history(session_id: str, role: str, content: str):
    """
    添加一条新消息到会话历史
    先写 DB,再删除缓存(简化一致性处理)
    """
    # 1. 写入数据库(模拟)
    save_to_db(session_id, role, content)
    # 2. 删除缓存,下次读取时重建
    cache_key = f"session:{session_id}:history"
    r.delete(cache_key)

def load_history_from_db(session_id, limit):
    """模拟数据库加载"""
    return [{"role": "user", "content": "你好"}, {"role": "assistant", "content": "你好!有什么可以帮你?"}]

def save_to_db(session_id, role, content):
    """模拟写入数据库"""
    pass

# 使用示例
history = get_chat_history("sess_001")
print(history)
add_message_to_history("sess_001", "user", "今天天气怎么样?")

【运行结果】

复制代码
[{'role': 'user', 'content': '你好'}, {'role': 'assistant', 'content': '你好!有什么可以帮你?'}]

之后每次读取会话历史都从 Redis List 中快速获取,仅在缓存失效或新增消息时与数据库交互,大幅降低 DB 负载。


【常见面试题】

Q1:为什么旁路缓存要"先更新 DB,再删除缓存",而不是先删除缓存再更新 DB?

  • 参考回答:先删缓存再更新 DB 可能存在并发问题:A 删除缓存、B 查询缓存未命中读 DB 写入旧数据到缓存、A 更新 DB,导致缓存是旧数据。先更新 DB 再删缓存风险更低,配合延迟双删可进一步降低。

Q2:多轮对话上下文如果特别长怎么办?

  • 参考回答:只缓存最近 N 轮(如最近 20 条),超过部分用滑动窗口截断。或使用 Redis ZSet 按时间戳排序,动态维护窗口大小。也可结合消息摘要压缩技术,将早期对话压缩为摘要缓存在 String 中。

Q3:Redis 的 List 和 Stream 在对话场景怎么选?

  • 参考回答:List 简单,适合短小对话历史存储,但缺乏消息持久化和消费组概念。如果对话需要多消费者(如多个大模型同时读取同一会话),可选 Stream,支持消费者组和消息确认。对于大多数单消费者场景,List 足够。

四、分布式锁与限流

【面试问题】

"多个服务实例同时调用付费模型 API,如何避免同一个请求被重复计费?如何防止单个用户刷爆 API 配额?"

【类比】

公共厕所的锁与红绿灯

  • 分布式锁 就像厕所门上的锁,一个人进去锁门,其他人只能在外面等。锁释放后下一个才能进。SET NX 就是拿锁的过程,"NX"表示门没锁我才锁。
  • 限流就像路口的红绿灯,不管来了多少车,每分钟只放行固定数量的车辆,防止交通瘫痪。滑动窗口和令牌桶是两种红绿灯算法。

【原理与术语】

  • 分布式锁 :在多实例环境下保证同一时刻只有一个进程执行某段代码。Redis 可以用 SET lock_key unique_value NX PX 30000 加锁(NX 表示不存在才设置,PX 设置过期时间防死锁)。释放时需用 Lua 脚本判断 value 是否一致,防止误删他人锁。
  • Redlock:Redis 官方提出的分布式锁算法,在多个独立 Redis 节点上顺序加锁,超过半数成功且耗时小于锁有效期则获得锁,提高可用性。
  • 滑动窗口限流:记录时间窗口内每个请求的时间戳,动态判断窗口内请求数是否超过阈值。
  • 令牌桶:系统以恒定速率向桶中放入令牌,请求需先获取令牌,桶满则丢弃多余令牌。可处理突发流量。

【AI 场景伪代码:Redis 分布式锁防止重复计费 + 滑动窗口限流】

python 复制代码
import redis
import time
import uuid

r = redis.Redis(decode_responses=True)

# ---------- 分布式锁 ----------
def acquire_lock(lock_name, expire_seconds=10):
    """获取分布式锁,返回锁标识(唯一值)"""
    lock_value = str(uuid.uuid4())
    acquired = r.set(lock_name, lock_value, nx=True, ex=expire_seconds)
    if acquired:
        return lock_value
    return None

def release_lock(lock_name, lock_value):
    """使用 Lua 脚本安全释放锁"""
    script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    r.eval(script, 1, lock_name, lock_value)

# ---------- 滑动窗口限流 ----------
def is_rate_limited(user_id, max_requests=5, window_seconds=1):
    """
    判断用户是否超过滑动窗口内的请求限制
    使用 Redis Sorted Set,key 为 user:rate:{user_id}
    score 为请求时间戳(毫秒),member 为唯一请求标识
    """
    key = f"user:rate:{user_id}"
    now = time.time() * 1000
    window_start = now - window_seconds * 1000

    # 移除窗口之前的旧数据
    r.zremrangebyscore(key, 0, window_start)

    # 当前窗口内请求数
    count = r.zcard(key)
    if count >= max_requests:
        return True  # 被限流

    # 未超出,添加本次请求记录,并设置过期时间
    r.zadd(key, {str(uuid.uuid4()): now})
    r.expire(key, window_seconds + 1)
    return False

# ---------- 使用示例 ----------
lock_id = acquire_lock("model_call:user123")
if lock_id:
    try:
        # 执行模型调用(保护临界区)
        print("执行模型推理...")
        time.sleep(0.5)
    finally:
        release_lock("model_call:user123", lock_id)
else:
    print("获取锁失败,请稍后重试")

# 限流检查
for i in range(6):
    limited = is_rate_limited("user123", max_requests=5)
    print(f"请求{i+1}: {'限流' if limited else '通过'}")

【运行结果】

复制代码
执行模型推理...
请求1: 通过
请求2: 通过
请求3: 通过
请求4: 通过
请求5: 通过
请求6: 限流

说明:分布式锁保证了模型调用不会并发重复计费;滑动窗口限流将用户请求频率控制在每秒 5 次以内。


【常见面试题】

Q1:为什么 Redis 锁要设置过期时间?

  • 参考回答:防止某个客户端获取锁后崩溃无法释放,导致死锁。过期时间保证即使持有者异常退出,锁也能自动释放。

Q2:为什么释放锁要用 Lua 脚本?

  • 参考回答:保证"判断 value 是否一致"和"删除锁"两个操作的原子性。如果分开执行,可能在判断后、删除前锁过期被别人获取,导致误删他人的锁。

Q3:固定窗口和滑动窗口限流有什么区别?

  • 参考回答:固定窗口(如每分钟 100 次)会在窗口边界出现突发流量两倍的问题;滑动窗口随时间平滑移动,统计更精确。滑动窗口可以用 Sorted Set 或 Redis 的 LIST 实现。

五、用 Redis 管理对话 Session 与 Prompt 配置热更新

【面试问题】

"用户对话 session 如何在多机部署下共享?产品要不停机更新模型 Prompt 配置,如何让所有服务实例即时生效?"

【类比】

云笔记与公告栏

  • 对话 session 就像你的云笔记,换一部手机登录,同样能继续写,因为数据存在云端。Redis 就是这个云端存储,所有服务实例共享。
  • Prompt 配置热更新就像公司公告栏:管理员一贴上最新通知(更新 Redis 配置),所有员工路过(服务实例读取)都能立刻看到最新版本,无需重启公司。

【原理与术语】

  • Session 集中化:将原本存在服务器本地的 session 数据转移到 Redis 中,通过 session_id 作为 key 存储用户身份、对话状态等,实现多实例无状态共享。
  • Prompt 配置热更新:将 Prompt 模板、模型参数等配置存储在 Redis Hash 或 String 中,服务启动时加载一次,后续通过定时轮询或订阅 Redis Pub/Sub 频道实时更新本地缓存,避免重启。
  • Pub/Sub 通知:Redis 发布/订阅机制,配置更新时发布一条消息,所有订阅的实例收到后立即刷新本地配置。

【AI 场景伪代码:Session 共享与 Prompt 热更新】

python 复制代码
import redis
import json
import threading
import time

r = redis.Redis(decode_responses=True)

# ---------- 1. 对话 session 管理 ----------
def get_or_create_session(session_id: str):
    """
    从 Redis 获取会话数据,不存在则创建新的
    存储为 Hash,包含 user_id, context, created_at
    """
    key = f"session:{session_id}"
    if r.exists(key):
        # 同时刷新过期时间
        r.expire(key, 3600)
        return r.hgetall(key)
    else:
        session_data = {
            "user_id": "anonymous",
            "context": "{}",
            "created_at": str(time.time())
        }
        r.hset(key, mapping=session_data)
        r.expire(key, 3600)
        return session_data

# ---------- 2. Prompt 配置热更新 ----------
# 当前服务的本地配置缓存
local_config = {}

def load_prompt_config():
    """从 Redis 加载最新的 Prompt 配置到本地"""
    global local_config
    key = "app:prompt_config"
    config = r.hgetall(key)
    if config:
        local_config = config
        print("Prompt 配置已更新:", config.get("system_prompt", ""))

def config_refresh_listener():
    """
    订阅 Redis 的配置更新频道,收到消息即重新加载配置
    运行在后台线程中
    """
    pubsub = r.pubsub()
    pubsub.subscribe("config:update")
    for message in pubsub.listen():
        if message["type"] == "message":
            load_prompt_config()

# 启动时加载一次配置,并启动后台监听线程
load_prompt_config()
threading.Thread(target=config_refresh_listener, daemon=True).start()

# 管理员端:更新配置并发布通知
def update_prompt_config(new_config: dict):
    r.hset("app:prompt_config", mapping=new_config)
    r.publish("config:update", "prompt_updated")

# 使用示例
print(get_or_create_session("sess_abc"))
update_prompt_config({"system_prompt": "你是一个乐于助人的AI助手。"})
time.sleep(0.1)  # 等待后台线程接收消息
print("当前使用的配置:", local_config)

【运行结果】

复制代码
{'user_id': 'anonymous', 'context': '{}', 'created_at': '1717157400.0'}
Prompt 配置已更新: 你是一个乐于助人的AI助手。
当前使用的配置: {'system_prompt': '你是一个乐于助人的AI助手。'}

所有服务实例共享同一个 Redis,任意一台服务器更新配置后,其他实例在几乎同一时刻(订阅推送)完成本地配置刷新,实现热更新。


【常见面试题】

Q1:为什么多机部署要把 Session 存 Redis 而不是本地内存?

  • 参考回答:本地内存绑死在单台机器,如果负载均衡把下一次请求打到另一台机器,就会丢失用户会话,导致用户需要重新登录或对话历史丢失。Redis 作为共享存储,让所有实例都能访问同一份 session 数据。

Q2:Prompt 配置热更新有哪些实现方式?

  • 参考回答:1) 轮询:服务定时读取 Redis,简单但有延迟;2) Redis Pub/Sub:实时推送,适合快速生效;3) 配置中心(如 Nacos、Apollo):专门的配置管理工具,功能更全面。选择取决于实时性要求和架构复杂度。

Q3:Redis Pub/Sub 有什么局限性?

  • 参考回答:消息不持久化,如果订阅者离线,该时段的消息会丢失;消息无法重放。如果配置更新要求不丢失,可以考虑 Redis Stream 或改用专业的消息队列。

总结:在 AI 应用开发初级岗位中,数据库与缓存是后端开发的基石。你需要理解索引优化与慢查询分析,掌握 Redis 缓存问题的解决套路,能设计合理的缓存策略管理对话上下文,并运用分布式锁、限流保证服务的稳定性和资源公平分配。把这些能力与面试回答结合,能让你在技术面中展现出扎实的基础和工程化思维。

相关推荐
一 乐1 小时前
在线考试|基于Springboot的在线考试管理系统设计与实现(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·毕设·在线考试管理系统
小陈的进阶之路1 小时前
MySQL 索引
数据库·mysql
無限進步D1 小时前
MySQL 子查询
数据库·mysql
Dxy12393102161 小时前
Django 模型查询中的数据库连接池配置指南
数据库·django·sqlite
Byron__1 小时前
数据库高频面试核心知识点
数据库·面试
Cloud_Shy6181 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第一章 Item 7 - 9)
开发语言·数据库·python
Yvonne爱编码1 小时前
数据库---Day10 索引
数据库·sql·mysql
小杍随笔1 小时前
【Rust后端缓存设计实战:从本地moka到Redis多层架构的避坑指南】
redis·缓存·rust
Jul1en_1 小时前
【Redis】 集群概念
数据库·redis·哈希算法