审视对象:/srv/kailashprod(rev=a74efa11)
方法:读代码 + 生产库 pg_stat_* 实测 + 容器运行态,不基于假设
你让我"设计更好的扩展性和稳定性",作为架构师我第一反应是:
分库分表 → Redis 缓存 → 消息队列 → K8s 横向扩缩 → 服务拆分。
然后我去查了真实数据,于是把这些全部否掉了:
| 指标 | 实测值 | 意味着什么 |
|---|---|---|
| 数据库总容量 | 27 MB | 分库分表连磁盘都撑不满 |
users | 550 行 | 单表索引都快溢出内存 |
orders | 10 行 | 🔴 根本没有"高并发"这回事 |
| 24h 请求数 | 126 | 峰值 ≈ 0.005 QPS |
| 24h 状态码 | 125×200 + 1×404,零 5xx | 🔴 没有稳定性问题要解决 |
| CPU | app 0.07% / pg 0.46% | 资源闲置 99% |
| 表膨胀 | 最高 packages 38% | 死元组绝对量只有 53 |
⚠️ **在这个量级上引入 Redis/MQ/K8s/读写分离,只会让系统更难维护、更容易出故障,
而可用的扩展性上限依然是 0.005 QPS —— 因为瓶颈根本不在架构。**
实测出来的三个真问题,按严重度排序:
| # | 问题 | 证据 | 后果 |
|---|---|---|---|
| 1 | 单 worker 单进程 | CMD uvicorn 无 --workers | 🔴 一个慢请求/一个死循环就全线卡死 |
| 2 | 连接池上限 10 | db.py: min_size=2, max_size=10 | ⚠️ 横向扩容到第 11 个实例就会打爆 PG |
| 3 | 业务逻辑糊在路由层 | routers 29062 行(84%) vs services 4198 行 | 🔴 不可测、不可复用、改一处崩一片 |
这三条都不是"性能不够",是"结构脆弱"。
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、无反向代理缓存层
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# ↑ 🔴 无 --workers ⇒ 1 个进程、1 个事件循环
⚠️ 单事件循环的后果(这是 FastAPI/asyncio 的固有特性):
time.sleep、同步 IO、requests 调用 → 整个事件循环阻塞| 层 | 行数 | 占比 | 判断 |
|---|---|---|---|
routers/ | 29,062 | 84% | 🔴 逻辑与 HTTP 强耦合 |
services/ | 4,198 | 12% | 只有薄薄一层 |
| 其余(wxpay/security/main…) | 1,507 | 4% |
🔴 这是典型的"贫血路由层":路由函数里直接写 SQL、直接算钱、直接改状态。
后果(今晚已实证):
miniapp_orders.py 里重复实现两次(L1803 与 L1975 算法不同)FOR UPDATE 手补,因为没有"库存服务"这个抽象189 张表 / 554 个索引 / 27 MB
隔离级别 read committed(🔴 无 SERIALIZABLE,跨行一致靠应用层手动加锁)
autovacuum 在跑,但多表死元组占比 30-100%(绝对量小,暂无害)
pg_stat_statements 未安装 ⇒ 🔴 **没有任何慢查询可观测性**
🔴 不要为"扩展性"做扩展 —— 要为"结构清晰 + 单点故障隔离"做。
| 目标 | 具体含义 | 怎么衡量 |
|---|---|---|
| 不因单点崩全站 | 一个慢请求不能拖垮整个服务 | 阻塞源隔离 + 超时兜底 |
| 逻辑可测 | 钱和状态计算能脱离 HTTP 单测 | 逻辑下沉到 services/ |
| 横向扩容不被连接数卡死 | 池大小随 worker 数自动适配 | 连接预算公式化 |
| 可观测 | 能查「哪条 SQL 慢、哪个接口慢」 | 装 pg_stat_statements + 请求耗时日志 |
⚠️ 明确不做的(做了都是负债):分库分表、读写分离、Redis、MQ、K8s、微服务拆分。
改动 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):
_pool 是全局变量 ⇒ 多进程下每个进程各建一个池 ⇒ 必须同步调小 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 | 库存/房量扣减 | services/inventory.py | 超卖问题出在路由里手写 SQL,没有"库存服务"可复用 |
| 2 | 退款分摊计算 | services/refund.py | 🔴 同一个逻辑在 L1803/L1975 写了两遍且算法不同 |
| 3 | 订单状态迁移 | services/order_state.py | 13 处改 status,规则散落(hotel.py 5 处各自判前置状态) |
| 4 | 金额计算 | services/pricing.py | 涉钱计算集中,才谈得上"可单测" |
方法:每次抽一个,先写单测再搬(不要"先搬后补测")。
⚠️ 不要一次全抽。34k 行不可能一周重写。
接口契约同步:
routers(薄) → services(业务+事务边界) → repositories(纯 SQL)
repositories/ 只写 SQL,不含业务判断services/ 只写规则,不含 SQL 字符串🔴 写在这里是为了"将来不重复讨论",触发条件写在右边:
| 触发条件 | 才考虑 |
|---|---|
| 日请求 > 5 万 且 单实例持续 >70% CPU | 才考虑加实例(同时才需调小 pool) |
| 出现真实的高并发写冲突(库存/名额) | 才考虑 PG 层乐观锁替代悲观锁 |
| 有异步需求(发通知、生成报表) | 才引入任务队列(且优先用 PG 表轮询,别一上来就上 MQ) |
| 阶段 | 风险 | 回滚 |
|---|---|---|
| 一·多 worker | 🔴 进程内状态被复制(内存缓存/定时器各跑一份) | docker compose up -d 指定旧镜像即可,分钟级 |
| 一·连接池 | 连接数超限 → PG 拒绝 | 改环境变量 PG_POOL_MAX,重启即生效 |
| 一·pg_stat | 需重启 PG | 改回 shared_preload_libraries 重启 |
| 二·下沉逻辑 | 🔴 改错就出业务事故(钱/状态) | 必须逐个抽+ 逐个测,一次只动一个函数 |
# 全局变量、定时器、后台任务、内存缓存
grep -rnE "startup|lifespan|BackgroundTasks|asyncio.create_task|scheduler|cache\s*=|_[A-Z]+\s*=\s*\[\]" backend/app/
🔴 idempotency.py 如果用进程内内存记录幂等 ⇒ 多 worker 后不同请求可能落到不同进程 ⇒ 幂等失效。
| # | 决策 | 我的建议 |
|---|---|---|
| 1 | 要不要今天就做第一阶段 3 个改动(多 worker + 连接池 + 慢日志)? | ✅ 要。改动小、风险低、收益直接 |
| 2 | pg_stat_statements 需要重启 PG,准不准? | ✅ 准。这是把"猜"变成"看"的唯一低成本手段 |
| 3 | 第二阶段(逻辑下沉)要不要现在启动? | ⚠️ 建议先只抽「退款分摊」——因为它已经重复实现两次了,这是最确定的一笔 |
| Bug | 根因 | 对应本方案 |
|---|---|---|
| 酒店超卖 | 路由里手写 SQL,无库存服务可复用 | 二阶段 #1 |
| 退款分摊算法重复 | 同逻辑写两遍且不一致 | 二阶段 #2 |
| 指纹丢失 | 部署脚本与指纹注入耦合 | 部署流程,非架构 |
这三件事指向同一个根因:业务逻辑没有归属层。
所以第一阶段(稳定性)不做这个,第二阶段必须做。