后端 Python / Node 面试题(完整答案版)

锚点项目:FastAPI + asyncio 异步管线、Express/Nest.js 通用服务、Tauri/Rust 桌面端


一、Python 后端

Q1:FastAPI 你最喜欢的几个特性?

标准答案

  1. Pydantic 模型:请求/响应类型校验自动化,错误信息结构化;
  2. 依赖注入Depends() 解耦中间件、鉴权、DB 会话;
  3. 异步原生async def 端点 + httpx + asyncpg,IO 密集场景吞吐高;
  4. OpenAPI 自动生成:Swagger / ReDoc 零配置;
  5. WebSocket 支持:和 LLM 流式输出天然契合。

核心代码骨架

python 复制代码
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated

app = FastAPI()

# 1. Pydantic 模型
class DeviceCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=50)
    type: str
    power_kw: float = Field(..., gt=0, le=10000)

class DeviceOut(BaseModel):
    id: int
    name: str
    type: str
    model_config = {"from_attributes": True}  # 允许从 ORM 转

# 2. 依赖注入:DB 会话
async def get_db() -> AsyncSession:
    async with SessionLocal() as session:
        yield session

# 3. 鉴权依赖
async def current_user(token: str = Depends(oauth2_scheme), db = Depends(get_db)):
    user = await auth_service.verify(token, db)
    if not user:
        raise HTTPException(401, "invalid token")
    return user

DB = Annotated[AsyncSession, Depends(get_db)]
User = Annotated[UserModel, Depends(current_user)]

# 4. 路由
@app.post("/devices", response_model=DeviceOut, status_code=201)
async def create_device(body: DeviceCreate, db: DB, user: User):
    return await device_service.create(db, body, owner_id=user.id)

追问应对

  • Q:Pydantic v1 / v2 你用哪个? v2,性能比 v1 提升 5-50x(核心用 Rust 重写)。迁移成本主要是 .dict().model_dump()Configmodel_config、validator 装饰器变了。新项目直接 v2。

Q2:asyncio 你踩过什么坑?

标准答案

  • 同步阻塞混入 :在 async 函数里调同步重活(如 cv 处理、PIL)→ 阻塞 event loop。解法:asyncio.to_thread()loop.run_in_executor
  • 不 await 的 Taskasyncio.create_task 后不 await,异常被吞,task 也可能被 GC。解法:用 task set 持有引用 + add_done_callback
  • gather 部分失败 :默认一个失败全部取消。用 return_exceptions=True 收集所有结果再统一处理;
  • 超时取消传播:CancelledError 必须重新抛出,吞掉会导致取消失效;
  • 资源泄漏:HTTP 连接、文件句柄、DB 连接必须用 async with;
  • 嵌套 loop :在 Jupyter / 同步代码里用 asyncio.run 会冲突,用 nest_asyncioasyncio.new_event_loop

核心代码骨架

python 复制代码
import asyncio
from PIL import Image

# 错误:阻塞 event loop
async def bad_resize(path: str):
    img = Image.open(path)        # 同步阻塞
    img.thumbnail((100, 100))     # 同步阻塞
    img.save(f"{path}.thumb.jpg")

# 正确:丢给线程池
async def good_resize(path: str):
    def _do():
        img = Image.open(path)
        img.thumbnail((100, 100))
        img.save(f"{path}.thumb.jpg")
    await asyncio.to_thread(_do)

# 错误:fire-and-forget 异常被吞
async def bad_background():
    asyncio.create_task(some_task())   # task 没被持有,异常没人看

# 正确:持有引用 + 错误回调
_bg_tasks: set[asyncio.Task] = set()
def safe_background(coro):
    task = asyncio.create_task(coro)
    _bg_tasks.add(task)
    task.add_done_callback(lambda t: (_bg_tasks.discard(t), t.exception() and log.exception("bg error", exc_info=t.exception())))
    return task

