
演示环境跑得飞快,一个用户、笔记本还热着、演示者早就知道该点哪个按钮。
然后正式上线第一周,流量一进来,同样的产品开始卡顿、超时、或者返回一个半天前的数据。用户管这叫"扩展性不行"。其实大多数时候不是扩展问题,是路径问题:代码本来就是给一个人设计的。
我负责几个生产平台的开发,包括 Rawk.ai,一个语音代理构建工具。我们把端到端响应时间从大约 900ms 砍到了 320ms。没有用什么更聪明的提示词,也没加服务器,就是修了路径。这篇说清楚时间到底花在哪了,以及哪些改法真能扛住流量。
产品按平均值看挺健康,但总有人撞上慢的那条路径。语音通话的用户直接挂断了。结算页的用户点重试,结果重复扣款。CRM 刷新了半天,员工按昨天数据做决策。
真正该看的数字是慢请求尾巴(p95、p99),以及最终返回时答案还对不对。三件事盯紧:
说不出你在测哪条请求,说明还没准备好优化。"这 app 有点慢"是投诉。"实时通话首帧音频时间"才是活儿。
通话方不会原谅每句话后 900ms 的空白。会抢话,会互相打断,通话直接崩掉。
掉到 320ms 靠的是管道改造:
语音场景的详细版本可以看构建真正能预约挂号的 AI 语音代理那篇。但同样的失败模式在从不打电话的产品里也一样出现。
大多数慢产品,就是一串单独看都合理的步骤,从来没被允许并行执行。
页面等着拿用户信息,然后等权限,然后等列表,然后等计数。每个调用单独看都没问题。加起来就是那条死在演示环境的请求链。
如果调用之间没有依赖,就并行跑:
import asyncio # Before: four round trips, one after another
user = await get_user(user_id)
perms = await get_permissions(user_id)
items = await get_items(workspace_id)
counts = await get_counts(workspace_id) # After: independent calls run at the same time
user, perms, items, counts = await asyncio.gather( get_user(user_id), get_permissions(user_id), get_items(workspace_id), get_counts(workspace_id),
)
现在请求耗时大约等于最慢那个调用的时间,而不是四个加起来。动手之前老老实实画依赖图。如果 get_items 需要先拿到 perms,那这俩保持串行,其他的并行跑。
PostgreSQL 是系统 of record。Redis 挡在那些热点请求、跨请求相同、允许重复返回的读取前面。
设计工作的核心是边界:哪些 key 存在、存活多久、哪些写操作会删掉它们。没有失效规则的缓存,就是一条已付款的发票还在 UI 里显示"新线索"的原因——员工信那个界面。
import json
from redis.asyncio import Redis redis = Redis()
HOURS_TTL = 300 # seconds; a safety net, not the invalidation strategy
async def get_location_hours(location_id: str) -> dict: key = f"location:{location_id}:hours" cached = await redis.get(key) if cached: return json.loads(cached) hours = await db.fetch_location_hours(location_id) await redis.set(key, json.dumps(hours), ex=HOURS_TTL) return hours async def update_location_hours(location_id: str, hours: dict) -> None: await db.update_location_hours(location_id) # The write owns the invalidation. Delete, don't wait for the TTL.
await redis.delete(f"location:{location_id}:hours")
TTL 是为了万一失效规则漏了,限制损害。真正的规则是:每条这个数据的写路径都要删掉 key。
Postgres 会回答的。就是回答得慢,而且随着表变大,每周都更慢。查一下慢请求实际跑了什么:
EXPLAIN ANALYZE
SELECT id, name, stage, created_at
FROM leads
WHERE workspace_id = 'ws_123' AND stage = 'new'
ORDER BY created_at DESC
LIMIT 50;
如果执行计划显示大表上的 Seq Scan,说明索引和读数据的写法对不上。建一个对得上的,匹配过滤列和排序列:
CREATE INDEX CONCURRENTLY idx_leads_workspace_stage_created
ON leads (workspace_id, stage, created_at DESC);
再跑一遍 EXPLAIN ANALYZE,确认计划切到了 index scan。CONCURRENTLY 让索引构建期间不会锁住在线表的写入。
Embedding 向量、向量搜索、模型调用,就为了回答一个前面一百个用户已经问过的问题。向量数据库应该在路径里只在答案真的依赖搜索的时候出现。如果事实本身就是个字段,直接读字段。
重复问题常见的话就缓存答案,但 key 的作用域要隔离好,保证一个租户看不到另一个的结果:
import hashlib def answer_cache_key(workspace_id: str, question: str) -> str: normalized = " ".join(question.lower().split()) digest = hashlib.sha256(normalized.encode()).hexdigest() return f"answer:{workspace_id}:{digest}"
key 里的 workspace_id 是关键。跨租户共享答案缓存,是等着在正确的问题出现时数据泄露。
每个供应商默认选了不同的云,每次请求都跨区。这每次跳转几十毫秒,每个来回都算在里面,多少提示词和查询调优都补不回来。把每次请求都要通信的服务放一起。偶尔跑的运维任务放哪都行。
产品已经有用户了,冲动往往是重写。这通常是个错误。先测量,然后改移动尾巴所需的最小层级:
加一层缓存或者修一条管道往往就是全部工作。重构是那轮优化之后还是够不着目标才提的方案。
然后写下来:架构概览和团队后面需要的 API 行为说明。等调优的人不在场的时候用得上。没文档的速度,是条你重复不了的演示。