03 - 后端流式架构与全栈端侧交付
章节定位:面试官对您后端工程底座与全栈端到端交付能力的集中考察。本章涵盖 FastAPI 异步架构分层、SSE 流式输出与断连处理、MySQL 8.0 表设计与冷热归档、Redis 高速缓存与 Celery 异步管道、React 管理端与微信小程序双端交付以及细粒度 RBAC 权限审计。
Q1:你们的整体后端架构是怎样的?能分层讲一下吗?
追问场景:二面技术架构开场,面试官想快速建立对您后端设计的整体认知。
答:
我画一个分层架构图来描述:
┌─────────────────────────────────────────────────────────┐
│ 接入层(前端) │
│ React Web 管理端 / 微信小程序 │
└─────────────────────────────────────────────────────────┘
│ HTTPS / SSE
┌─────────────────────────────────────────────────────────┐
│ 网关层(Nginx + 网关) │
│ 路由转发、限流、SSL 终结、静态资源缓存 │
└─────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────┐
│ Web 层(FastAPI 异步应用) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 中间件层:CORS / 日志 / 鉴权 / 异常捕获 │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ 路由层:RESTful API + SSE 流式接口 │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ Service 层:会话管理 / Agent 编排 / 知识库调用 │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ Repository 层:MySQL / Redis 数据访问 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────┐
│ 外部依赖层 │
│ RAGFlow(检索底座) / 大模型 API / 对象存储 MinIO │
└─────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────┐
│ 基础设施层 │
│ MySQL 8.0 / Redis / Celery + Redis Broker │
└─────────────────────────────────────────────────────────┘
这个架构的核心设计理念是 「关注点分离」。Web 层(FastAPI)只负责 HTTP 协议层面的接入、SSE 流式推送和基础的请求路由;真正的业务逻辑——比如 Agent 状态机的编排、多轮会话的上下文维护、知识库的检索调用——全部封装在 Service 层,通过依赖注入(FastAPI 的
Depends)注入到路由处理器中。这样设计的好处是:如果未来需要把 Agent 能力暴露为 gRPC 服务或消息队列消费者,Service 层可以直接复用,不需要重写业务逻辑。
Q2:为什么选择 SSE 而不是 WebSocket 来做流式输出?
追问场景:考察技术选型的工程判断力,面试官想看您是否“用了某技术但不知道为什么”。
答:
当时选型的时候,我认真评估过 WebSocket、SSE 和长轮询(Long Polling)三种方案。最终选 SSE 的核心逻辑是 「协议与场景的匹配度」:
第一,数据流向是单向的。 AI 对话的核心体验是从服务器向客户端“流式地吐出”回答内容,客户端不需要频繁地往服务端发送数据(用户发完问题后,主要的交互就是“等服务器推送”)。SSE 本身就是为“服务器向客户端单向推送”设计的,而 WebSocket 是为“全双工双向通信”设计的。用 WebSocket 做单向流,协议层面有额外开销(握手更重、需要维护心跳),属于“杀鸡用牛刀”。
第二,HTTP 生态的天然兼容性。 SSE 基于标准 HTTP/1.1 的
text/event-stream或 HTTP/2 的 Server Push,不需要像 WebSocket 那样做协议升级(Upgrade)。这意味着它可以天然穿过大多数企业级防火墙、负载均衡器和反向代理(Nginx 对 SSE 有很好的原生支持),对政务内网环境的兼容性更好。而 WebSocket 在政务网络环境中偶尔会遇到代理不支持或会话超时的问题。第三,自动重连机制。 SSE 规范中内置了
Last-Event-ID机制,如果连接意外断开,客户端在重连时可以将这个 ID 带给服务器,服务器可以恢复断点继续推送。虽然这个机制在复杂场景下还需要加强(比如依赖服务端会话状态),但至少协议层给了我们一个基础。WebSocket 的断线重连需要完全由应用层自己实现。当然也有权衡:SSE 是单向的,如果后续我们需要在生成过程中允许用户“中断生成”或“修改条件”,就需要额外用 HTTP API 来做控制信令,而不能像 WebSocket 那样在同一个连接里用消息类型来区分。我们在实现“打断”功能时,就是在前端调了一个独立的 HTTP DELETE 接口
/api/chat/{task_id}/cancel,配合服务端的协程取消机制来实现的。这个方案稍复杂一点,但完全可接受。一句话总结选型逻辑:SSE 是一个“足够好”且“更简单”的方案,满足了 90% 的场景,没有为 10% 的边缘需求引入 2 倍的复杂度。
Q3:SSE 流式输出的生成器(Generator)具体是怎么实现的?
追问场景:面试官要验证您是否真的深入理解并落地过 SSE 生成器全链路架构,而非照搬浅层文档。
答:
在我们的 FastAPI 异步架构中,SSE 流式生成器(Async Generator)本质上是一个 “上下游协议转换与数据清洗的异步管道” ——上游承接大模型或 LangGraph Agent 的原始流式 Token,中间进行合规过滤与结构化协议封装,下游持续向客户端推流,并全程监控连接生命周期。
整体架构与数据流转可以分为以下五个核心阶段:
1. 前置准备阶段(Pre-Stream):
- 鉴权与配额校验:提取 Header 中的身份凭证,校验租户限流配额。
- 上下文滑动窗口组装:从 Redis 提取该会话最近的 N 轮历史对话并执行 Token 裁剪,组装 Prompt。
- 响应头协议声明:向客户端声明Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive以及关键的X-Accel-Buffering: no(通知网关关闭反向代理缓冲,确保逐字实时推送)。2. 多路复用事件封装阶段(Event Multiplexing):
生成器并不是简单地推纯文本,而是封装成前端易于解析的标准结构化事件协议:
-event: start:推送任务初始化元数据与唯一 task_id(供前端绑定中断操作)。
-event: token:正文文字增量包,前端追加到富文本打字机中。
-event: tool_start/event: tool_end:Agent 工具调用状态(如“正在检索社保法规库…”),用于前端动画反馈。
-event: cite:RAG 召回的结构化引用来源卡片(包含文档名、段落偏移、置信度分值)。
-event: ping:定时心跳包(每隔 15 秒发送一次),防止中间网关因超时主动掐断连接。
-event: end:结束信令(附带总耗时与 Token 统计)。3. 实时敏感词与合规嗅探阶段(In-Flight Guardrail):
- 内存中维护环形滑动字符缓冲区(保留最新 10 个字符),一旦命中政务合规违规词,生成器立即截断上游输出,替换为预设兜底话术并向前端发送终止事件。4. 断连监听与算力止损阶段(Disconnect & Cancel):
- 生成器在异步循环中通过request.is_disconnected()监听 ASGI 层的底层 TCP 断连信号。
- 用户一旦关闭页面或切换 Tab,生成器立即感知并捕获asyncio.CancelledError,强制中断对上游大模型/RAGFlow 的 HTTP 连接,彻底杜绝“用户已走、服务器仍在白白消耗商业 Token”的算力浪费。5. 异步归档与收尾阶段(Post-Stream / Finally):
- 在生成器循环中累积完整的 Assistant 回答,推流结束后在finally块中通过后台异步任务写回 MySQL 8.0 持久化表,并更新 Redis 缓存与审计日志,不阻塞 HTTP 线程。
Q4:(追问)客户端断连后,后端正在执行的任务会立刻释放资源吗?你们是怎么处理的?
追问场景:面试官深挖生产级健壮性,这是很多“调通即可”的项目最容易露馅的地方。
答:
这个问题很关键。如果客户端断连后后端任务不释放,会导致资源泄漏——比如 LLM API 的调用仍在继续消耗 Token 费用、RAGFlow 的会话句柄没有释放、数据库连接池被耗尽。我们做了两个层面的处理:
第一层:协程级别的主动取消(快速路径)。
正如前文生成器机制所述,我们在循环里检查了
await request.is_disconnected()。一旦检测到断连,就break跳出循环,然后执行finally或except asyncio.CancelledError中的清理逻辑。但这里有一个细节:如果当前代码正阻塞在agent_graph.astream_events()的内部迭代中(比如正在等待 LLM API 的 HTTP 响应),break并不会立刻中断这个正在阻塞的异步调用。为了处理这种情况,我们把整个
agent_graph.astream_events()放在了一个asyncio.wait_for()中,并设置了超时。同时,当检测到断连时,我们通过一个asyncio.Event信号通知底层的 LangGraph 执行环境“任务应该被取消”,LangGraph 内部会抛出asyncio.CancelledError,从而提前中断 LLM 调用。这个机制依赖于我们传给 Agent 的config中携带了一个可取消的context。第二层:Celery 超时兜底(慢速路径)。
对于极少数情况——比如 LLM 服务本身卡住了,连
astream_events的第一块数据都没有返回——上面的协程取消可能来不及。我们在调用 Agent 的整个流程外层包了一个 Celery 异步任务,设置了 60 秒的全局超时。如果 60 秒后 Agent 仍未完成,Celery worker 会强制终止该任务(通过SoftTimeLimitExceeded异常),并触发清理回调。具体清理动作包括:
- 调用 RAGFlow 的会话关闭接口(如果它支持),释放检索索引的缓存句柄。
- 将 Redis 中该会话的 Checkpoint 状态回滚到断连前的最后一步,避免状态损坏。
- 记录一条审计日志,标记该次任务被异常中断。通过这两层机制,我们确保了即使客户端暴力关闭页面(比如直接 kill 微信小程序进程),后端的资源也能在秒级内得到释放,不会长期泄漏。
Q5:FastAPI 的异步架构中,你们怎么避免阻塞事件循环?
追问场景:考察异步编程的底层理解,面试官想验证您是“真异步”还是“挂着 async 关键字但调用了同步阻塞代码”。
答:
这个问题问得很专业。在 FastAPI 异步架构中,最常见的陷阱就是 在 async 函数中调用了同步阻塞的 I/O 操作或 CPU 密集型计算,这会阻塞整个事件循环,拖垮所有并发请求。
我们根据操作类型做了三类处理:
第一类:I/O 密集型异步操作(天然异步)
数据库访问用
asyncpg/SQLAlchemy Async,Redis 操作用aioredis,HTTP 请求用httpx.AsyncClient,文件操作使用aiofiles。这些库都提供了原生异步接口,我们可以直接在 async 函数中await,不会阻塞事件循环。第二类:同步阻塞的 I/O 操作(主动 offload 到线程池)
RAGFlow 的部分 Python SDK 接口还没有完全支持异步(比如某些文件上传操作),或者我们调用的一些旧版加密库是同步的。对于这些不可避免的同步调用,我们用
asyncio.to_thread()或loop.run_in_executor()将它们放到默认的线程池中执行,不阻塞主事件循环。```python
示例:在 async 接口中调用同步的 RAGFlow 解析函数
result = await asyncio.to_thread(ragflow_client.sync_parse_document, file_path)
```第三类:CPU 密集型计算(独立进程 / Celery)
真正的 CPU 密集型操作——比如大批量文档的版面分析后处理、或者复杂的 JSON 序列化/反序列化——即使放在线程池也会因为 GIL 竞争影响性能。这类任务我们全部通过 Celery 异步任务来执行,不放在 FastAPI 的请求生命周期内。比如“生成对比报告”这个 Agent 任务中的 Summarizer 节点,如果需要处理 20 份文档的汇总,我们会让 Agent 先收集所有文档,然后触发一个 Celery 任务去生成报告,通过 WebSocket 或轮询的方式返回结果。这样 FastAPI 的请求响应时间保持在 200ms 以内,真正耗时的逻辑在后台 worker 中跑。
在代码审查层面,我们使用了
ruff的规则ASYNC101和ASYNC102来检测 async 函数中是否有直接调用的同步阻塞函数,在 CI 阶段就拦截这类问题。
Q6:你们的接口响应时间大概是多少?有没有做过性能优化?
追问场景:考察性能敏感度和优化意识,面试官想知道您是否关注过线上的实际表现。
答:
我把响应时间拆成两类来监控:「首字延迟(TTFT)」和 「总完成时间」。
数据表现(基于线上监控):
- 普通 RAG 问答(非 Agent 任务):首字延迟平均在 800ms-1.5s(主要取决于上游大模型 API 的首 token 响应速度),总完成时间平均在 5-8 秒(取决于回答长度,一般 300-500 字)。
- Agent 复杂任务(如政策对比):首字延迟更久,约 2-4 秒(因为 Planner 节点先要调用一次 LLM 做计划生成),总完成时间约 10-20 秒。我们做过的优化措施,按效果排序:
热点问答缓存(效果最显著)。对于高频问题(比如“高新区人才补贴标准”这类被问了上百次的问题),我们在 Redis 中缓存了完整的回答内容,设置了 6 小时的 TTL。命中缓存时直接走静态返回,首字延迟降到 < 100ms。缓存命中率大约在 15%-20%,对整体平均响应时间有不错的下拉效果。
检索链路的并行化。在混合检索阶段,BM25 检索和向量检索原来是串行的(先 BM25 再向量,然后合并)。我们改为了使用
asyncio.gather()并行执行,这两个独立操作互不依赖,并行后总耗时从原来的“两者之和”变成了“两者之最大”,整体检索耗时降低了约 40%。模型参数调优。我们在调用大模型时,将
temperature设为 0.1(政务场景不需要创造性),top_p设为 0.9,并且没有开启stream之外的特殊参数。同时和模型服务提供商沟通后,申请了更高的并发连接配额,避免因为连接等待导致的延迟。这块属于基础设施调优,效果大概有 10%-20% 的提升。连接池与 Keep-Alive。FastAPI 到 RAGFlow 和大模型服务的 HTTP 连接全部使用连接池(
httpx.AsyncClient(limits=...), pool_connections=...),启用了 HTTP Keep-Alive,避免每个请求重复建立 TCP 连接的开销。这在并发较高时效果明显,但单次请求的改善不大。一个原则:我们不追求“极致低延迟”,因为 AI 生成的场景用户预期就是“需要等待”,我们的核心目标是 “首字要快、中间要稳、不能中断”。所以优化优先级是:稳定性 > 首字延迟 > 总完成时间。在上述优化之后,系统的 P95 首字延迟控制在 2 秒以内,我们的政务客户对这个指标是满意的。
Q7:你们的 MySQL 数据库是怎么设计的?核心表有哪些?
追问场景:二面技术深挖开场,面试官想摸底您的数据建模能力。
答:
我们的 MySQL 8.0 数据库围绕”多租户知识库管理 + 对话审计”两个核心域设计了 7 张核心表,我挑最重要的 4 张来说明:
| 表名 | 核心字段 | 用途 | 数据量级 |
|---|---|---|---|
t_tenant |
tenant_id, name, config(JSON), status |
租户信息与配置(知识域隔离的顶层) | 数十条(客户级) |
t_user |
user_id, tenant_id, role, permissions(JSON), dept |
用户与 RBAC 权限 | 数百条(政务客户员工) |
t_document |
doc_id, tenant_id, file_name, file_hash, status, publish_authority, doc_number, effective_date, expire_date, parsed_chunk_count, created_at |
知识库文档主表。记录每份政策文件的元数据和解析状态 | 数千份(核心政策文件约 2000 份) |
t_conversation |
conv_id, tenant_id, user_id, session_id, query, answer(TEXT), citations(JSON), feedback(TINYINT), latency_ms, token_usage(JSON), created_at |
对话审计表。每一条问答都落库,用于合规审计和效果分析 | 数万条/月(取决于使用频率) |
t_audit_log |
log_id, tenant_id, user_id, action, target, ip, user_agent, detail(JSON), created_at |
操作审计日志,所有关键行为(登录、文件上传/删除、敏感词拦截)全部记录 | 增长较快,需定期归档 |
设计思路上的两个核心考量:
第一是多租户数据隔离。tenant_id 是所有业务表的”必填外键”,任何查询都强制带 tenant_id 过滤。我们在应用层(Repository)封装了一个”租户感知”的 BaseDAO,自动注入当前用户的 tenant_id,杜绝漏写 WHERE 条件导致的数据泄露。
第二是文档状态机管理。t_document 的 status 字段状态流转为:uploading → parsing(DeepDoc 解析中)→ indexing(写入向量库中)→ active(可被检索)→ failed(解析失败,需人工介入)→ deprecated(已废止,不被检索)。这个状态机让知识库的治理变得可追踪——客户能看到”哪些文件已经在知识库里了,哪些还在处理中”。
Q8:对话表的数据量增长很快,你们怎么做冷热归档的?
追问场景:简历提到”冷热归档”,面试官要验证您是否真正处理过数据增长问题,而非纸上谈兵。
答:
t_conversation和t_audit_log是两张”日志型”表,增长非常快。我们的冷热归档策略按 “时间分层 + 状态分层” 来设计:
分层策略:
| 层级 | 存储介质 | 数据范围 | 访问方式 | 保留时长 |
|---|---|---|---|---|
| 热数据 | MySQL InnoDB | 近 3 个月的对话记录 | 实时读写(查询历史会话、审计回溯) | 3 个月 |
| 温数据 | MySQL 独立归档表(分区表) | 3-12 个月的对话记录 | 只读查询(需要时可通过管理员工具检索) | 1 年 |
| 冷数据 | MinIO 对象存储(CSV/Parquet 格式) | 1 年以上的历史记录 | 离线查询(需从对象存储下载后分析) | 按合规要求保留 5-10 年 |
具体实现机制:
- 定时归档任务(Celery 周期任务):每天凌晨 2 点,一个 Celery Beat 任务会扫描
t_conversation表,将created_at < 当前日期 - 3个月且feedback = 'helpful'(过滤掉无用的低质对话)的记录批量迁移到归档表t_conversation_archive。迁移完成后,从主表中 DELETE 删除。 - 分区表(温数据层):
t_conversation_archive使用了 MySQL 的 RANGE 分区,按created_at月分区。查询指定月份的数据时,MySQL 只扫描对应分区,即使数据量达到百万级,查询性能依然可控。 - 冷数据导出到对象存储:每季度执行一次”冷化”操作,将
t_conversation_archive中超过 1 年的分区数据导出为 Parquet 文件(压缩率高、支持列式查询),上传到 MinIO,然后DROP PARTITION释放 MySQL 空间。我们使用pandas+pyarrow做导出,程序化执行。 - 查询路由:业务代码中封装了一个
ConversationRepository,当用户查询历史会话时,先查热表,如果没有再查温表,如果温表也没有则返回提示”该会话已归档,如需调取请联系管理员”。冷数据不对外提供在线查询,只用于合规审计时的离线导出。
这套方案上线后,主表
t_conversation的数据量从高峰期的 200 多万条稳定控制在 30 万条以内,所有日常查询的响应时间都保持在 50ms 以内。
Q9:Redis 在你们项目中除了做缓存,还承担了哪些职责?能详细讲讲分布式锁与防击穿设计吗?
追问场景:考察中间件应用的广度,面试官想了解您对 Redis 的理解不限于”存 Session”。
答:
Redis 在我们的项目中扮演了 “四合一” 的角色,按重要性排序:
1. 会话状态存储(最核心):
这是第 05 章详细讲过的。存储多轮对话历史、LangGraph Checkpoint 以及 Agent 的执行上下文。Key 格式为
session:{tenant_id}:{user_id}:{session_id},TTL 24 小时,使用 RedisJSON 模块支持局部更新。
2. 热点问答缓存(性能加速):
针对高频问题(比如咨询量大的政策条款)缓存完整的回答内容。Key 为
cache:qa:{md5(query + tenant_id + dept)},Value 为序列化的回答 JSON(包含答案正文、引用列表、生成时间等),TTL 6 小时。缓存未命中时才走完整的 RAG 链路。我们统计的缓存命中率约为 15%-20%,有效降低了平均响应延迟和 LLM Token 消耗。
3. 分布式锁(解决并发一致性问题):
最典型的场景是”同一个文件被多个用户同时上传到知识库”。如果两个用户同时上传了同一份
file_hash相同的文件,我们希望只解析一次,而不是重复解析两次造成资源浪费。实现方案:```python
import redis.asyncio as aioredisasync def acquire_parse_lock(file_hash: str, ttl: int = 300) -> bool:
lock_key = f”lock:parse:{file_hash}”
# SET NX EX 是 Redis 实现分布式锁的标准原子操作
result = await redis_client.set(lock_key, “locked”, nx=True, ex=ttl)
return result is Trueasync def release_parse_lock(file_hash: str):
lock_key = f”lock:parse:{file_hash}”
await redis_client.delete(lock_key)
```用文件哈希作为锁 Key,同一份文件在同一时间只有一个解析任务在执行。这里的锁 TTL 设了 300 秒(5 分钟),足以覆盖绝大多数文件的解析耗时,如果超时锁自动释放,不会死锁。
4. 限流计数器(防滥用):
政务场景需要防止恶意用户刷接口,我们基于 Redis 的 INCR + EXPIRE 实现了 滑动窗口限流:
```python
对每个用户 + 接口维度做限流:每分钟最多 30 次请求
key = f”ratelimit:{user_id}:{api_path}:{current_minute}”
count = await redis_client.incr(key)
if count == 1:
await redis_client.expire(key, 60)
if count > 30:
raise RateLimitExceeded(“请求过于频繁,请稍后再试”)
```这个方案比固定窗口更精准,而且完全是 Redis 原子操作,在高并发下没有竞态条件。
一个额外的设计细节:我们没有把 Redis 当成”缓存”去滥用——所有写入 Redis 的数据都有明确的 TTL 上限,并且我们定期用
INFO keyspace监控 Key 数量和内存使用率,确保 Redis 的内存占用在可控范围内(我们给 Redis 分配了 2GB 内存,实际使用率稳定在 60% 左右)。
追问场景:面试官深挖分布式锁的”坑”,考察您是否真正在生产环境遇到过问题。
答:
遇到过。分布式锁最经典的坑就是 “锁超时 + 业务执行超时” 的组合问题,我们踩过并修复了。
问题现场:有个客户上传了一份 200 多页的扫描件 PDF,文件体积大、图片质量一般,DeepDoc 解析耗时超过了我们设定的锁 TTL(当时是 60 秒)。60 秒后锁自动释放了,但解析任务还在后台继续执行。此时另一个用户恰好也上传了同一份文件(可能来自不同的入口),发现锁已经不存在了,于是也启动了一个解析任务。结果同一份文件被解析了两次,不仅浪费了计算资源,还导致知识库中出现了重复的 chunk 数据,检索时出现了重复引用。
我们做了两轮优化:
第一轮:锁超时时间动态化 + 刷新机制(看门狗模式):
将锁的超时时间从固定 60 秒改为基于文件大小动态计算:
ttl = max(120, file_size_mb * 30),对于大文件自动分配更长的锁 TTL。同时,我们实现了一个 “看门狗”(Watchdog)——解析任务启动后,每隔 30 秒检查一次是否还在运行,如果是则主动刷新锁的 TTL(用 Lua 脚本确保只有锁的持有者才能刷新)。
lua -- 锁续期 Lua 脚本:只有 value 匹配时才续期 if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("expire", KEYS[1], ARGV[2]) else return 0 end
第二轮:业务层面幂等性兜底(”二次检查”模式):
即使用了看门狗,仍然存在极端情况(比如 Worker 进程被强制 kill,锁没来得及释放)。所以我们在解析任务真正开始之前加了一个 “二次检查”:
```python
async def parse_document(file_hash: str, file_content: bytes):
# 先检查这个文件是否已经在知识库中且状态为 active
existing = await db.get_document_by_hash(file_hash, status=”active”)
if existing:
logger.info(f”Document already parsed, skipping duplicate task, hash={file_hash}”)
return existing.doc_id# 如果不存在,再走真正的解析逻辑 ...```
也就是说,分布式锁是 “第一道防线”(防止并发启动),数据库的状态检查是 “第二道防线”(防止重复执行)。即使锁失效了,数据层面也不会产生重复记录。这两道防线结合起来,才真正实现了任务的幂等性。
Q10:长流程任务(文档解析等)中为什么用 Celery?如何保障 Worker 容错与任务不丢?
追问场景:考察技术选型能力,面试官想看您是否理解不同异步方案的适用边界。
答:
选 Celery 的主要原因是 “政务内网环境下的稳定性 + 生态成熟度”。我对比了三个方案:
1. Celery + Redis Broker(我们选的):
- 优势:Python 生态的异步任务事实标准,文档极其完善,踩坑经验丰富。支持任务重试、定时任务(Celery Beat)、进度回调、任务状态追踪。Redis Broker 在政务内网轻量级部署非常方便,不需要额外引入重型中间件(比如不需要单独部署 RabbitMQ 或 Kafka 集群)。
- 劣势:依赖 Redis 的持久化配置,如果 Redis 重启且未开启 AOF,可能丢失队列中的任务。我们通过 Redis AOF + 每日 RDB 备份来解决这个问题,同时核心任务(文件解析)的输入数据本身就存储在 MinIO 中,即使任务丢失了也可以由上传后的状态机触发重试。
2. 纯 asyncio + 后台 Task:
- 理论上我们可以用
asyncio.create_task()在 FastAPI 进程内直接跑后台任务,不需要引入额外依赖。但风险是:如果 FastAPI 进程重启,所有内存中的后台任务全部丢失,没有持久化保证;而且一个文件解析任务如果耗时 5 分钟,会占用一个 FastAPI Worker 的线程/协程资源,影响并发处理能力。政务内网环境对系统稳定性要求高,我们不愿意冒这个风险。
3. Apache Kafka(我没有选,但特意补充说明一下):
我在之前的公司(知道创宇的边缘网关项目)用过 Kafka,我知道它的优势是吞吐量极高、支持消息回溯、Consumer Group 水平扩展能力很强。但对于政务小灵通这个项目来说,我们的异步任务量很小(每天几十到几百个文件解析任务),用 Kafka 属于”大炮打蚊子”,而且 Kafka 的运维成本(ZooKeeper/KRaft、分区管理、消费者组偏移量管理)在内网环境中过于复杂,客户侧没有人能维护。所以最终选择了更轻量的 Celery。
总结选型逻辑:在”足够满足需求”的前提下,选择 运维成本最低、团队最熟悉 的方案,而不是追求”技术最前沿”。对于政务项目来说,简单可靠比高大上更重要。
追问场景:考察可靠性保障,面试官想要”确保任务不丢”的具体方案。
答:
任务丢失的风险分两种:“内存丢失”(Worker 进程崩溃时正在执行的任务丢了)和 “队列丢失”(Redis 重启后未消费的任务丢了)。我们分别做了防护:
针对”执行中的任务丢失”:
我们的 Worker 配置了
task_acks_late = True。默认 Celery 是”预取消息后立即确认(ACK)”,如果 Worker 随后崩溃,这条消息就不会被重新投递。开启acks_late后,Worker 会在任务执行完成后才向 Broker 发送确认。如果任务执行过程中 Worker 崩溃,Broker 会将该任务重新分配给其他存活 Worker(前提是任务幂等)。```python
celery_config.py
task_acks_late = True
task_reject_on_worker_lost = True # Worker 异常退出时拒绝任务,重新入队
```
针对”Broker 重启导致队列丢失”:
我们用的是 Redis 作为 Broker,默认 Redis 只存内存,重启会丢队列。我们的方案是 开启 Redis AOF 持久化(Append Only File),并设置
appendfsync everysec。这样即使 Redis 意外重启,也能从 AOF 日志中恢复绝大部分队列数据。当然,极端情况下 AOF 也可能会丢失最后 1 秒的数据,但概率已经极低了。同时,我们还有一个兜底——“状态机驱动的自愈”:我们有一个 Celery Beat 定时任务(每 10 分钟执行一次),扫描数据库中
status='uploading'或status='parsing'但超过 30 分钟仍未变为active或failed的文档记录。这些”卡住”的文档会被重新投递到解析队列,并追加一条审计日志。这个自愈机制相当于把数据库当成了”最终真实状态源”,即使消息队列完全丢失了,我们也能从数据库状态中重建任务。总结:我们追求的是 “最终一致性” 而不是 “绝对的 0 丢失” 。通过
acks_late+ AOF 持久化 + 定时自愈扫描,我们把任务丢失的概率降到了极低的水平,并且在丢失发生时可以通过自愈机制快速补偿。
Q11:你们为什么做了 React Web 管理端 + 微信小程序两个端?它们的定位分别是什么?
追问场景:二面开场,面试官想了解您的产品思维和端侧划分逻辑。
答:
双端设计来自对政务场景三类用户的实际需求分析,不是“为了全栈而全栈”:
| 端侧 | 目标用户 | 核心场景 | 交互特点 |
|---|---|---|---|
| 微信小程序 | 基层窗口工作人员、企业办事人员 | 现场快速查询政策、回答群众咨询 | 轻量、碎片化、随时可用 |
| React Web 管理端 | 政府内部业务骨干、科室负责人、系统管理员 | 知识库管理、文件批量导入、审计日志查询、复杂任务(政策对比报告)、系统配置 | 重度、桌面化、需要大屏操作 |
小程序的定位是“查询即服务”:窗口工作人员面对群众时,掏出手机打开小程序,输入问题就能得到带引用的答案,整个交互时间控制在 1-2 分钟内。我们刻意简化了小程序的交互——不做复杂任务编排、不做批量操作、不做管理配置,只做“一问一答 + 引用溯源”。
Web 管理端的定位是“治理与决策”:知识库的建设不是一个“上传即完成”的动作,需要持续治理。管理员在 Web 端查看文档解析状态、处理解析失败的文件、配置知识域权限、查看审计日志、生成统计报表、执行政策对比等复杂任务。Web 端支持多标签、大表格、富文本编辑等桌面级交互。
数据层面两个端完全同源——共享同一个后端 API、同一套权限体系和同一个知识库。用户的工作流可能是:在 Web 端上传并管理文件 → 窗口服务时在小程序端查询 → 事后回到 Web 端查看问答统计。这种“多端协同、数据统一”的体验,政务客户是比较认可的。
Q12:前端的工程化搭建你是怎么做的?有没有配置代码规范、打包优化这些?
追问场景:面试官想验证您的全栈能力不只是“能写 React 组件”,还包括工程化功底。
答:
虽然是独立开发,但我在前端工程化上投入了相当精力,因为一个好的工程底座能让后期维护成本大幅降低。具体做了以下几件事:
构建工具:Vite
我没有选 CRA(Create React App),因为它基于 Webpack 的冷启动和热更新在项目变大后变得缓慢。Vite 基于 ESBuild 的预构建和原生 ESM 的按需编译,在开发环境下几乎是秒启动。对于独立开发者来说,“改一行代码等 3 秒刷新”会严重打断心流,Vite 的体验好很多。
代码规范:ESLint + Prettier + Husky
我配置了
@typescript-eslint的推荐规则集,Prettier 做自动格式化,Husky + lint-staged 在 commit 前自动检查和修复。虽然我是独立开发,但还是希望代码保持一致的风格,方便后续可能的团队扩展。Commit 规范用 Conventional Commits(feat:、fix:、docs:等)。
API 层:基于 Axios 的统一封装
封装了一个
request.ts模块:
- 自动注入 JWT Token(从 localStorage 读取)。
- 统一的错误拦截(401 自动跳转登录、500 统一 Toast 提示)。
- 请求/响应日志(开发环境打印,生产环境不打印)。
- 取消重复请求(防止用户快速点击导致的重复提交)。业务层所有 API 调用都通过
api/user.ts、api/document.ts、api/chat.ts等模块导出,每个接口都有完整的 TypeScript 类型定义(请求参数类型 + 响应数据类型),和后端的 Pydantic 模型保持同步。
打包优化
- 路由懒加载:使用
React.lazy()+Suspense按页面拆分 chunk,首屏加载只下载必要的代码。首屏的初始 JS 体积控制在 200KB 以内(gzip 后)。- Ant Design 按需加载:配置了 Vite 的插件
vite-plugin-style-import,只打包用到的组件和样式,避免整个 Ant Design 库全量引入。- 生产环境 Source Map:只生成
hidden-source-map,方便线上错误追踪但不会暴露源码。
Q13:统一认证和权限控制是怎么做的?流程是什么?
追问场景:这是全栈项目的核心能力,面试官必问。
答:
认证和权限分为 “登录认证” 和 “细粒度权限控制” 两个层面,我分别展开:
第一层:登录认证(JWT + 政务统一认证对接)
政务项目的登录不像互联网应用那样用“手机号+验证码”,而是要对接客户现有的身份认证体系。我们的方案分两种情况:
- 部署在政务内网的项目:对接客户已有的统一身份认证系统(通常是 LDAP 或 OAuth 2.0 / CAS),用户在客户的组织内部系统中登录后,携带 SSO 票据访问我们的系统。后端验证票据有效性后,生成内部的 JWT Token 返回给前端。
- 独立部署的轻量版:采用“用户名 + 密码 + 图形验证码”的经典方案,密码使用 bcrypt 加盐哈希存储,登录成功后生成 JWT Token。
JWT Token 的 payload 中包含
user_id、tenant_id、username、roles等信息,有效期设为 2 小时。前端每次请求在Authorization: Bearer <token>头中携带,后端的中间件会验证 Token 签名和有效期,并解析出用户信息注入到 Request Context 中。
第二层:权限控制(RBAC + 数据域过滤)
权限分两个维度:“能不能做”(操作权限) 和 “能看到什么数据”(数据权限)。
操作权限(RBAC):
我们定义了 5 种角色,按角色分配菜单和按钮可见性:
-ROLE_SYSTEM_ADMIN:全部权限(创建租户、系统配置)。
-ROLE_TENANT_ADMIN:租户内全部管理权限(用户管理、文档导入/删除、审计日志查看)。
-ROLE_KNOWLEDGE_MANAGER:知识库管理权限(上传文档、管理分类、查看解析状态),无用户管理权限。
-ROLE_WINDOW_STAFF:小程序端专用,只能查询和浏览,无管理权限。
-ROLE_GUEST:访客模式(仅限 Web 端的公开政策查询,不登录可用,查询范围受限制)。前端实现:在路由配置中为每个页面设置
permission属性,使用一个AuthGuard高阶组件在路由守卫中做校验,无权限时重定向到 403 页面。菜单组件根据当前用户的permissions列表动态渲染(antd 的Menu组件配合filter)。
数据权限(数据域过滤):
这一点我在前面的章节反复提到过——租户级别的数据隔离。前端在每次 API 请求中并不显式传递
tenant_id,因为后端中间件会从 JWT Token 中解析出tenant_id,自动注入到所有数据访问层(Repository)。前端只要确保“当前登录用户是谁”,后端会自动限定其数据范围。
Q14:(追问)你们如何防止前端权限控制被绕过?比如恶意用户直接在浏览器控制台调用 API?
追问场景:面试官考察安全意识,想看您是否只做了“前端防君子不防小人”。
答:
这个问题很关键。我反复强调一个原则:前端权限控制只做“用户体验优化”(隐藏不可用的按钮和菜单),真正的安全边界在后端。
具体来说:
1. 前端任何权限判断都不作为安全决策依据。
菜单隐藏、按钮 disable、路由守卫——这些都只是让用户界面看起来更干净,但 API 层没有任何一个接口是“仅靠前端传参来决定权限”的。所有 API 接口在进入业务逻辑之前,都会经过一个统一的
PermissionMiddleware:
# FastAPI 中间件
async def permission_middleware(request: Request, call_next):
user = request.state.user # 从 JWT 解析
path = request.url.path
method = request.method
# 从数据库或 Redis 中读取该用户的路由权限配置
has_permission = await permission_service.check(user.roles, path, method)
if not has_permission:
raise HTTPException(status_code=403, detail="无权访问该资源")
return await call_next(request)
这意味着,即使一个前端开发者恶意修改了页面代码、直接暴露了被隐藏的按钮,或者用 Postman 直接调用 API,后端会返回 403,不会执行任何业务逻辑。
2. 数据权限在 SQL 层面强制过滤。
后端的数据访问层(Repository)在组装 SQL 时,会从
request.state.user中提取tenant_id,自动拼接到所有查询的 WHERE 条件中。即使用户 A 通过某种方式拿到了用户 B 的document_id并发起请求,Repository 层的tenant_id过滤会使得查询结果为空,不会返回 B 的数据。
3. CSRF 与 XSS 防护。
- CSRF:我们使用了
fastapi-csrf-protect中间件,在非 GET 请求中要求携带一个从 Cookie 中读取的X-CSRF-Token,前后端配合验证。- XSS:前端对用户输入的 Query 做了 HTML 转义,后端在输出时也使用了
bleach库对模型生成的内容做安全过滤(尤其是管理端展示审计日志时,防止存储型 XSS)。总结一句话:前端权限是“皮肤”,后端权限是“骨骼”。皮肤没了只是不好看,骨骼没了整个系统就塌了。 我们把安全防线建在骨骼上。
Q15:独立全栈交付时,前后端联调是怎么做的?接口契约与规范如何保证?
追问场景:简历中重点写了“安全审计日志”,面试官会确认这不是装饰性描述。
答:
审计日志是政务项目 “合规的硬性要求” ,不是可选项。我们设计了统一的审计日志表
t_audit_log,记录四类关键行为:
审计范围(四类事件):
| 事件类型 | 具体行为 | 记录字段 |
|---|---|---|
| 认证事件 | 登录、登出、Token 刷新、登录失败 | user_id, ip, user_agent, result(success/fail), fail_reason |
| 知识库操作 | 文件上传、文件删除、文件解析状态变更、知识库配置修改 | user_id, tenant_id, target(file_id/file_name), action, detail(JSON) |
| 安全事件 | 敏感词命中拦截、越权访问尝试、异常请求(SQL 注入特征等) | user_id, ip, request_path, request_params, intercept_reason |
| 系统配置变更 | 租户配置修改、权限分配变更、系统参数调整 | user_id, tenant_id, config_key, old_value, new_value |
技术实现细节:
- 异步写入:审计日志的写入走的是 异步通道(通过 Celery 任务或 Redis 缓冲队列),不阻塞主业务流程。因为每次请求都要写日志,如果同步写 MySQL 会导致响应延迟增加。
# 使用 Celery 异步写入审计日志
@celery_app.task
def log_audit(tenant_id: str, user_id: str, action: str, target: str, detail: dict, ip: str):
AuditLog.create(
tenant_id=tenant_id,
user_id=user_id,
action=action,
target=target,
detail=detail,
ip=ip,
created_at=datetime.now()
)
-
敏感信息脱敏:
detail字段虽然是 JSON 格式,但在存储前会过滤掉密码、Token、身份证号等敏感字段,只保留“可审计但不可滥用”的信息。脱敏规则使用正则表达式匹配模式(手机号、身份证号、Token 格式等)。 -
不可篡改设计:审计日志表只允许
INSERT,不允许UPDATE和DELETE(通过数据库权限控制)。业务代码中也没有任何“删除审计日志”的接口。所有日志永久保留(按合规要求保留 5-10 年,通过第 06 章的冷热归档方案实现)。 -
审计日志查看权限:只有
ROLE_TENANT_ADMIN和ROLE_SYSTEM_ADMIN有权限查看审计日志,且查看时只能看到自己租户范围内的日志。日志列表支持按时间、用户、事件类型、IP 等多维度检索,并支持导出为 Excel/CSV 文件,供客户内部审计使用。
追问场景:面试官想听您讲“真实工作中的痛点”而非标准答案,考察问题解决能力。
答:
独立全栈最大的便利就是 “自己调自己” ——不需要等待前后端联调排期,改完后端接口,前端立即适配。但这种便利也带来了陷阱:因为太“默契”,容易忽略接口文档的重要性。 我就踩过一个坑:
典型问题:接口变更没有文档记录,一个月后自己也忘了。
项目开发到第二个月时,我修改了“文档列表”接口的返回结构,把原来嵌套的
authority对象改为了扁平的authority_name字符串。改动很轻量,前端顺便就改了——当时一切正常。但一个月后,我在开发“审计日志导出”功能时,需要调用同一个接口获取文档名称,而我完全忘记了那个字段已经变了,写出来的代码报了undefined错误。查了半天才发现是自己改了接口没留任何记录。事后养成的规范:
1. 先定义 OpenAPI 文件再写实现:所有接口先写 OpenAPI(Swagger)的 YAML 文件,前端基于自动生成的 TypeScript 类型定义来写代码。这样接口变更会强制更新 OpenAPI 文件,而 Git 的 diff 记录了所有历史变更。
2. FastAPI 自动生成 OpenAPI:FastAPI 的@router装饰器和 Pydantic 模型天然支持生成 OpenAPI 文档。我后来要求自己:任何接口的修改,都必须确保/docs(FastAPI 自动生成的 Swagger UI)是可访问且准确的,这相当于强制记录了接口规范。
3. Mock 数据与真实 API 分阶段切换:项目初期前后端(虽然是同一个人)并行开发时,前端先用mock数据跑通 UI,等后端接口就绪后再切换到真实 API。mock数据文件直接作为“接口契约”的参考基准。这个坑让我深刻认识到:即使是一个人开发,也要把自己当成“两个角色”来运作——后端开发者应该用文档和规范来约束前端开发者的“自己”,否则在项目后期或者项目交接时,所有问题都会暴露出来。后来我引入 OpenAPI 后,这种“自己坑自己”的情况再也没有发生过。