site logo

Marico's space

演示快、上线慢:Postgres、Redis 与并行管道修复延迟问题

算法解析 2026-10-08 20:56:24 5

演示环境跑得飞快,一个用户、笔记本还热着、演示者早就知道该点哪个按钮。

然后正式上线第一周,流量一进来,同样的产品开始卡顿、超时、或者返回一个半天前的数据。用户管这叫"扩展性不行"。其实大多数时候不是扩展问题,是路径问题:代码本来就是给一个人设计的。

我负责几个生产平台的开发,包括 Rawk.ai,一个语音代理构建工具。我们把端到端响应时间从大约 900ms 砍到了 320ms。没有用什么更聪明的提示词,也没加服务器,就是修了路径。这篇说清楚时间到底花在哪了,以及哪些改法真能扛住流量。

Load is a tail, not an average(负载看的是尾巴,不是平均值)

产品按平均值看挺健康,但总有人撞上慢的那条路径。语音通话的用户直接挂断了。结算页的用户点重试,结果重复扣款。CRM 刷新了半天,员工按昨天数据做决策。

真正该看的数字是慢请求尾巴(p95、p99),以及最终返回时答案还对不对。三件事盯紧:

  • 速度:首次有价值的响应时间,不是转圈圈消失的时间。
  • 正确性:返回快但数据是旧的,这是个 bug,只是配了更好看的图表。
  • 成本:每次按键都调一次模型或者供应商 API,还没慢到离谱就已经贵得离谱了。

说不出你在测哪条请求,说明还没准备好优化。"这 app 有点慢"是投诉。"实时通话首帧音频时间"才是活儿。

What the Rawk.ai latency pass actually changed(Rawk.ai 那次延迟优化的实际改动)

通话方不会原谅每句话后 900ms 的空白。会抢话,会互相打断,通话直接崩掉。

掉到 320ms 靠的是管道改造:

  • 语音转文本改流式,不再等完整 transcript
  • LLM 输出流式吐字,不再攒完整段落
  • 第一个分句就开始放音频
  • 在代理需要之前预取工具结果
  • 服务同区域部署,不让请求跨区跳来跳去

语音场景的详细版本可以看构建真正能预约挂号的 AI 语音代理那篇。但同样的失败模式在从不打电话的产品里也一样出现。

Where the time actually goes(时间到底花在哪了)

大多数慢产品,就是一串单独看都合理的步骤,从来没被允许并行执行。

1. Sequential I/O(串行 I/O)

页面等着拿用户信息,然后等权限,然后等列表,然后等计数。每个调用单独看都没问题。加起来就是那条死在演示环境的请求链。

如果调用之间没有依赖,就并行跑:

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,那这俩保持串行,其他的并行跑。

2. A cache that's missing, or lying(缓存要么没有,要么说谎)

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。

3. Queries that scan because nobody indexed the access pattern(查询在扫表,因为没人按访问模式建索引)

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 让索引构建期间不会锁住在线表的写入。

4. Retrieval on every request(每个请求都去查一遍)

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 是关键。跨租户共享答案缓存,是等着在正确的问题出现时数据泄露。

5. Cross-region hops(跨区跳转)

每个供应商默认选了不同的云,每次请求都跨区。这每次跳转几十毫秒,每个来回都算在里面,多少提示词和查询调优都补不回来。把每次请求都要通信的服务放一起。偶尔跑的运维任务放哪都行。

Audit the live path before proposing a rebuild(动手重构之前先审计线上路径)

产品已经有用户了,冲动往往是重写。这通常是个错误。先测量,然后改移动尾巴所需的最小层级:

  1. 命名用户能感知的那一条请求。在接近生产的数据上计时,包括慢请求尾巴。
  2. 列出那条请求上每个下游调用,按顺序,标清楚每个返回什么。
  3. 标记哪些结果跨用户相同,哪些绝对不能共享。
  4. 只缓存相同的、安全的读取,写入时显式删除。
  5. 把每次请求都要通信的服务放一起。
  6. 重测同一条请求。如果尾巴没动,说明缓存不是瓶颈。

加一层缓存或者修一条管道往往就是全部工作。重构是那轮优化之后还是够不着目标才提的方案。

然后写下来:架构概览和团队后面需要的 API 行为说明。等调优的人不在场的时候用得上。没文档的速度,是条你重复不了的演示。