# 错误:吞掉 CancelledError
async def bad_handle():
    try:
        await long_op()
    except Exception:    # ⚠️ Exception 包含 CancelledError (3.7) 或不包含 (3.8+)
        pass

# 正确:CancelledError 必须 re-raise
async def good_handle():
    try:
        await long_op()
    except asyncio.CancelledError:
        # 清理资源
        await cleanup()
        raise
    except Exception as e:
        log.exception(e)

追问应对

  • Q:asyncio.to_thread 和 ProcessPoolExecutor 怎么选? IO 密集 / 小 CPU 任务用 to_thread(线程池,GIL 不放但 IO 时会让出);CPU 重活(图像处理、ML 推理)必须 ProcessPoolExecutor 绕开 GIL,但要注意 fork/spawn 和参数 pickle 开销。

Q3:你说的"async generator 流式产出"怎么落地?

标准答案

  • 上游用 httpx.AsyncClient.stream 而不是普通 get
  • yield 出来的每个 chunk 立刻通过 ws 推送;
  • 客户端断开 → WebSocketDisconnect 捕获 → 上游 context 自动退出释放连接。

核心代码骨架(已在 02 RAG 节展开,这里给纯 Python 版的不同侧重):

python 复制代码
from typing import AsyncIterator
import httpx, json
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

async def stream_llm(prompt: str) -> AsyncIterator[str]:
    """async generator:边收边吐"""
    async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=None)) as client:
        async with client.stream(
            "POST", LLM_URL,
            headers={"Authorization": f"Bearer {API_KEY}"},
            json={"model": "gpt-4", "messages": [{"role": "user", "content": prompt}], "stream": True}
        ) as resp:
            async for line in resp.aiter_lines():
                if not line.startswith("data:"): continue
                payload = line[5:].strip()
                if payload == "[DONE]": break
                try:
                    delta = json.loads(payload)["choices"][0]["delta"].get("content")
                    if delta: yield delta
                except (json.JSONDecodeError, KeyError):
                    continue

# 也可作为 HTTP SSE 端点
from fastapi.responses import StreamingResponse

@app.get("/stream")
async def sse_endpoint(q: str):
    async def event_source():
        async for chunk in stream_llm(q):
            yield f"data: {json.dumps({'chunk': chunk}, ensure_ascii=False)}\n\n"
        yield "data: [DONE]\n\n"
    return StreamingResponse(event_source(), media_type="text/event-stream")

@app.websocket("/ws/chat")
async def ws_chat(ws: WebSocket):
    await ws.accept()
    try:
        data = await ws.receive_json()
        async for chunk in stream_llm(data["q"]):
            await ws.send_json({"delta": chunk})
        await ws.send_json({"done": True})
    except WebSocketDisconnect:
        pass    # httpx context 自动关闭释放上游连接

追问应对

  • Q:FastAPI SSE 和 WebSocket 怎么选? 单向流式输出(LLM 答案)→ SSE 简单可靠、能走 HTTP/2、浏览器原生 EventSource;双向(聊天、协同)→ WebSocket。我们 LLM 默认走 WebSocket 是因为前端要发取消信号。

Q4:信号量 + 超时取消怎么写?

标准答案

  • 并发限制asyncio.Semaphore
  • 超时asyncio.wait_for (3.10-) 或 asyncio.timeout (3.11+);
  • 取消传播:捕获 CancelledError 后必须 re-raise,否则取消失效。

核心代码骨架

python 复制代码
import asyncio

class LLMPool:
    def __init__(self, max_concurrency=20):
        self.sem = asyncio.Semaphore(max_concurrency)

    async def call(self, prompt: str, timeout: float = 30):
        async with self.sem:
            try:
                # 3.11+ 推荐用 asyncio.timeout,更精确
                async with asyncio.timeout(timeout):
                    return await self._do_call(prompt)
            except TimeoutError:
                log.warning("llm timeout: %s", prompt[:50])
                raise
            except asyncio.CancelledError:
                log.info("llm cancelled by client")
                # 清理资源
                raise

    async def _do_call(self, prompt):
        async with httpx.AsyncClient() as client:
            r = await client.post(LLM_URL, json={"prompt": prompt})
            r.raise_for_status()
            return r.json()

