数据库与缓存核心概念: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>ALL。ALL代表全表扫描,需要优化。 - 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 应用场景理解 :
假设你在做一个大模型对话的批量任务系统:
- 事务 A 查询今天所有
status = 'pending'的任务,准备分配给空闲的推理 worker。 - 与此同时,你的后台调度器(事务 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=1、WHERE A=1 AND B=2、WHERE A=1 AND B=2 AND C=3,以及WHERE A=1 AND C=3(C 部分用不上但 A 能用)。不能用于跳过 A 的查询,如WHERE B=2或WHERE 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 缓存问题的解决套路,能设计合理的缓存策略管理对话上下文,并运用分布式锁、限流保证服务的稳定性和资源公平分配。把这些能力与面试回答结合,能让你在技术面中展现出扎实的基础和工程化思维。