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/扫描件)和归档的冷数据。

核心设计要点总结:

  1. 无状态应用层 + 外部化状态存储,确保水平扩展能力。
  2. 热点缓存(精确命中),降低对 LLM API 的依赖和成本。
  3. 检索底座独立扩展,确保 RAG 检索不成为瓶颈。
  4. 异步解耦长耗时任务,保证用户体验(首字延迟)不受后台批处理影响。
  5. 多租户隔离在每一层都落实(缓存 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

架构组件与流程:

  1. 接收端(API):用户上传文件,后端保存到 MinIO,生成 task_id,在 MySQL 中插入一条状态为 uploaded 的记录,立即向 Celery 投递任务并返回 202 Accepted(包含 task_id)。
  2. Celery 主任务(编排者):Worker 消费任务,启动一个 “父任务”,使用 Celery 的 chain 或 group 将 parsing → indexing 串联起来。设置 task_time_limit 和 soft_time_limit 避免死循环或无限卡住。
  3. 子任务 1:解析(Parsing):调用 RAGFlow DeepDoc 接口。如果文件过大(> 50MB),触发“大文件处理模式”——将文件拆分成多个部分并行解析(使用 Celery 的 group 并发执行),完成后合并结果。
  4. 子任务 2:索引(Indexing):将解析后的文本块(chunks)调用 Embedding 模型生成向量,写入向量数据库。这一步如果因为网络波动失败,配置 retry(指数退避,最多 3 次)。
  5. 状态持久化与进度推送:每个子任务完成时,回调更新数据库中的状态字段(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 次”)。

我们的落地策略(多层限流):

  1. 边缘层(Nginx):配置 limit_req_zone 和 limit_conn_zone,针对 IP 地址进行 漏桶限流(如每秒 100 个请求)。这是第一道防线,粗粒度、高性能(基于 Nginx 的共享内存)。
  2. 应用层(FastAPI 中间件):使用 滑动窗口(基于 Redis) 进行精细化限流。例如,针对 tenant_id + user_id + api_path 维度,限制每分钟 30 次请求。利用 Redis 的 ZSET 存储时间戳,ZREMRANGEBYSCORE 清理过期数据,ZCARD 获取计数。或者使用更简单的 INCR + EXPIRE 实现固定窗口(对于大多数场景,固定窗口的精度已经足够)。
  3. 核心资源层(LLM API 调用):使用 令牌桶(或信号量)控制对 LLM API 的并发调用数。这个桶的容量与 LLM 服务商的 QPS 配额对齐,防止因超频被供应商封禁。

“热点缓存”作为限流的补充(非对称降级):

当流量接近限流阈值时,我们不单纯粗暴地拒绝所有用户。对于高频问题(如“高新区的招聘政策”),缓存命中率较高,这些请求不需要经过 LLM API,因此可以“豁免”限流计数,或者将限流阈值调高。这相当于 “动态调整限流水位”——在保证核心资源(LLM API)不被打垮的前提下,尽可能多地服务用户。

Q10:在设计系统时,如何保证最终一致性?能结合 Agent 任务编排举个例子吗?

追问场景:考察分布式系统理论在实战中的应用。Agent 的“计划-执行-汇总”长流程天生需要最终一致性。

答:

在 Agent 编排场景中,“政策对比任务”涉及多个步骤(计划 → 查 A 区 → 查 B 区 → 汇总),跨度可能长达数分钟,无法使用分布式事务(2PC)。因此我采用 “Saga 模式” 来保证最终一致性。

Saga 模式的核心思想:将一个长事务拆分为多个本地事务(子步骤),每个子步骤都对应一个“补偿操作”(Compensation)。如果某个子步骤失败,则反向执行之前子步骤的补偿操作。

在 Agent 任务中的具体落地(以“政策对比”为例):

  1. 步骤 1(计划生成):Planner 调用 LLM 生成计划,将计划写入 Redis(状态持久化)。补偿操作:删除 Redis 中的计划,更新状态为“任务取消”。
  2. 步骤 2-4(并行查询):Executor 调用 search_policy 工具查询不同区的政策,将结果片段暂存到 Redis 的 collected_docs 列表中。补偿操作:删除对应的查询结果片段,并记录一条“数据已回滚”的审计日志(实际难以撤回已写入的数据,但逻辑上通过状态标记为“废弃”,并在最终汇总时忽略该数据)。
  3. 步骤 5(汇总生成):Summarizer 调用 LLM 生成对比报告。补偿操作:删除生成的报告,返回“任务失败”状态。

失败场景处理:

  • 如果 步骤 3(查 B 区) 失败(比如检索超时),Saga 协调器会启动补偿流程:
    1. 调用步骤 2 的补偿操作(将 A 区的查询结果标记为“孤立数据”,不进入最终汇总)。
    2. 调用步骤 1 的补偿操作(清理计划)。
    3. 向前端返回错误提示:“B 区政策检索失败,建议缩小范围重试。”
  • 如果 步骤 5(汇总生成) 失败,我们不是回滚前面所有步骤,而是重试步骤 5(因为前面收集的数据是有效的,重新调用 LLM 即可)。这体现了 “补偿操作”的差异化设计——并非所有失败都需要回滚全部状态。

与事务性消息的结合:

在 Agent 任务中,如果 Executor 调用 send_notification 工具发送了邮件,我们使用 “本地消息表 + 定期轮询” 或 “事务性发件箱模式(Transactional Outbox)” 来保证“任务完成”和“邮件发送”之间的最终一致性。即:Agent 状态更新和“待发送邮件”记录在同一个数据库事务中提交,然后一个独立的后台进程从数据库扫描“待发送”记录并异步发送邮件。

Q11:你们的系统需要应对“热点”数据(比如某个政策文件突然火了)。在缓存和数据库层面如何应对?

追问场景:考察应对“流量尖刺”的实战经验,尤其是如何防止缓存击穿和雪崩。

答:

“突发热点”在政务场景很常见——一个重磅政策在某个时刻发布,短时间内大量用户同时查询。这会给缓存和数据库带来双向压力。我采用 “多级缓存 + 防击穿机制” 来应对。

架构分层(三级缓存):

  1. 一级缓存(本地缓存 Caffeine/进程内缓存):每个 Pod 内维护一个小型 LRU 缓存(容量 1000 条),TTL 5 分钟。针对极端高频的“超级热点”(如国务院某条重磅新闻),在极短时间内(秒级)被成千上万用户查询,请求在 Pod 内部就被拦截,连 Redis 都不需要访问。
  2. 二级缓存(Redis 集中式缓存):存储常规热点回答,TTL 6 小时。Key 为 qa:{md5(query+tenant)}。
  3. 三级存储(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 Header X-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. 第一反应:确认影响面(1-3 分钟)
    - 打开 Grafana 大盘,看核心业务指标:QPS 是否下降?错误率(5xx)是否飙升?P99 延迟是否陡增?
    - 快速判断:是 “全量故障”(所有用户受影响)还是 “局部故障”(仅特定租户或特定 API 受影响)?

  2. 第二层:定位异常服务(3-5 分钟)
    - 进入 K9s / Kubernetes Dashboard,检查 Pod 状态。是否有 Pod 处于 CrashLoopBackOff?CPU/Memory 是否达到 Limit 被 OOM Kill?
    - 如果是“慢响应”而非“报错”,检查下游依赖的健康状态(LLM API 是否超时?RAGFlow 是否高负载?数据库连接池是否耗尽?)。

  3. 第三层:通过 Trace ID 串联日志(5-10 分钟)
    - 从报警中提取一个报错请求的 trace_id,去 Loki(日志系统) 搜索该 trace_id 的所有日志。查看调用栈和具体的错误堆栈(如 ConnectionError、TimeoutError)。
    - 如果是“全量慢”,去 MySQL 慢查询日志 或 APM(如 SkyWalking/Pyroscope) 看是否有“全表扫描”或“死锁”导致数据库成为瓶颈。

  4. 第四层:决策与止损(10-15 分钟)
    - 如果故障由新版本发布导致 → 立即回滚(Rollback)。
    - 如果故障由依赖服务(如 LLM API)宕机导致 → 启用降级策略(熔断,走静态兜底回答)。
    - 如果故障由流量突增导致 → 扩容 Pod(kubectl scale)或临时调整限流阈值。
    - 如果故障由数据问题导致(如某个文件解析导致 RAGFlow 崩溃)→ 摘除异常租户(将该租户的流量引流到隔离的降级集群,或暂时禁用其检索功能)。

  5. 第五层:根因分析与复盘(事后 24 小时内)
    - 将故障根因记录到 “事故报告(Incident Report)”,包含时间线、影响范围、Root Cause、短期修复措施、长期改进方案(比如增加更细粒度的监控指标)。

我认为“可观测性”的核心不在于工具多先进,而在于“排障时能不能快速找到那条关键的日志”。 因此我非常强调 “结构化日志(JSON 格式)” 和 “Trace ID 全链路透传”,这两件事做到位了,80% 的故障都能在 10 分钟内定位。