pool = LLMPool(max_concurrency=20)

追问应对

  • Q:信号量数怎么定? 压测出上游单 key 不限流的并发上限,留 20% 余量。线上发现限流频繁就调小或加 key。终极方案是用 token bucket 按 RPM/TPM 限流,比信号量更精确,对应 aiolimiter 库。

Q5:Celery / RQ / Dramatiq 怎么选?

标准答案

维度 Celery RQ Dramatiq
复杂度 高(功能全) 低(基于 Redis)
生态 最成熟 简单 现代
路由/优先级
适合 大型项目 中小项目 喜欢简洁的中型项目

DeepPark 用 Celery:文档解析 / 向量入库异步化,Beat 调度定时清理任务。

核心代码骨架(Celery 实战):

python 复制代码
# celery_app.py
from celery import Celery

celery_app = Celery(
    "deeppark",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",
    include=["tasks.doc", "tasks.embed"],
)
celery_app.conf.update(
    task_serializer="json",
    accept_content=["json"],
    timezone="Asia/Shanghai",
    enable_utc=False,
    task_acks_late=True,            # 任务完成后再 ack,防止 worker 崩溃丢任务
    task_reject_on_worker_lost=True,
    worker_prefetch_multiplier=1,   # 长任务防止单 worker 囤积
    beat_schedule={
        "cleanup-temp-files": {
            "task": "tasks.doc.cleanup",
            "schedule": 3600.0,
        }
    }
)

# tasks/doc.py
@celery_app.task(bind=True, max_retries=3, default_retry_delay=60)
def parse_document(self, doc_id: int):
    try:
        doc = db.fetch_doc(doc_id)
        chunks = chunker.chunk(doc.content, meta={"doc_id": doc_id})
        # 链式:解析完触发 embedding
        embed_chunks.delay([c["text"] for c in chunks], doc_id)
    except TransientError as e:
        raise self.retry(exc=e)

# 调用
parse_document.delay(doc_id=42)
# 或链式调用
from celery import chain
chain(parse_document.s(42), notify_user.s()).apply_async()

追问应对

  • Q:Celery 任务幂等怎么保证? 任务 ID 用业务唯一键(如 doc:{id}:parse),开始时 Redis SETNX 占位,结束时 DEL。重复执行直接跳过。task_acks_late + 业务幂等两道防线。

Q6:FastAPI 项目目录怎么组织?

标准答案

bash 复制代码
app/
  api/v1/        # 路由
  core/          # 配置、日志、安全
  models/        # SQLAlchemy ORM
  schemas/       # Pydantic 模型
  services/      # 业务逻辑
  repositories/  # 数据访问层
  tasks/         # Celery 任务
  deps.py        # 依赖注入
  main.py
tests/
alembic/         # 迁移

原则:路由薄、service 厚、repository 隔离 DB;schemas 和 models 严格分离(防把 ORM 漏到 API 层)。

核心代码骨架(分层示例):

python 复制代码
# models/device.py (ORM)
from sqlalchemy.orm import Mapped, mapped_column
class Device(Base):
    __tablename__ = "device"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]

# schemas/device.py (Pydantic)
class DeviceCreate(BaseModel):
    name: str

# repositories/device.py
class DeviceRepo:
    def __init__(self, db: AsyncSession): self.db = db
    async def create(self, name: str) -> Device:
        d = Device(name=name)
        self.db.add(d); await self.db.commit(); await self.db.refresh(d)
        return d
    async def get(self, id: int) -> Device | None:
        return await self.db.get(Device, id)

