后端架构审视与重构方案 · 2026-10-09

审视对象:/srv/kailash prod(rev=a74efa11)
方法:读代码 + 生产库 pg_stat_* 实测 + 容器运行态,不基于假设

🔴 第 0 章 · 我要先推翻自己的第一反应

你让我"设计更好的扩展性和稳定性",作为架构师我第一反应是:

分库分表 → Redis 缓存 → 消息队列 → K8s 横向扩缩 → 服务拆分。

然后我去查了真实数据,于是把这些全部否掉了:

指标实测值意味着什么
数据库总容量27 MB分库分表连磁盘都撑不满
users550 行单表索引都快溢出内存
orders10 行🔴 根本没有"高并发"这回事
24h 请求数126峰值 ≈ 0.005 QPS
24h 状态码125×200 + 1×404,零 5xx🔴 没有稳定性问题要解决
CPUapp 0.07% / pg 0.46%资源闲置 99%
表膨胀最高 packages 38%死元组绝对量只有 53

⚠️ **在这个量级上引入 Redis/MQ/K8s/读写分离,只会让系统更难维护、更容易出故障,

而可用的扩展性上限依然是 0.005 QPS —— 因为瓶颈根本不在架构。**

✅ 所以真正的问题不是"扩展性不够"

实测出来的三个真问题,按严重度排序:

#问题证据后果
1单 worker 单进程CMD uvicorn 无 --workers🔴 一个慢请求/一个死循环就全线卡死
2连接池上限 10db.py: min_size=2, max_size=10⚠️ 横向扩容到第 11 个实例就会打爆 PG
3业务逻辑糊在路由层routers 29062 行(84%) vs services 4198 行🔴 不可测、不可复用、改一处崩一片

这三条都不是"性能不够",是"结构脆弱"。


第 1 章 · 架构现状(实测,非推测)

1.1 技术栈

FastAPI 0.115.6 + uvicorn 0.32.1 + asyncpg 0.30
PyJWT 2.10 + pydantic 2.10 + cryptography 44 + COS SDK + bcrypt 4.2
单容器 + 单 PG,无缓存、无 MQ、无反向代理缓存层

1.2 部署形态

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
#                        ↑ 🔴 无 --workers ⇒ 1 个进程、1 个事件循环

⚠️ 单事件循环的后果(这是 FastAPI/asyncio 的固有特性):

1.3 代码结构

层行数占比判断
routers/29,06284%🔴 逻辑与 HTTP 强耦合
services/4,19812%只有薄薄一层
其余(wxpay/security/main…)1,5074%

🔴 这是典型的"贫血路由层":路由函数里直接写 SQL、直接算钱、直接改状态。

后果(今晚已实证):

1.4 数据库

189 张表 / 554 个索引 / 27 MB
隔离级别 read committed(🔴 无 SERIALIZABLE,跨行一致靠应用层手动加锁)
autovacuum 在跑,但多表死元组占比 30-100%(绝对量小,暂无害)
pg_stat_statements 未安装 ⇒ 🔴 **没有任何慢查询可观测性**

第 2 章 · 重构方案

2.1 目标重定义(关键)

🔴 不要为"扩展性"做扩展 —— 要为"结构清晰 + 单点故障隔离"做。

目标具体含义怎么衡量
不因单点崩全站一个慢请求不能拖垮整个服务阻塞源隔离 + 超时兜底
逻辑可测钱和状态计算能脱离 HTTP 单测逻辑下沉到 services/
横向扩容不被连接数卡死池大小随 worker 数自动适配连接预算公式化
可观测能查「哪条 SQL 慢、哪个接口慢」装 pg_stat_statements + 请求耗时日志

⚠️ 明确不做的(做了都是负债):分库分表、读写分离、Redis、MQ、K8s、微服务拆分。

2.2 三阶段路线


🟢 第一阶段:稳定性(当天可完成,零业务风险)

改动 1 —— 多 worker(最高性价比)

# 现状:1 个进程
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

# 改为:4 个进程(按 CPU 核数 ×2 +2 起步)
CMD ["uvicorn", "app.main:app", \
     "--host", "0.0.0.0", "--port", "8000", \
     "--workers", "4", \
     "--timeout-keep-alive", "65", \
     "--limit-concurrency", "200"]

✅ 立刻解决:一个请求卡死不再拖垮全站(其余 3 个 worker 照常服务)

⚠️ 前置检查(不查就改会引入新 bug):

⇒ 必须同步调小 max_size(见改动 2),否则 4×10=40 连接

改动 2 —— 连接池公式化

# 现状:硬编码 max_size=10(进程数一变就不对)
_pool = await asyncpg.create_pool(_dsn(), min_size=2, max_size=10)

