07 - 云原生工程化与高并发系统设计
章节定位:面试官对您容器化交付、CI/CD 质量工程与大型高并发分布式系统设计能力的全面考查。本章涵盖 Docker 多阶段镜像优化、K8s 探针配置与优雅停机、GitLab CI 流水线、万级 QPS 流式 AI 系统架构设计、长流程异步解析、多租户隔离、限流降级与全链路可观测性。
Q1:你们的 CI/CD 流水线是怎么设计的?GitLab CI 与 Jenkins 在实际中如何取舍?
追问场景:二面工程化能力考察开场,面试官想评估您的自动化交付成熟度。
答:
我们基于 GitLab CI 构建了完整的 CI/CD 流水线(同时政务小灵通项目中部分环节也复用了我在知道创宇时期积累的 Jenkins 经验)。整个流水线从代码提交到上线分为 5 个阶段:
| 阶段 | 触发条件 | 核心动作 | 失败阻断策略 |
|---|---|---|---|
| 1. 代码提交 → Lint & 单元测试 | 任意 MR 创建/更新 | 代码规范检查(ruff/golangci-lint) + 单元测试(pytest/go test) + 覆盖率报告 |
Lint 不通过或单测失败 → MR 禁止合并 |
| 2. 构建 → 容器镜像打包 | MR 合并到 main 分支 |
Docker 多阶段构建(Python/Go 镜像)+ 推送到私有镜像仓库(Harbor)+ 镜像安全扫描(Trivy) | 构建失败 → 阻断部署 |
| 3. 部署 → 开发环境(Dev) | 镜像推送完成 | K8s 滚动更新(kubectl set image)+ 健康检查(/health 端点验证) |
自动触发,失败回滚 |
| 4. 部署 → 预发布环境(Staging) | Dev 验证通过后手动触发(需审批) | 镜像 Tag 提升(dev-xxx → staging-xxx)+ K8s 更新 + 冒烟测试自动运行 |
手动决策,失败不回滚生产 |
| 5. 上线 → 生产环境(Prod) | Staging 验证通过 + 产品/测试审批 | 金丝雀发布(先 10% 流量 → 观察 30 分钟 → 全量)+ 监控告警自动绑定 | 金丝雀监控指标异常 → 自动回滚 |
一个关键的工程决策:我们没有用“GitOps + ArgoCD”那样完整的声明式 Pull 模式,因为政务内网环境网络策略复杂(K8s 集群与 Git 仓库的网络访问需要额外开白名单)。我们采用了 “Push + 镜像 Tag 提升” 的简化 GitOps 思路——CI 负责构建镜像并推送,部署脚本通过
kubectl set image触发 K8s 滚动更新。这种方式虽然不算“最前沿”,但在政务环境的网络限制下稳定运行了 1 年多,没有因为网络问题导致部署中断。
追问场景:考察工具视野和迁移判断力。
答:
两者我都有较深的使用经验。在知道创宇期间主力用 Jenkins(用了约 3 年),在政务小灵通项目中使用 GitLab CI。我不认为谁“更好”,而是“适用场景不同”:
Jenkins 的优势:
- 插件生态极其丰富:几乎什么都能做(对接各种私有的部署平台、通知系统、自动化测试平台)。
- 灵活度最高:Pipeline as Code(Declarative/Scripted Pipeline)能做非常复杂的条件分支、人工审批、多环境串联。
Jenkins 的劣势:
- 运维负担重:需要单独维护 Jenkins Master/Agent 的部署、插件升级、备份,尤其是 Master 单点故障问题(虽然可以高 tavern,但配置复杂)。
- 配置界面臃肿:虽然有 Pipeline,但很多老项目依然依赖“自由风格”的点击配置,难以版本化追踪。
GitLab CI 的优势:
- 一体化集成:代码仓库 + CI/CD 在一个平台,MR 触发流水线天然关联,CI 的状态直接显示在 MR 页面,体验极佳。
- 配置文件即代码(
.gitlab-ci.yml):所有流水线逻辑放在代码仓库中,天然支持版本管理和 Code Review。我们修改流水线配置也需要经过 MR 审批,避免“一个人改了 Jenkins 配置导致所有人构建失败”。我的选择逻辑:
- 如果是 “遗留系统多、需要对接各种内部老平台” 的复杂环境,Jenkins 的插件生态依然不可替代。
- 如果是 “新项目、全容器化、追求开发体验”(比如政务小灵通),GitLab CI 的一体化体验更胜一筹,运维成本更低。实际现状:政务小灵通用的是 GitLab CI(因为项目新,且代码库在 GitLab 上)。但我保留了 Jenkins 的知识储备,因为如果面试的公司有 Jenkins 的遗留流水线,我可以无缝接盘改造。
Q2:Docker 镜像你们是怎么优化的?体积大概多大?
追问场景:面试官想看您是否关注“镜像瘦身”,而非“能跑就行”。
答:
镜像优化是我比较在意的一个细节,因为政务客户的内网带宽和镜像仓库存储资源通常有限,大镜像会拖慢部署速度、浪费存储成本。我们针对 Python 和 Go 两套技术栈做了不同的优化策略:
Python 镜像(FastAPI 服务):
- 基础镜像:使用
python:3.11-slim-bookworm而非python:3.11(前者约 120MB,后者约 900MB)。因为政务内网不需要gcc、make等编译工具,只要运行时环境就够了。- 多阶段构建:依赖安装阶段用完整镜像,安装完
pip依赖后,只将/usr/local/lib/python3.11/site-packages复制到最终的 slim 镜像中。构建时用--no-cache-dir减少缓存文件。- 最终体积:Python FastAPI 服务的最终镜像控制在 350MB 左右(其中基础镜像 120MB,依赖包约 150MB,业务代码约 80MB)。
Go 镜像(边缘网关控制面 - 辅助说明,体现广度):
- 静态编译:使用
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo编译为纯静态二进制,不依赖 glibc。- 基础镜像:选用
alpine:latest(约 5MB)作为运行时镜像,将编译好的二进制复制进去。- 最终体积:Go 服务最终镜像控制在 15-20MB 左右,镜像拉取速度极快。
通过这两种优化方式,政务客户在低带宽环境下(比如分公司通过 VPN 拉取镜像),也能在 1-2 分钟内完成新版本的镜像下载和容器启动。
Q3:Kubernetes 部署中,你们怎么配置探针来保证服务高可用?遇到过什么容器化踩坑?
追问场景:K8s 生产级必备能力,面试官想确认您不是“只会在 Dev 环境跑 Pod”。
答:
探针配置是 K8s 高可用的“第一道防线”,我在政务小灵通项目中对每个微服务都配置了三种探针,一个都不能少:
1. 启动探针(Startup Probe):
我们有一些服务启动时需要加载模型或建立数据库连接池,初始化时间较长。如果使用就绪探针直接检查,K8s 会在
initialDelaySeconds之后就开始检查,未就绪就重启 Pod,导致“启动即重启”的死循环。配置示例(Python FastAPI):
yaml startupProbe: httpGet: path: /health/startup port: 8000 failureThreshold: 30 # 允许 30 次失败 periodSeconds: 10 # 每 10 秒检查一次
这样给服务长达 300 秒(30 × 10)的启动时间,足够 FastAPI + 数据库连接池 + RAGFlow 客户端初始化完成。
2. 就绪探针(Readiness Probe):
决定流量是否可以被转发到该 Pod。对于 AI 服务,我设计的检查逻辑不仅是“端口通了”,还包含“底层依赖是否就绪”——比如检查 Redis 连接是否正常、RAGFlow 的检索接口是否可达。
如果 Redis 连接断开,就绪探针返回失败,K8s 将该 Pod 从 Service 的 Endpoint 中摘除,流量不会打到这个不健康的 Pod 上,直到它恢复。
yaml readinessProbe: httpGet: path: /health/readiness port: 8000 initialDelaySeconds: 10 periodSeconds: 5 failureThreshold: 3
3. 存活探针(Liveness Probe):
决定 Pod 是否需要重启。这里有一个非常重要的权衡:存活探针的判断标准要比重启阈值更宽松,避免因为轻微的瞬时抖动(比如一次慢查询导致响应超时)就重启 Pod,造成服务中断。我们设的
failureThreshold: 5(连续 5 次失败,约 25 秒后才重启),比就绪探针的3次更宽容。政务场景的额外考量:我们配置了
terminationGracePeriodSeconds: 60,给 Pod 60 秒的优雅关闭时间。当 K8s 决定终止 Pod 时,FastAPI 的shutdown事件中会调用 RAGFlow 的会话关闭接口、刷新未完成的 Celery 任务队列,避免“暴力关停”导致的知识库索引状态不一致。
追问场景:考察基础设施实操经验,面试官想听“真实踩坑”而非“云厂商托管全自动”。
答:
政务项目必须部署在客户指定的政务内网环境中,我们无法使用公有云托管的 K8s 服务(如 EKS、ACK),所以是 “自建 K8s 集群” (使用
kubeadm部署,高可用架构:3 个 Master 节点 + 多个 Worker 节点)。自建确实踩过一些坑:
坑一:ETCD 磁盘 I/O 延迟导致集群不稳定。
问题表现:集群运行一段时间后,
kubectl get pod偶尔卡住,API Server 响应超时。排查发现是 ETCD 所在的磁盘(机械硬盘)I/O 延迟过高,导致 Leader 选举超时。解法:我们重新规划了节点角色,将 ETCD 专用 SSD 磁盘挂载到 Master 节点,并配置了 ETCD 的
--heartbeat-interval和--election-timeout参数来适配内网网络延迟。同时,我们定期备份 ETCD 快照到 MinIO 对象存储,作为集群元数据的灾难恢复手段。
坑二:Ingress Controller 的优雅停机问题。
问题表现:滚动更新时,偶发少量 502 错误。
排查与解法:原来是 Nginx Ingress Controller 在 Pod 销毁时,还保留着该 Pod 的路由记录,而此时 Pod 已经 Terminating。解决方案是三重配合:
1. Pod 配置preStop钩子,在销毁前等待 10 秒(sleep 10),让 Ingress Controller 有足够时间摘除该 Pod 的 Endpoint。
2. 确保 Service 的externalTrafficPolicy: Local保留客户端真实 IP(特定场景需要),不强行修改默认值。
3. 在 Nginx Ingress 的 ConfigMap 中开启proxy-next-upstream-timeout的合理配置,确保重试逻辑。
坑三:资源配置 Requests/Limits 未设置导致的“吵闹邻居”问题。
初期团队成员不重视
resources.requests和resources.limits的配置。一个内存泄露的 Java 服务(虽然我们是 Go/Python 栈,但针对 Python 的 Gunicorn 设置不当也会吃内存)把节点的内存吃满了,导致该节点上的其他 Pod 被 OOM Kill。解法:我们强制要求所有 Deployment 必须配置 Requests 和 Limits,并通过 K8s 的 LimitRange 策略在命名空间级别设置了默认值(默认 Request 为 Limit 的 50%)。同时部署了 Kubernetes 垂直 Pod 自动扩展(VPA) 的推荐模式(仅推荐,不自动应用),定期查看推荐值来手动调整。
避坑声明:我强调的是“踩坑与修补”,而不是“完美无瑕地自建了万级节点集群”。政务项目规模不大(几十个 Pod),这些坑的解法都是工程实践经验,并非高深理论。
Q4:代码 Review 规范与数据库 Schema 变更(Migration)在生产中怎么安全落地?
追问场景:简历中写到了“推动 Code Review 与发布规范落地”,面试官会追问“执行过程中的阻力与解法”。
答:
我在知道创宇担任技术经理期间,推动 Code Review 规范落地的过程中,最大的阻力不是“技术难度”,而是 “大家觉得浪费时间” 。我分三个阶段解决了这个问题:
第一阶段:制定“轻量级”规范,拒绝“论文级”Review。
我没有一上来就要求每行代码都要写三篇评论,而是制定了 “MR 必须满足的三条硬门槛”:
1. CI 必须全绿:Lint + 单测 + 安全扫描必须通过。
2. 必须有至少 1 位同事 Approve:避免“自己 Merge 自己”的情况。
3. 变更文件超过 500 行时,需关联设计文档链接:小改动不需要额外写文档,大改动必须有技术方案。这三条门槛写在 GitLab 的
Merge Request模板里,自动填充到 MR 描述中,减少开发者的认知负担。
第二阶段:引入自动化工具“代劳”一部分人工工作。
我们配置了 GitLab Code Quality 插件(基于 CodeClimate 引擎),自动在 MR 中标注“圈复杂度过高”、“代码重复”等警告。这样 Code Reviewer 不需要自己去数
if-else嵌套层数,工具已经帮忙筛了一轮。同时,SAST 工具(Semgrep)自动扫描安全漏洞,高危问题直接标红并禁止合并。这让人类 Reviewer 的精力可以集中在 “逻辑是否合理”、“设计是否可扩展” 这些工具无法判断的高层次问题上。
第三阶段:建立“Review 责任制”和“发布双签制”。
- Review 责任制:Approve 了代码的 Reviewer 要对上线后的质量承担连带责任。如果上线后出现问题,问责的时候会溯源到 Reviewer。这提升了大家的责任心,不再“随便点个 Approve 就完事”。
- 发布双签制:生产环境发布必须由 “开发负责人 + 技术经理” 双重确认(输入 OTP 动态码验证)。发布操作通过公司内部的“发布管理平台”执行,系统记录每一次发布的版本、时间、操作人、审批人,完全可追溯。
落地效果:这套机制运行半年后,团队的上线回滚率从之前的约 15% 下降到了约 5%,且大部分回滚是因为业务需求变更而不是代码 Bug。大家对 Code Review 的态度也从“被迫”变成了“习惯”。
追问场景:这是一个非常“高阶”的工程问题,面试官用此题区分“会 CRUD”和“懂生产级变更管理”。
答:
这个问题确实是生产环境最危险的环节之一。我遵循 “向前兼容、分批发布、可逆操作” 三个原则。具体流程如下:
原则一:变更必须“向前兼容”(Backward Compatible)。
所有的数据库变更(DDL)必须做到:变更前的旧代码必须能正常运行在变更后的 Schema 上。
- ✅ 安全操作:新增列(
ALTER TABLE ADD COLUMN)且带默认值、新增索引(CREATE INDEX CONCURRENTLY,避免锁表)、新增表。- ❌ 高风险操作:重命名列(
RENAME COLUMN)、删除列(DROP COLUMN)、修改列类型(ALTER COLUMN TYPE)。这些操作会直接破坏旧代码的兼容性,禁止直接执行。如果必须做“不兼容变更”(比如把
status从VARCHAR(10)改为VARCHAR(20)),我们的流程是分 3 个版本发布:
1. 版本 N:新增一个列status_new,双写逻辑(同时写新旧列),读逻辑仍然读旧列。
2. 版本 N+1:停止写旧列,只写新列;读逻辑切到新列。
3. 版本 N+2:删除旧列。
原则二:变更执行与代码部署解耦。
我们使用 Alembic(Python SQLAlchemy 的迁移工具) 管理迁移脚本。迁移脚本的自动执行不在应用启动时触发(
alembic upgrade head),而是由 CI 在 “部署新代码之前” 手动执行(或由 DBA 在运维窗口执行)。具体 CI 流程:
1. CI 构建镜像。
2. (手动审批步骤) 执行alembic upgrade head(迁移数据库)。
3. 迁移成功后,再执行 K8s 滚动更新(部署新代码)。这种“先变更 Schema,后部署代码”的顺序,确保了新代码启动时数据库 Schema 已经就绪。如果迁移失败(比如语法错误),CI 会中断,不会部署新代码,避免了“错误代码 + 错误 Schema”的叠加灾难。
原则三:可逆操作(Rollback Plan)。
每个
upgrade脚本必须配套一个downgrade脚本。虽然生产环境很少真的执行downgrade(因为数据丢失风险),但编写它迫使开发者思考“如何回滚”。在政务客户的实际运维中,我们需要提前将迁移脚本提交给客户的 DBA 团队审核,在约定的“停机窗口”(通常是周末凌晨)由 DBA 手动执行。我们 CI 的自动化迁移主要在开发/测试环境使用,生产环境迁移由人工在窗口操作,并将操作日志截图留痕。
Q5:如果让你设计一个日活 10 万用户、峰值 QPS 5000 的 AI 对话系统(类似政务小灵通),你的整体架构会怎么设计?
追问场景:二面或三面的经典系统设计题。面试官想考察您在“高并发 + AI 场景”下的架构视野。
答:
这是一个典型的“AI 原生高并发”系统设计问题。我会从 “分层架构 + 异步解耦 + 弹性伸缩” 三个维度来回答。我画一个整体架构的分层描述:
第一层:接入层(流量入口)
- DNS + CDN:静态资源(前端 JS/CSS、图片)走 CDN 缓存,减少后端压力。
- Nginx / 网关层:负责 SSL 终结、限流(每 IP/每用户 100 req/min)、负载均衡(轮询或最小连接数)。同时在此层做 “热点问答缓存”——对于完全相同的 Query(MD5 命中),直接返回缓存的完整 SSE 流式响应,不走后续 AI 链路,极大降低 LLM 调用成本。
- WebSocket / SSE 适配:对于长连接(SSE 流式),Nginx 配置
proxy_buffering off和X-Accel-Buffering: no,确保流式数据实时转发。
第二层:应用层(无状态服务集群,水平扩展)
- 使用 FastAPI(Python) 编写对话服务,部署多个 Pod(根据压测结果确定副本数,比如 20-50 个)。由于服务是无状态的(会话状态全部存储在 Redis),可以随意水平扩展。
- 服务内部采用 分层架构:Router 层(HTTP/SSE 路由)→ Service 层(对话编排、Agent 状态机)→ Repository 层(数据访问)。核心的 AI 链路(RAG 检索 + LLM 生成)封装在 Service 层,通过依赖注入实现可测试性。
- 异步非阻塞 I/O:使用
httpx.AsyncClient并发调用 RAGFlow 检索(并行 BM25 + 向量检索)和 LLM API,通过asyncio.gather减少串行等待时间。
第三层:缓存与状态层(Redis 集群)
- 会话状态(Session):存储多轮对话历史、LangGraph Checkpoint、用户上下文(
tenant_id、user_id、dept)。使用 Redis 集群(主从 + 哨兵或 Redis Cluster)保证高可用,分片存储不同租户的数据。- 热点缓存(Cache):高频问题的完整回答缓存,TTL 6 小时,命中后直接返回,预估可降低 20%-30% 的 LLM 调用量。
- 分布式限流计数器:每个用户 + 接口维度的滑动窗口计数器(Redis INCR + EXPIRE),用于精确限流。
第四层:检索底座层(RAGFlow / 向量数据库)
- RAGFlow 本身是有状态服务(依赖 Elasticsearch 或向量数据库),不直接暴露给前端,而是通过应用层的 Service 调用其内部 API。这一层的扩展依赖于底层向量数据库(如 Elasticsearch 或 Milvus)的集群能力。
- 若 QPS 极高,需要提前对向量数据库做 索引分片(Sharding),按租户 ID 哈希分片,避免单点检索成为瓶颈。
第五层:异步任务层(Celery + Redis/Kafka)
- 将 “非实时” 操作全部异步化:文档上传解析、知识库批量导入、冷热数据归档、复杂 Agent 任务的后半段(如超过 10 步的深度对比报告生成)。通过消息队列(Redis 或 Kafka)解耦,Worker 根据任务量弹性伸缩(HPA)。
- 同时利用 Celery 的
acks_late和重试机制来保障任务不丢失。
第六层:数据持久层(MySQL + 对象存储)
- MySQL(分库分表):对话记录表和审计日志表数据量最大,按租户 ID 或时间进行分库分表(比如按月份分表,或者使用
tenant_id % 16做水平分库)。对于超过 3 个月的历史数据,自动归档到对象存储(冷存储)。- MinIO / OSS:存储原始政策文件(PDF/Word/扫描件)和归档的冷数据。
核心设计要点总结:
- 无状态应用层 + 外部化状态存储,确保水平扩展能力。
- 热点缓存(精确命中),降低对 LLM API 的依赖和成本。
- 检索底座独立扩展,确保 RAG 检索不成为瓶颈。
- 异步解耦长耗时任务,保证用户体验(首字延迟)不受后台批处理影响。
- 多租户隔离在每一层都落实(缓存 Key 带 tenant_id、数据库查询隐式注入 tenant_id)。
Q6:(追问)QPS 5000 意味着每秒 5000 个请求,其中可能涉及外部 LLM API 调用(延迟可能 500ms-2s)。你怎么确保系统不被打垮?
追问场景:面试官在 Q1 的基础上深挖“高可用保障”,考察您对“背压(Backpressure)”和“限流降级”的思考。
答:
这是系统设计的核心矛盾——“高并发”对“慢服务”。外部 LLM API 的延迟是系统中最不可控的因素。我采用 “三层防护 + 优雅降级” 策略:
第一层防护:前置限流(预防)
- 边缘限流:在 Nginx/网关层配置基于 IP + 用户 ID 的限流(如单用户 10 req/min,防止单用户刷爆 API)。
- 信号量隔离(Semaphore):应用层使用
asyncio.Semaphore控制同时进行的 LLM API 请求数量(比如设置并发上限为 200)。超出并发上限的请求,直接返回503 Service Unavailable或排队提示,避免将所有请求堆积在内存中导致 OOM。
第二层防护:超时与熔断(保护)
- 超时控制:为每个 LLM API 调用设置 合理的超时时间(如 30 秒,结合业务 SLA),并配合
asyncio.timeout在超时后抛出异常,快速释放资源。- 熔断器模式(Circuit Breaker):在应用层使用 Resilience4j(Java)或 Python 的
pybreaker监控 LLM API 的错误率。如果错误率超过阈值(如 5%),熔断器 打开(Open),后续请求不再调用 LLM,直接走 “降级路径”(返回预设的“系统繁忙,请稍后重试”或使用缓存中的历史相似回答)。经过一段时间(如 30 秒)后,熔断器进入 “半开(Half-Open)” 状态,允许少量请求透传探测服务是否恢复。
第三层防护:降级与兜底(生存)
- 兜底回答:当 LLM API 不可用或超时时,系统不报错崩溃,而是返回 “抱歉,当前 AI 服务繁忙,建议您稍后重试或联系人工客服” 的静态提示,并附带知识库检索到的原文链接(让用户自行查阅)。
- 异步转同步的削峰填谷:如果峰值流量远超处理能力,不直接拒绝所有用户,而是启用 “排队模式”——将用户请求写入消息队列(Kafka),返回一个“任务已接收”的 token,用户稍后通过轮询或 WebSocket 获取结果(牺牲实时性换取可用性)。
容量规划与压测验证:
- 根据预估 QPS 5000,提前压测确定单 Pod 能支撑的并发数(比如单 Pod 支撑 200 QPS),计算出需要部署的 Pod 数量(至少 25 个,留 50% 余量即 40 个),并配置 Kubernetes 的 HPA(Horizontal Pod Autoscaler) 基于 CPU 或自定义指标(如队列深度)自动扩缩容。
Q7:针对文档解析的长流程(上传 → 解析 → 索引 → 完成),你会怎么设计这个异步架构来保证可靠性和可观测性?
追问场景:简历中提到了“长耗时异步任务”,面试官会考察文档处理管道(类似 RAGFlow 的作业系统)的设计。
答:
文档解析是一个典型的 “长耗时、多步骤、易失败” 的异步任务。我把它设计为一个 “状态机驱动的任务管道”,每个任务有明确的状态和可重试性。
状态机设计(6 个状态):
uploaded→queued→parsing(DeepDoc OCR/版面分析)→indexing(向量化入库)→completed/failed
架构组件与流程:
- 接收端(API):用户上传文件,后端保存到 MinIO,生成
task_id,在 MySQL 中插入一条状态为uploaded的记录,立即向 Celery 投递任务并返回202 Accepted(包含 task_id)。- Celery 主任务(编排者):Worker 消费任务,启动一个 “父任务”,使用 Celery 的
chain或group将parsing→indexing串联起来。设置task_time_limit和soft_time_limit避免死循环或无限卡住。- 子任务 1:解析(Parsing):调用 RAGFlow DeepDoc 接口。如果文件过大(> 50MB),触发“大文件处理模式”——将文件拆分成多个部分并行解析(使用 Celery 的
group并发执行),完成后合并结果。- 子任务 2:索引(Indexing):将解析后的文本块(chunks)调用 Embedding 模型生成向量,写入向量数据库。这一步如果因为网络波动失败,配置
retry(指数退避,最多 3 次)。- 状态持久化与进度推送:每个子任务完成时,回调更新数据库中的状态字段(
progress百分比)。同时通过 WebSocket 或 Server-Sent Events 向前端推送进度更新(比如“正在解析第 3/10 页”)。
可靠性保障(三道防线):
- 幂等性:解析和索引操作都是幂等的。通过
task_id+ 文件哈希(MD5)做去重,即使 Worker 重复执行任务,检查到已有相同哈希的completed记录就会直接跳过(参考第 06 章“二次检查”模式)。- 死信与人工介入:重试失败后进入死信队列,数据库状态置为
failed,并触发企业微信/钉钉告警,由运营人员通过管理端“重新解析”按钮手动重试。- 孤儿任务扫描(自愈):一个独立的 Celery Beat 定时任务每 10 分钟扫描一次数据库,如果发现状态为
parsing或indexing超过 30 分钟未更新的任务,自动将它们重新投递到队列(并增加重试计数,防止无限循环)。
可观测性:
- 在 Grafana 中为文档解析任务建立专门的 Dashboard:实时展示“队列长度”、“解析成功率”、“平均解析耗时”、“失败原因分布(OCR 失败/超时/向量库写入失败)”。
- 日志中携带
task_id和file_hash,方便追踪单个文件的完整生命周期。
Q8:多租户隔离在数据层面有哪些方案?你们用的是哪种?为什么?
追问场景:多租户是企业级 SaaS 和政务项目的标配,面试官会考察您的隔离级别认知和决策能力。
答:
多租户数据隔离有 三种主流方案,我按“隔离性从高到低”排列:
| 方案 | 实现方式 | 隔离性 | 运维成本 | 资源利用率 | 适用场景 |
|---|---|---|---|---|---|
| 独立数据库(DB per Tenant) | 每个租户拥有独立的物理数据库实例 | 最高(物理隔离) | 最高(备份、迁移、连接池管理复杂) | 最低(小租户浪费资源) | 金融、政务高安全等级场景 |
| 共享数据库 + 独立 Schema | 同一个数据库实例,不同租户使用不同 Schema(PostgreSQL 的 schema 或 MySQL 的 database) |
较高(逻辑隔离) | 中等 | 中等 | 中等规模 SaaS(如 50-500 租户) |
| 共享数据库 + 共享 Schema(行级隔离) | 所有租户共享同一张表,通过 tenant_id 字段区分 |
较低(逻辑隔离,依赖应用层过滤) | 最低(一套表结构、一套备份) | 最高 | 大规模 SaaS、多租户数量极大(如 1000+ 租户) |
我们政务小灵通选择的是“第三种 + 增强安全”——即 共享 Schema + 租户 ID 行级隔离,但在应用层和 SQL 层做了强制过滤:
- 选择原因:政务场景的租户数量较多(可能数百个单位),但每个租户的数据量不大(几千份文件)。如果为每个租户独立建库或独立 Schema,维护成本太高(备份策略、迁移脚本、连接池配置都会极其复杂),且资源利用率低。
- 隔离保障:我们通过数据库 “行级安全策略(RLS,Row-Level Security)”(PostgreSQL 支持)或 “应用层强制过滤” 来保证
tenant_id不被绕过。在 Repository 层,所有查询(SELECT/UPDATE/DELETE)都通过一个 BaseDAO 自动注入tenant_id,开发者无法“忘记”加过滤条件。这是 “防御性编程” 的体现,而非依赖开发者的自觉。- 备份与恢复:由于共享表,备份是整个库一起做的。恢复时,如果需要为单个租户恢复数据,我们会通过
mysqldump --where="tenant_id='xxx'"导出该租户的数据。如果安全等级要求极高(比如涉密单位),我会建议客户选择方案一(独立数据库),物理隔离,确保绝对的数据分离,即使应用层被攻破,攻击者也难以跨租户访问数据。这需要在系统设计之初就做好配置化的多租户支持(通过 Spring Cloud 或类似框架的 AbstractRoutingDataSource),而不是写死在代码里。
Q9:如何设计一个“限流”系统来保护你的后端服务?能讲讲具体的算法和落地吗?
追问场景:考察基础架构能力,面试官会特别关注算法的工程实现,而非仅仅背诵令牌桶概念。
答:
限流的核心目标是 “保护系统在过载时依然可用”。我结合两种主流算法和我们的实际落地场景来回答:
算法对比:
- 令牌桶(Token Bucket):允许一定的突发流量(Burst)。桶容量为
b,以固定速率r填充令牌。请求到来时,如果桶中有令牌则通过,否则拒绝。适合应对“瞬时突发流量”的场景(比如新闻热点导致短暂高峰)。- 漏桶(Leaky Bucket):请求以任意速率进入桶中,以固定速率“漏出”到服务端。流量被强制整形为平滑的速率,适合保护下游脆弱系统。
- 滑动窗口(Sliding Window):基于时间窗口(如 1 分钟)记录请求计数,精度比固定窗口更高(避免了“窗口边界突刺”问题)。适合精确计数限流(比如“单用户每分钟 30 次”)。
我们的落地策略(多层限流):
- 边缘层(Nginx):配置
limit_req_zone和limit_conn_zone,针对 IP 地址进行 漏桶限流(如每秒 100 个请求)。这是第一道防线,粗粒度、高性能(基于 Nginx 的共享内存)。- 应用层(FastAPI 中间件):使用 滑动窗口(基于 Redis) 进行精细化限流。例如,针对
tenant_id + user_id + api_path维度,限制每分钟 30 次请求。利用 Redis 的ZSET存储时间戳,ZREMRANGEBYSCORE清理过期数据,ZCARD获取计数。或者使用更简单的INCR+EXPIRE实现固定窗口(对于大多数场景,固定窗口的精度已经足够)。- 核心资源层(LLM API 调用):使用 令牌桶(或信号量)控制对 LLM API 的并发调用数。这个桶的容量与 LLM 服务商的 QPS 配额对齐,防止因超频被供应商封禁。
“热点缓存”作为限流的补充(非对称降级):
当流量接近限流阈值时,我们不单纯粗暴地拒绝所有用户。对于高频问题(如“高新区的招聘政策”),缓存命中率较高,这些请求不需要经过 LLM API,因此可以“豁免”限流计数,或者将限流阈值调高。这相当于 “动态调整限流水位”——在保证核心资源(LLM API)不被打垮的前提下,尽可能多地服务用户。
Q10:在设计系统时,如何保证最终一致性?能结合 Agent 任务编排举个例子吗?
追问场景:考察分布式系统理论在实战中的应用。Agent 的“计划-执行-汇总”长流程天生需要最终一致性。
答:
在 Agent 编排场景中,“政策对比任务”涉及多个步骤(计划 → 查 A 区 → 查 B 区 → 汇总),跨度可能长达数分钟,无法使用分布式事务(2PC)。因此我采用 “Saga 模式” 来保证最终一致性。
Saga 模式的核心思想:将一个长事务拆分为多个本地事务(子步骤),每个子步骤都对应一个“补偿操作”(Compensation)。如果某个子步骤失败,则反向执行之前子步骤的补偿操作。
在 Agent 任务中的具体落地(以“政策对比”为例):
- 步骤 1(计划生成):Planner 调用 LLM 生成计划,将计划写入 Redis(状态持久化)。补偿操作:删除 Redis 中的计划,更新状态为“任务取消”。
- 步骤 2-4(并行查询):Executor 调用
search_policy工具查询不同区的政策,将结果片段暂存到 Redis 的collected_docs列表中。补偿操作:删除对应的查询结果片段,并记录一条“数据已回滚”的审计日志(实际难以撤回已写入的数据,但逻辑上通过状态标记为“废弃”,并在最终汇总时忽略该数据)。- 步骤 5(汇总生成):Summarizer 调用 LLM 生成对比报告。补偿操作:删除生成的报告,返回“任务失败”状态。
失败场景处理:
- 如果 步骤 3(查 B 区) 失败(比如检索超时),Saga 协调器会启动补偿流程:
1. 调用步骤 2 的补偿操作(将 A 区的查询结果标记为“孤立数据”,不进入最终汇总)。
2. 调用步骤 1 的补偿操作(清理计划)。
3. 向前端返回错误提示:“B 区政策检索失败,建议缩小范围重试。”- 如果 步骤 5(汇总生成) 失败,我们不是回滚前面所有步骤,而是重试步骤 5(因为前面收集的数据是有效的,重新调用 LLM 即可)。这体现了 “补偿操作”的差异化设计——并非所有失败都需要回滚全部状态。
与事务性消息的结合:
在 Agent 任务中,如果 Executor 调用
send_notification工具发送了邮件,我们使用 “本地消息表 + 定期轮询” 或 “事务性发件箱模式(Transactional Outbox)” 来保证“任务完成”和“邮件发送”之间的最终一致性。即:Agent 状态更新和“待发送邮件”记录在同一个数据库事务中提交,然后一个独立的后台进程从数据库扫描“待发送”记录并异步发送邮件。
Q11:你们的系统需要应对“热点”数据(比如某个政策文件突然火了)。在缓存和数据库层面如何应对?
追问场景:考察应对“流量尖刺”的实战经验,尤其是如何防止缓存击穿和雪崩。
答:
“突发热点”在政务场景很常见——一个重磅政策在某个时刻发布,短时间内大量用户同时查询。这会给缓存和数据库带来双向压力。我采用 “多级缓存 + 防击穿机制” 来应对。
架构分层(三级缓存):
- 一级缓存(本地缓存 Caffeine/进程内缓存):每个 Pod 内维护一个小型 LRU 缓存(容量 1000 条),TTL 5 分钟。针对极端高频的“超级热点”(如国务院某条重磅新闻),在极短时间内(秒级)被成千上万用户查询,请求在 Pod 内部就被拦截,连 Redis 都不需要访问。
- 二级缓存(Redis 集中式缓存):存储常规热点回答,TTL 6 小时。Key 为
qa:{md5(query+tenant)}。- 三级存储(MySQL + RAGFlow):兜底数据源。
热点防护机制(防击穿/雪崩):
- 缓存击穿(Cache Breakdown):当某个热点 Key 刚好失效,大量请求同时打到数据库/RAGFlow。我们使用 “互斥锁(Mutex)”:在 Redis 中设置一个锁 Key(如
lock:qa:md5),只有第一个请求能获取锁并重建缓存,后续请求等待锁释放,直接读缓存。Go 中可用singleflight或 Python 中使用aioredis分布式锁 +asyncio的wait来实现。- 缓存雪崩(Cache Avalanche):大量 Key 在同一时间过期。我们将热点 Key 的 TTL 设置为 “基础 TTL + 随机偏移(如 6h + rand(0, 600s))”,避免集中失效。
- 缓存预热(Cache Warm-up):当系统刚启动或流量突增时,我们通过定时任务或离线分析,在流量高峰前将可能成为热点的前 N 个问题(基于历史统计)预先加载到 Redis 缓存中。
“数据新鲜度”与“缓存更新”的权衡:
政务政策不允许“过期回答”。当管理员上传新政策文件时,我们会发送一个 “缓存失效事件” 到消息队列,所有 Pod 消费该事件后,主动删除相关缓存 Key。同时针对时间敏感(有废止日期)的缓存 Key,我们设置较短的 TTL(如 30 分钟),保证在关键日期前后,缓存自动刷新。
Q12:你在系统设计中如何考虑“全链路可观测性”?线上故障的快速排查 SOP 是什么?
追问场景:考察运维能力,面试官想知道您的系统是“黑盒”还是“白盒”。
答:
我遵循 “Metrics + Logs + Traces” 三大支柱来构建可观测性体系,但针对政务内网环境做了适配(Prometheus 和 Grafana 可以部署,但链路追踪因网络限制略有弱化):
支柱一:Metrics(指标监控)—— Prometheus + Grafana
我们在每个 FastAPI 服务中集成了
prometheus-fastapi-instrumentator,自动暴露标准的 HTTP 指标(请求数、延迟分布、错误率),并自定义了两个业务指标:
-rag_retrieval_duration_seconds:RAG 检索耗时直方图,用于监控知识库检索链路的性能退化。
-agent_step_total:Agent 任务执行的步数统计,用于监测是否有异常的“步数爆炸”。在 Grafana 中配置了三个核心告警规则,通过 AlertManager 推送到企业微信:
- 服务不可用告警:up{job="fastapi"} == 0持续 1 分钟 → 紧急。
- 高错误率告警:rate(http_requests_total{status=~"5.."}[5m]) > 0.05(5% 的 5xx 错误率)→ 紧急。
- 慢响应告警:histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) > 3s→ 告警(不紧急,用于性能预警)。
支柱二:Logs(日志)—— ELK 或 Loki 轻量版
政务内网资源有限,我们未部署完整的 Elasticsearch 集群,而是使用了轻量的 Loki + Grafana 组合。日志输出格式强制为结构化 JSON(通过 Python 的
python-json-logger),每行日志包含timestamp、level、trace_id、user_id、tenant_id、message等字段。故障排查时,在 Grafana 中通过
{trace_id="xxx"}即可检索出该请求全链路的日志,极大加快了定位速度。
支柱三:Traces(链路追踪)—— 基于自定义 Trace ID
由于政务内网网络策略限制,部署 Jaeger 或 Zipkin 需要额外的网络开放,我们选择了“轻量级”方案:在 FastAPI 中间件层生成全局唯一的
trace_id,通过logging的contextvars传递到所有异步调用栈中(包括 Celery 任务)。调用外部服务时,将trace_id放在 HTTP HeaderX-Trace-ID中传递给下游。虽然无法做到自动可视化火焰图,但通过日志检索trace_id可以手动串联起完整的请求链路,对于政务内网规模来说,已经足够定位 95% 以上的故障根因。
故障定位 SOP(标准作业程序):
当告警触发时,我的排查路径是固定的:
1. 看 Grafana:是哪个服务报错?错误率是突然飙升还是缓慢爬升?(快速判断是“发布导致”还是“容量不足”)。
2. 看 Loki:用trace_id或user_id过滤日志,找到具体的报错堆栈。
3. 看 K8s:kubectl describe pod看事件,kubectl top pod看资源,判断是否是 OOM 或资源瓶颈。
追问场景:考察运维思维和工程成熟度,面试官希望候选人具备“让系统可被诊断”的设计能力。
答:
我在架构设计时遵循 “三大支柱(Metrics、Logging、Tracing)” 原则,并在前文可观测性监控体系中已展开。这里我重点讲 故障排查的 SOP(标准作业程序),这是检验系统“可观测性设计”是否有效的试金石。
线上故障排查 SOP(按顺序执行):
第一反应:确认影响面(1-3 分钟)
- 打开 Grafana 大盘,看核心业务指标:QPS 是否下降?错误率(5xx)是否飙升?P99 延迟是否陡增?
- 快速判断:是 “全量故障”(所有用户受影响)还是 “局部故障”(仅特定租户或特定 API 受影响)?第二层:定位异常服务(3-5 分钟)
- 进入 K9s / Kubernetes Dashboard,检查 Pod 状态。是否有 Pod 处于CrashLoopBackOff?CPU/Memory 是否达到 Limit 被 OOM Kill?
- 如果是“慢响应”而非“报错”,检查下游依赖的健康状态(LLM API 是否超时?RAGFlow 是否高负载?数据库连接池是否耗尽?)。第三层:通过 Trace ID 串联日志(5-10 分钟)
- 从报警中提取一个报错请求的trace_id,去 Loki(日志系统) 搜索该 trace_id 的所有日志。查看调用栈和具体的错误堆栈(如ConnectionError、TimeoutError)。
- 如果是“全量慢”,去 MySQL 慢查询日志 或 APM(如 SkyWalking/Pyroscope) 看是否有“全表扫描”或“死锁”导致数据库成为瓶颈。第四层:决策与止损(10-15 分钟)
- 如果故障由新版本发布导致 → 立即回滚(Rollback)。
- 如果故障由依赖服务(如 LLM API)宕机导致 → 启用降级策略(熔断,走静态兜底回答)。
- 如果故障由流量突增导致 → 扩容 Pod(kubectl scale)或临时调整限流阈值。
- 如果故障由数据问题导致(如某个文件解析导致 RAGFlow 崩溃)→ 摘除异常租户(将该租户的流量引流到隔离的降级集群,或暂时禁用其检索功能)。第五层:根因分析与复盘(事后 24 小时内)
- 将故障根因记录到 “事故报告(Incident Report)”,包含时间线、影响范围、Root Cause、短期修复措施、长期改进方案(比如增加更细粒度的监控指标)。
我认为“可观测性”的核心不在于工具多先进,而在于“排障时能不能快速找到那条关键的日志”。 因此我非常强调 “结构化日志(JSON 格式)” 和 “Trace ID 全链路透传”,这两件事做到位了,80% 的故障都能在 10 分钟内定位。