# services/device.py
class DeviceService:
    def __init__(self, repo: DeviceRepo, kafka: KafkaProducer):
        self.repo, self.kafka = repo, kafka
    async def create(self, body: DeviceCreate, owner_id: int) -> Device:
        d = await self.repo.create(body.name)
        await self.kafka.send("device.created", {"id": d.id, "owner": owner_id})
        return d

# api/v1/device.py (薄路由)
@router.post("/devices", response_model=DeviceOut, status_code=201)
async def create(body: DeviceCreate,
                 svc: DeviceService = Depends(get_device_service),
                 user = Depends(current_user)):
    return await svc.create(body, owner_id=user.id)

追问应对

  • Q:service 之间怎么调用? 跨 service 调用通过依赖注入传入。避免循环依赖(A 调 B、B 调 A)------出现循环说明业务边界划错了,提取公共部分到第三个 service 或者下沉到 repo 层。

二、Node.js / Nest.js

Q7:Express vs Nest 怎么选?

标准答案

  • Express:极简、自由、适合小型 API 或 Mock 服务;
  • Nest:约定大于配置(模块化 + 依赖注入 + 装饰器),适合中大型项目和有 Spring/Angular 背景的团队;
  • 配套:Nest 自带 Swagger、Validation、Pipe / Guard / Interceptor,企业级开箱即用。

核心代码骨架(Nest 完整 CRUD):

typescript 复制代码
// device.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Device])],
  controllers: [DeviceController],
  providers: [DeviceService],
})
export class DeviceModule {}

// device.dto.ts
export class CreateDeviceDto {
  @IsString() @MinLength(1) @MaxLength(50)
  name: string

  @IsNumber() @Min(0) @Max(10000)
  powerKw: number
}

// device.service.ts
@Injectable()
export class DeviceService {
  constructor(
    @InjectRepository(Device) private repo: Repository<Device>,
    @Inject('KAFKA') private kafka: KafkaProducer,
  ) {}

  async create(dto: CreateDeviceDto, userId: number) {
    const device = await this.repo.save({ ...dto, ownerId: userId })
    await this.kafka.send('device.created', { id: device.id })
    return device
  }
}

// device.controller.ts
@Controller('devices')
@UseGuards(JwtAuthGuard)
export class DeviceController {
  constructor(private svc: DeviceService) {}

  @Post()
  @ApiResponse({ status: 201, type: DeviceOut })
  create(@Body() dto: CreateDeviceDto, @CurrentUser() user: User) {
    return this.svc.create(dto, user.id)
  }
}

追问应对

  • Q:Nest 装饰器和 Spring 注解像在哪?不像在哪? 像:DI、模块化、声明式路由 / 校验。不像:Nest 基于 TS 装饰器(运行时元数据,依赖 reflect-metadata),编译时类型擦除后实际是字符串 token,没 Spring 那么强的类型保障;启动速度比 Spring 快很多(无类扫描)。

Q8:Node 后端常见的内存泄漏来源?

标准答案

  • 全局/模块级缓存无限增长 → 用 LRU;
  • EventEmitter 不解绑 → 用 once 或显式 off
  • 闭包持有大对象 → 注意作用域;
  • 未关闭的 stream / connection;
  • 大文件 fs.readFile 一次性读 → 用 stream;
  • 监控:node --inspect + Chrome DevTools / clinic.js

核心代码骨架

typescript 复制代码
import { LRUCache } from 'lru-cache'

// ❌ 无界 Map
const cache = new Map<string, BigData>()

// ✅ LRU 有上限 + TTL
const cache = new LRUCache<string, BigData>({
  max: 1000,
  ttl: 1000 * 60 * 30,   // 30 分钟
  updateAgeOnGet: false,
})

// ❌ 监听不解绑
class Worker {
  start(emitter: EventEmitter) {
    emitter.on('msg', this.handle)
  }
  handle(msg) { /* ... */ }
}