# 改为:从环境变量读,且有上限保护
POOL_MIN = int(os.getenv("PG_POOL_MIN", "2"))
POOL_MAX = int(os.getenv("PG_POOL_MAX", "10"))
_pool = await asyncpg.create_pool(
    _dsn(), min_size=POOL_MIN, max_size=POOL_MAX,
    command_timeout=15,      # 🔴 单条 SQL 超时,防止慢查询占着连接不放
    max_inactive_connection_lifetime=300,
)

📐 连接预算公式(写进部署文档,防止将来扩容踩坑):

总连接数 ≤ PG max_connections × 0.8
总连接数 = 实例数 × worker 数 × PG_POOL_MAX
⇒ 当前:1 × 4 × 10 = 40 ≤ 100 × 0.8 = 80  ✅ 有余量
⇒ 上限:最多 2 个容器实例(2 × 4 × 10 = 80)

改动 3 —— 装慢查询观测(只读,改配置不改代码)

-- 开启后能直接回答"哪条 SQL 慢"
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 配 shared_preload_libraries 后重启 PG

⚠️ 需改 postgresql.conf + 重启 PG,属于运维操作,需你拍板。

收益:把"猜哪里慢"变成"看得见"。

改动 4 —— 请求耗时日志

# middleware.py 加一个最简耗时记录
@app.middleware("http")
async def timing(request, call_next):
    t = time.perf_counter()
    r = await call_next(request)
    ms = (time.perf_counter() - t) * 1000
    if ms > 500:                      # 🔴 只记慢的,别把日志淹了
        logger.info("SLOW %s %.0fms", request.url.path, ms)
    return r

🟡 第二阶段:结构治理(1-2 周,需求驱动)

把逻辑从路由层下沉,按优先级:

#抽出什么放哪为什么(今晚实证过的)
1库存/房量扣减services/inventory.py超卖问题出在路由里手写 SQL,没有"库存服务"可复用
2退款分摊计算services/refund.py🔴 同一个逻辑在 L1803/L1975 写了两遍且算法不同
3订单状态迁移services/order_state.py13 处改 status,规则散落(hotel.py 5 处各自判前置状态)
4金额计算services/pricing.py涉钱计算集中,才谈得上"可单测"

方法:每次抽一个,先写单测再搬(不要"先搬后补测")。

⚠️ 不要一次全抽。34k 行不可能一周重写。

接口契约同步:

routers(薄) → services(业务+事务边界) → repositories(纯 SQL)

🔵 第三阶段:按真实压力触发(现在不做)

🔴 写在这里是为了"将来不重复讨论",触发条件写在右边:

触发条件才考虑
日请求 > 5 万 且 单实例持续 >70% CPU才考虑加实例(同时才需调小 pool)
出现真实的高并发写冲突(库存/名额)才考虑 PG 层乐观锁替代悲观锁
有异步需求(发通知、生成报表)才引入任务队列(且优先用 PG 表轮询,别一上来就上 MQ)

第 3 章 · 风险与回滚

阶段风险回滚
一·多 worker🔴 进程内状态被复制(内存缓存/定时器各跑一份)docker compose up -d 指定旧镜像即可,分钟级
一·连接池连接数超限 → PG 拒绝改环境变量 PG_POOL_MAX,重启即生效
一·pg_stat需重启 PG改回 shared_preload_libraries 重启
二·下沉逻辑🔴 改错就出业务事故(钱/状态)必须逐个抽+ 逐个测,一次只动一个函数

🔴 第一阶段必须先做的两件事

  1. 扫进程内状态(多进程会重复执行)
# 全局变量、定时器、后台任务、内存缓存
grep -rnE "startup|lifespan|BackgroundTasks|asyncio.create_task|scheduler|cache\s*=|_[A-Z]+\s*=\s*\[\]" backend/app/
  1. 确认没有进程内幂等依赖

🔴 idempotency.py 如果用进程内内存记录幂等 ⇒ 多 worker 后不同请求可能落到不同进程 ⇒ 幂等失效。


第 4 章 · 给你三个决策点

#决策我的建议
1要不要今天就做第一阶段 3 个改动(多 worker + 连接池 + 慢日志)?✅ 要。改动小、风险低、收益直接
2pg_stat_statements 需要重启 PG,准不准?✅ 准。这是把"猜"变成"看"的唯一低成本手段
3第二阶段(逻辑下沉)要不要现在启动?⚠️ 建议先只抽「退款分摊」——因为它已经重复实现两次了,这是最确定的一笔

附 · 我今晚已实证的三个 bug(都属于结构性问题)

Bug根因对应本方案
酒店超卖路由里手写 SQL,无库存服务可复用二阶段 #1
退款分摊算法重复同逻辑写两遍且不一致二阶段 #2
指纹丢失部署脚本与指纹注入耦合部署流程,非架构

这三件事指向同一个根因:业务逻辑没有归属层。

所以第一阶段(稳定性)不做这个,第二阶段必须做。