// ✅ 用 once 或 AbortController
class Worker {
  controller = new AbortController()
  start(emitter: EventEmitter) {
    emitter.on('msg', this.handle, { signal: this.controller.signal })
  }
  stop() { this.controller.abort() }
}

// ❌ 大文件一次性读
const buf = await fs.readFile('huge.csv')   // OOM

// ✅ 流式
import { createReadStream } from 'fs'
import { pipeline } from 'stream/promises'
import { parse } from 'csv-parse'
await pipeline(
  createReadStream('huge.csv'),
  parse({ columns: true }),
  async function* (source) {
    for await (const row of source) {
      await processRow(row)
    }
  }
)

追问应对

  • Q:怎么排查到具体的泄漏代码? Chrome DevTools 连 Node 拍两次 heap snapshot(间隔几分钟),看 Comparison 视图哪类对象增长最多,点进去看 retainers 链找到根。clinic doctor 自动诊断更友好但精度差一些。生产挂 --heap-prof 周期性导出 heap profile。

Q9:WebSocket 在 Node 里怎么做?

标准答案

  • 轻量:ws 库;
  • 全功能:socket.io(自动 fallback、room、ack);
  • 多实例广播:Redis adapter(pub/sub);
  • 心跳:定期 ping/pong,超时清理;
  • 鉴权:握手阶段在 query 或 cookie 里带 token,后端校验后挂用户上下文。

核心代码骨架

typescript 复制代码
import { WebSocketServer, WebSocket } from 'ws'
import jwt from 'jsonwebtoken'

const wss = new WebSocketServer({ port: 8080 })

// 鉴权
wss.on('connection', (ws, req) => {
  const token = new URL(req.url!, 'http://x').searchParams.get('token')
  let user
  try { user = jwt.verify(token!, process.env.JWT_SECRET!) }
  catch { return ws.close(4401, 'unauthorized') }
  ;(ws as any).user = user

  // 心跳
  ;(ws as any).isAlive = true
  ws.on('pong', () => ((ws as any).isAlive = true))

  ws.on('message', async (data) => {
    const msg = JSON.parse(data.toString())
    await handleMessage(ws, msg)
  })
})

// 心跳清理
setInterval(() => {
  wss.clients.forEach(ws => {
    if (!(ws as any).isAlive) return ws.terminate()
    ;(ws as any).isAlive = false
    ws.ping()
  })
}, 30000)

// 多实例广播 (socket.io 版)
import { Server } from 'socket.io'
import { createAdapter } from '@socket.io/redis-adapter'
import { createClient } from 'redis'

const io = new Server(httpServer)
const pubClient = createClient({ url: 'redis://localhost' })
const subClient = pubClient.duplicate()
await Promise.all([pubClient.connect(), subClient.connect()])
io.adapter(createAdapter(pubClient, subClient))

// 任意实例 emit,所有实例的客户端都收到
io.to('room-1').emit('update', payload)

追问应对

  • Q:ws 库为啥不自带 reconnect? ws 定位是底层,留给上层决策。socket.io 有自动重连 + 消息缓冲。我们的策略:浏览器端用 reconnecting-websocket 包装 + 指数退避;带消息恢复的场景用 socket.io 或自实现序列号 + ack。

三、Rust(了解级别)

Q10:你为什么了解 Rust?

标准答案

  • 个人开源项目尝试用 Tokio + Axum 写过 API;
  • 评估 Tauri 时学过 Rust 基础(所有权、生命周期、async trait);
  • 看好 Rust 在系统级 / 性能敏感后端的未来(如向量数据库、Agent runtime)。

核心代码骨架(Axum 最小 API):

rust 复制代码
use axum::{routing::get, Json, Router, extract::State};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;

#[derive(Clone)]
struct AppState { db: PgPool }

#[derive(Serialize, sqlx::FromRow)]
struct Device { id: i64, name: String }

async fn list_devices(State(state): State<Arc<AppState>>) -> Json<Vec<Device>> {
    let rows = sqlx::query_as::<_, Device>("SELECT id, name FROM device")
        .fetch_all(&state.db).await.unwrap();
    Json(rows)
}

#[tokio::main]
async fn main() {
    let db = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap();
    let state = Arc::new(AppState { db });
    let app = Router::new()
        .route("/devices", get(list_devices))
        .with_state(state);
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

追问应对

  • Q:所有权 / 借用大概怎么理解? 每个值有唯一 owner,owner 出作用域值就 drop;借用分 &T(不可变,多份共存)和 &mut T(可变,独占);二者不能同时存在 → 编译期防 data race;生命周期标注 'a 告诉编译器引用的存活范围。

四、接口设计

Q11:RESTful / GraphQL / gRPC 怎么选?

标准答案

维度 REST GraphQL gRPC
协议 HTTP/JSON HTTP/JSON HTTP2/Protobuf
类型契约 OpenAPI Schema proto
前端友好 极高 一般
性能 一般 一般
服务间通信 OK 较少 主流
适合 通用 API 复杂前端聚合 微服务内部

核心代码骨架(gRPC 服务端):

proto 复制代码
// device.proto
syntax = "proto3";
package device.v1;

service DeviceService {
  rpc Get(GetReq) returns (Device);
  rpc Stream(StreamReq) returns (stream Device);  // 服务端流
}
message GetReq { int64 id = 1; }
message Device { int64 id = 1; string name = 2; double power_kw = 3; }
python 复制代码
# Python gRPC 服务端
import grpc
from concurrent import futures
from device_pb2 import Device
from device_pb2_grpc import DeviceServiceServicer, add_DeviceServiceServicer_to_server

class DeviceService(DeviceServiceServicer):
    async def Get(self, request, context):
        d = await db.fetch_device(request.id)
        return Device(id=d.id, name=d.name, power_kw=d.power_kw)

    async def Stream(self, request, context):
        async for d in db.stream_devices():
            if context.cancelled(): break
            yield Device(id=d.id, name=d.name, power_kw=d.power_kw)

server = grpc.aio.server()
add_DeviceServiceServicer_to_server(DeviceService(), server)
server.add_insecure_port("[::]:50051")

追问应对

  • Q:BFF(Backend For Frontend)适合 GraphQL 吗? 非常适合。BFF 的核心痛点是"前端聚合多个微服务、字段精挑",正是 GraphQL 强项。Apollo / Yoga 都是好选择。但内部微服务通信仍用 gRPC 性能好。

Q12:接口版本怎么管?

标准答案

  • URI 版本:/api/v1/xxx(最常见,直观);
  • Header 版本:Accept: application/vnd.app.v1+json
  • 兼容策略:新字段加 optional 不破坏旧客户端;删字段先标 deprecated → 一段时间后移除;
  • 文档同步:OpenAPI 自动生成 changelog。

核心代码骨架(FastAPI 多版本路由):

python 复制代码
from fastapi import FastAPI, APIRouter

app = FastAPI()

v1 = APIRouter(prefix="/api/v1", tags=["v1"])
v2 = APIRouter(prefix="/api/v2", tags=["v2"])

@v1.get("/devices/{id}")
async def get_device_v1(id: int):
    d = await repo.get(id)
    return {"id": d.id, "name": d.name}

@v2.get("/devices/{id}")
async def get_device_v2(id: int):
    d = await repo.get(id)
    # v2 增加更多字段 + 改了结构
    return {
        "id": d.id,
        "name": d.name,
        "metadata": {"created_at": d.created_at, "tags": d.tags}
    }

app.include_router(v1)
app.include_router(v2)

追问应对

  • Q:v1 什么时候下线? 流程:① v2 上线时 v1 标 Deprecated(response header Deprecation: true);② 监控 v1 流量 + 通知接入方迁移;③ 流量降到 < 1% 维持 30 天;④ 删除。一般周期 3-6 个月,看接入方协作意愿。

Q13:幂等性怎么保证?

标准答案

  • GET / PUT / DELETE 天然幂等,POST 需要业务保证;
  • 方法
    1. 客户端生成 Idempotency-Key(UUID),服务端用 Redis 记录"key → result",重试直接返回;
    2. 业务唯一索引:如订单号唯一,重复插入触发约束;
    3. 状态机:操作前检查当前状态,已完成的不再执行。

核心代码骨架

python 复制代码
from fastapi import Header, HTTPException
import hashlib

async def idempotent(
    request: Request,
    idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
):
    if not idempotency_key:
        return None  # 不要求幂等
    body = await request.body()
    cache_key = f"idem:{idempotency_key}:{hashlib.md5(body).hexdigest()}"
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)
    return cache_key   # 业务处理后用这个 key 写入

@app.post("/orders")
async def create_order(body: OrderCreate, request: Request,
                       cache_key = Depends(idempotent)):
    if isinstance(cache_key, dict):
        return cache_key   # 已处理过直接返回
    result = await order_service.create(body)
    if cache_key:
        await redis.setex(cache_key, 86400, json.dumps(result))
    return result

追问应对

  • Q:相同 Idempotency-Key 但 body 不同怎么办? 视为攻击或客户端 bug。我们把 body hash 进 cache key,body 变了 cache 不命中走新逻辑,但同时记录告警。严格做法是拒绝 + 返回 422。

五、安全

Q14:API 安全你关注哪些?

标准答案

  • 鉴权:JWT / Session,refresh token 机制;
  • 授权:RBAC / ABAC,按资源 + 操作粒度;
  • 输入校验:Pydantic / class-validator,永不信前端;
  • SQL 注入:用参数化查询 / ORM;
  • XSS:响应数据转义、CSP;
  • CSRF:token + SameSite cookie;
  • 限流:用户级 / IP 级 / 路由级;
  • 审计日志:写操作落日志,可追溯;
  • HTTPS:强制 TLS 1.2+。

核心代码骨架(RBAC + 限流):

python 复制代码
from functools import wraps
from fastapi import HTTPException, Request

def require_perms(*perms: str):
    def deco(fn):
        @wraps(fn)
        async def wrapper(*args, current_user, **kwargs):
            user_perms = set(current_user.permissions)
            if not all(p in user_perms for p in perms):
                raise HTTPException(403, f"missing: {perms}")
            return await fn(*args, current_user=current_user, **kwargs)
        return wrapper
    return deco

@app.post("/devices/{id}/control")
@require_perms("device:control")
async def control(id: int, body: ControlBody, current_user = Depends(get_user)):
    return await device_service.control(id, body, op=current_user.id)

# 限流:用 slowapi
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=lambda req: get_user_id_or_ip(req))
app.state.limiter = limiter

@app.post("/chat")
@limiter.limit("30/minute")
async def chat(request: Request, body: ChatBody):
    return await chat_service.handle(body)

追问应对

  • Q:JWT 怎么主动失效? JWT 本质无状态,主动失效要引入黑名单。Redis 维护"已撤销 jti"列表,每次校验查一次。或者用 access + refresh 模式,access 短期(15min)自然失效,refresh 撤销时 Redis 拉黑。

Q15:JWT 你怎么用?刷新怎么做?

标准答案

  • access token 短期(15min),refresh token 长期(7d);
  • access 放内存 / Authorization header,refresh 放 HttpOnly cookie;
  • access 过期 → 前端用 refresh 换新 access;
  • refresh 一次性使用(rotation),换出新对,旧的拉黑;
  • 登出:把 refresh 拉黑(Redis 黑名单)+ 清前端存储。

核心代码骨架

python 复制代码
import jwt
from datetime import datetime, timedelta, timezone
from uuid import uuid4

SECRET = "..."
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=7)

def issue_tokens(user_id: int):
    now = datetime.now(timezone.utc)
    jti = str(uuid4())
    access = jwt.encode({
        "sub": str(user_id), "type": "access", "exp": now + ACCESS_TTL
    }, SECRET, algorithm="HS256")
    refresh = jwt.encode({
        "sub": str(user_id), "type": "refresh", "jti": jti, "exp": now + REFRESH_TTL
    }, SECRET, algorithm="HS256")
    # 记录有效 refresh jti
    redis.setex(f"refresh:{jti}", REFRESH_TTL, "1")
    return access, refresh

@app.post("/auth/refresh")
async def refresh_token(refresh: str = Cookie(...)):
    try:
        payload = jwt.decode(refresh, SECRET, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "refresh expired")
    if payload["type"] != "refresh":
        raise HTTPException(401, "bad type")
    # 检查是否被撤销 / 是否已用过(rotation)
    if not await redis.get(f"refresh:{payload['jti']}"):
        # refresh 被复用 → 视为攻击,撤销整个用户所有 refresh
        await redis.delete(f"refresh:{payload['jti']}")
        await revoke_all_user_refresh(payload["sub"])
        raise HTTPException(401, "refresh reused, all sessions revoked")
    await redis.delete(f"refresh:{payload['jti']}")  # 旧的作废
    new_access, new_refresh = issue_tokens(int(payload["sub"]))
    response = JSONResponse({"access": new_access})
    response.set_cookie("refresh", new_refresh, httponly=True, secure=True, samesite="strict")
    return response

追问应对

  • Q:为什么 refresh token 要 rotation? 防止 refresh 泄露后长期被滥用。每次刷新都换新的,旧的立即失效。如果同一个旧 refresh 被用第二次,说明可能被盗,立刻撤销该用户所有 session 强制重登。

六、可能被追问

  • Q:你说 DeepPark 信号量限并发,怎么定的 20?

    上线前压测出 LLM 厂商单 key 的稳定并发上限大概 25 不限流,留 20% 余量取 20。后期切多 key 后改用 token bucket,按 key 分流。

  • Q:FastAPI WebSocket 怎么做横向扩展?

    单实例只能撑几千连接。横向扩展用 Redis pub/sub 做实例间消息广播,sticky session(按用户 hash)让同一用户固定到同一实例;如果实例数多,引入 Centrifugo / Soketi 这类专门的实时消息层。

  • Q:你为什么从 Express 转向 FastAPI?

    主要是 AI 项目 Python 生态更全(LangChain / 模型库 / 向量库都首选 Python)。Express / Nest 我做通用业务 API 时还在用。语言选择跟着业务走。

相关推荐
ricardo19732 小时前
代码分割 + 路由懒加载 + 字体子集化:前端瘦身三板斧
前端·面试
I Promise343 小时前
多传感器融合&模型后处理C++工程师面试参考回答
开发语言·c++·面试
涤生大数据4 小时前
大数据面试高频题:row_number() 数据倾斜到底怎么解决?
java·大数据·面试
摇滚侠4 小时前
HashMap 源码解析 底层原理 面试如何回答
java·面试·职场和发展
刀法如飞5 小时前
《理解道德经》简单版-第 1 章:道可道,非常道
前端·后端·面试
Moment6 小时前
开发Agent为什么必须先做意图识别?
前端·后端·面试
plainGeekDev6 小时前
Kotlin协程面试题:suspend原理都说不清,协程你真会用?
android·面试·kotlin
神奇小汤圆7 小时前
一个程序员眼中的 AI 核心概念,讲透 LLM 、Agent 、MCP 、Skill 、RAG...
面试
张元清7 小时前
React 指针 Hook:Hover、长按、双击、刮擦和点击外部,告别那些经典 bug
前端·javascript·面试