Flask 应用怎么做缓存和性能优化?瓶颈一般在哪?
简化版
Flask 应用的性能瓶颈几乎从不在框架本身——路由匹配、模板渲染这些都是编译后的 Python 函数调用,微秒级。真正的瓶颈按出现频率排是:① 数据库 N+1 查询(循环里访问关联对象,一个列表页发几百条 SQL);② 缺索引的慢查询;③ 同步调用外部服务(发邮件、调第三方 API 阻塞住整个请求);④ 序列化/渲染大量数据(一次返回上万条);⑤ WSGI worker 数量不足。所以优化的第一步永远是先测量——用 flask-debugtoolbar 或记录每个请求的 SQL 条数与耗时,别凭感觉加缓存。缓存要分层看:请求内用 g 缓存(同一请求里重复的查询只做一次)、进程内用 lru_cache(适合配置、字典表这类不变的数据)、跨进程用 Redis(Flask-Caching 的 @cache.cached 缓存视图、@cache.memoize 按参数缓存函数)、HTTP 层用 ETag/Cache-Control 让浏览器和 CDN 帮你挡请求。用 Flask-Caching 有两个必须知道的坑:@cache.cached() 的默认 key 只用 request.path,不含查询字符串——分页和筛选会互相串数据,必须加 query_string=True;多 worker 下必须用 Redis 而不是默认的 SimpleCache(每个进程一份内存缓存,命中率低且数据不一致)。缓存的难点从来不是写入,而是失效——推荐「写操作后主动删 key」加「较短的 TTL 兜底」,并且要防缓存击穿(热点 key 过期瞬间大量请求打到数据库,用单飞锁)和缓存穿透(查不存在的数据,缓存空值或布隆过滤器)。核心记忆:先测量再优化;瓶颈通常是 N+1;@cached 记得 query_string=True;多 worker 用 Redis;难的是失效不是写入。
详细版
优化手段按性价比排序:
| 手段 | 典型收益 | 成本 |
|---|---|---|
| 消灭 N+1(预加载) | 10~100 倍 | 低(改一行查询) |
| 加索引 | 10~1000 倍 | 低 |
| 分页 / 限制返回量 | 数倍 | 低 |
| 外部调用异步化 | 数倍 | 中(引入 Celery) |
| Redis 缓存 | 数倍~数十倍 | 中(要处理失效) |
| HTTP 缓存(ETag/CDN) | 挡掉大量请求 | 中 |
| 加 worker / 换机器 | 线性 | 高(花钱) |
# ① ★先测量:请求内的 SQL 计数★
from flask_sqlalchemy.record_queries import get_recorded_queries
app.config["SQLALCHEMY_RECORD_QUERIES"] = True
@app.after_request
def log_slow(resp):
qs = get_recorded_queries()
total = sum(q.duration for q in qs)
if len(qs) > 20 or total > 0.5: # ★★阈值告警★★
app.logger.warning("SLOW %s n_query=%d total=%.3fs",
request.path, len(qs), total)
for q in sorted(qs, key=lambda x: -x.duration)[:3]:
app.logger.warning(" %.3fs %s", q.duration, q.statement[:200])
return resp
# ② ★★消灭 N+1(最高性价比)★★
# ✗ 100 篇文章 = 1 + 100 + 100 = 201 条 SQL
articles = Article.query.all()
for a in articles:
print(a.author.name, len(a.comments))
# ✓ 3 条 SQL
from sqlalchemy.orm import joinedload, selectinload
articles = Article.query.options(
joinedload(Article.author), # ★多对一 → JOIN★
selectinload(Article.comments), # ★★一对多 → 单独 IN 查询(不产生笛卡尔积)★★
).all()
# ③ ★请求内缓存:g★
def get_current_settings():
if "settings" not in g:
g.settings = Setting.query.all() # ★一次请求只查一次★
return g.settings
# ④ ★进程内缓存:lru_cache(★只适合不变的数据★)★
from functools import lru_cache
@lru_cache(maxsize=1)
def get_country_map(): # ★字典表,重启才更新★
return {c.code: c.name for c in Country.query.all()}
# ★⚠️ 注意:多 worker 下每个进程一份;数据变了要重启或手动 cache_clear()★
# ⑤ ★★跨进程缓存:Flask-Caching + Redis★★
from flask_caching import Cache
cache = Cache()
cache.init_app(app, config={
"CACHE_TYPE": "RedisCache", # ★★不要用默认的 SimpleCache★★
"CACHE_REDIS_URL": "redis://localhost:6379/1",
"CACHE_DEFAULT_TIMEOUT": 300,
"CACHE_KEY_PREFIX": "myapp:", # ★多应用共用 Redis 时必加★
})
@app.route("/articles")
@cache.cached(timeout=60, query_string=True) # ★★必须加 query_string★★
def article_list(): ...
@cache.memoize(timeout=300) # ★按参数缓存函数★
def get_user_stats(user_id): ...
cache.delete_memoized(get_user_stats, user_id) # ★精确失效★
# ★手动操作★
cache.set("k", value, timeout=60)
cache.get("k")
cache.delete("k")
cache.get_or_set("k", lambda: expensive(), timeout=60)
# ⑥ ★HTTP 缓存★
@app.route("/api/config")
def config():
data = get_config()
etag = hashlib.md5(json.dumps(data, sort_keys=True).encode()).hexdigest()
resp = jsonify(data)
resp.set_etag(etag)
resp.cache_control.max_age = 300
return resp.make_conditional(request) # ★★匹配则自动返回 304★★
⚠️ 三个必须记住的点:①
@cache.cached()的默认缓存 key 只用request.path——也就是说/articles?page=1和/articles?page=2会共用同一份缓存,用户翻到第 2 页看到的还是第 1 页的内容。必须加query_string=True(它会把查询参数排序后哈希进 key)。同样的道理,如果响应内容和登录用户有关,缓存 key 里必须包含用户标识——否则会把 A 用户的数据缓存给 B 用户看到,这是缓存最严重的事故类型。② 多 worker 部署时必须用 Redis 而不是默认的SimpleCache。SimpleCache把数据存在进程内存里,gunicorn 起 8 个 worker 就有 8 份互相独立的缓存——命中率只有 1/8,更糟的是数据不一致(一个 worker 更新了数据并删了自己的缓存,其他 7 个还在返回旧数据)。同理Flask-Limiter的memory://存储也是这个问题。③ 缓存的难点是失效不是写入。推荐的组合是「写操作后主动cache.delete相关 key」加「较短的 TTL 兜底」——主动删保证及时性,TTL 保证即使漏删了也不会一直脏下去。要避开三个经典问题:缓存穿透(大量查询不存在的数据,每次都打到数据库——缓存空值或用布隆过滤器)、缓存击穿(热点 key 过期瞬间大量请求涌向数据库——用分布式锁做单飞)、缓存雪崩(大量 key 同时过期——TTL 加随机抖动)。
完整版教学
一、先测量:瓶颈到底在哪
★ ★优化的第一原则:不测量就是瞎猜★
典型的错误路径:
"页面慢" → "加个 Redis 缓存吧" → ★复杂度上升,还是慢★
真实原因:★一条没走索引的 SQL 扫了 200 万行★
★ ★一次请求的时间都花在哪(典型分布)★:
┌──────────────────────────────────────────────────┐
│ Flask 框架开销(路由/上下文) │ ★< 1ms★ │
│ 模板渲染(不含查询) │ ★1~10ms★ │
│ ★数据库查询★ │ ★★10~2000ms★★ │
│ ★外部 API 调用★ │ ★★50~5000ms★★ │
│ 序列化(1000 条记录) │ 10~50ms │
└──────────────────────────────────────────────────┘
★ ★所以:先看数据库和外部调用★
★ ★工具一:flask-debugtoolbar(开发环境)★
pip install flask-debugtoolbar
app.config["DEBUG_TB_INTERCEPT_REDIRECTS"] = False
DebugToolbarExtension(app)
→ ★浏览器右侧面板直接显示:SQL 条数、每条耗时、模板渲染时间、
请求耗时、g 里的内容★
★ ★排查 N+1 最快的方式★
★ ★工具二:SQL 计数中间件(可上生产)★
app.config["SQLALCHEMY_RECORD_QUERIES"] = True
@app.after_request
def check(resp):
qs = get_recorded_queries()
if len(qs) > 20:
logger.warning("N+1? path=%s n=%d", request.path, len(qs))
return resp
★ ★把"查询条数"变成可监控的指标★
★ ★工具三:cProfile(找 CPU 热点)★
from werkzeug.middleware.profiler import ProfilerMiddleware
app.wsgi_app = ProfilerMiddleware(app.wsgi_app,
restrictions=[30],
profile_dir="./profiles")
★ 适合:★纯计算慢★(序列化、加密、图片处理)
★ 不适合:IO 等待(profile 里只会看到 socket.recv)
★ ★工具四:APM(生产环境)★
Sentry Performance / Elastic APM / SkyWalking / OpenTelemetry
→ ★分布式追踪:一个请求在各层花了多久★
★ 至少要有:★每个 endpoint 的 P50/P95/P99 耗时★
★ ★自己实现的最小可观测性★:
@app.before_request
def _start(): g._t0 = time.perf_counter()
@app.after_request
def _end(resp):
dur = (time.perf_counter() - g._t0) * 1000
resp.headers["X-Response-Time"] = f"{dur:.1f}ms" # ★浏览器可见★
metrics.histogram("http.duration", dur,
tags={"endpoint": request.endpoint})
return resp
★ ★压测:知道极限在哪★
wrk -t4 -c100 -d30s http://localhost:8000/api/articles
ab -n 1000 -c 50 http://...
locust(★能写复杂场景★)
★ 关注:★QPS、P95 延迟、错误率★
★ ★压测要在类生产环境做★(本地 SQLite 压出来的数字没意义)
优化的第一原则是「不测量就是瞎猜」——最典型的错误路径是「页面慢 → 加个 Redis → 复杂度上升还是慢」,而真实原因是一条没走索引的 SQL 扫了 200 万行。看一次请求的时间分布就知道该往哪查:Flask 框架开销不到 1ms、模板渲染 1~10ms、而数据库查询和外部 API 调用可以到几百上千毫秒——所以先看数据库和外部调用。工具上:开发用 flask-debugtoolbar(浏览器面板直接显示 SQL 条数和耗时,排查 N+1 最快)、生产可以上一个 SQL 计数中间件(把「查询条数」变成可监控指标)、纯计算慢用 cProfile(但它看不出 IO 等待)、生产环境最好有 APM(至少要有每个 endpoint 的 P95/P99)。压测要注意必须在类生产环境做——本地 SQLite 压出来的数字没有意义。
二、数据库层面的优化
★ ★N+1:最常见也最容易修的问题★
# 现象
articles = Article.query.limit(100).all() # ★1 条★
for a in articles:
a.author.name # ★★100 条★★
len(a.comments) # ★★100 条★★
→ ★201 条 SQL,每条 1ms 网络往返 = 200ms 纯等待★
# ★两种预加载策略★
joinedload(Article.author) # ★LEFT JOIN,一条 SQL★
✓ 适合★多对一/一对一★
✗ ★一对多时会产生笛卡尔积★(100 篇文章 × 各 10 条评论 = 1000 行)
selectinload(Article.comments) # ★额外一条 WHERE id IN (...)★
✓ ★适合一对多★,行数不放大
✓ ★可以嵌套★:selectinload(A.comments).joinedload(Comment.user)
subqueryload(...) # 子查询(★较老,一般用 selectinload★)
# ★算例对比(100 篇文章,每篇 10 条评论)★
┌────────────────────┬──────────┬────────────────────┐
│ 懒加载 │ ★201 条★ │ 每条都是往返 │
│ joinedload(comments)│ 1 条 │ ★★返回 1000 行★★ │
│ selectinload(...) │ ★2 条★ │ ★100 + 1000 行,无重复★│
└────────────────────┴──────────┴────────────────────┘
★ ★只取需要的列★:
# ✗ 取了 content(可能是几十 KB 的正文)
Article.query.all()
# ✓
db.session.query(Article.id, Article.title).all()
Article.query.options(load_only(Article.id, Article.title))
Article.query.options(defer(Article.content)) # ★延迟加载大字段★
★ ★列表页不要加载正文★——这一条常能省 90% 的传输量
★ ★count 的优化★:
✗ len(query.all()) # ★★把所有数据读进来才数★★
✓ query.count() # SELECT COUNT(*)
✓ ★大表上 COUNT 也很贵★ → 估算或不显示总数(见分页题)
✗ if query.all(): # 同样把数据全读了
✓ ★db.session.query(query.exists()).scalar()★
★ ★批量操作★:
✗ for x in items: db.session.add(Model(**x)); db.session.commit()
→ ★N 次提交,N 次 fsync★
✓ db.session.bulk_insert_mappings(Model, items) # ★★快 10~100 倍★★
✓ db.session.execute(update(Model).where(...).values(...))
★ 注意:bulk 系列★不触发 ORM 事件和默认值★
★ ★索引:最高性价比的优化★
★ 什么时候需要:
- ★WHERE 条件里的列★
- ★JOIN 的关联列(外键通常要)★
- ★ORDER BY 的列★
- ★复合索引遵循最左前缀★
★ 排查:
EXPLAIN ANALYZE SELECT ... # ★看是不是 Seq Scan★
pg_stat_statements / MySQL 慢查询日志
★ ★注意:索引不是越多越好★——每个索引都让写入变慢、占空间
★ ★连接池★:
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 10, # ★常驻连接★
"max_overflow": 20, # 峰值额外连接
"pool_pre_ping": True, # ★★检测失效连接(治 MySQL 8 小时超时)★★
"pool_recycle": 3600, # ★主动回收(要小于数据库 wait_timeout)★
"pool_timeout": 30,
}
★ ★总连接数 = worker 数 × (pool_size + max_overflow)★
→ ★8 个 worker × 30 = 240 个连接★,可能超过数据库上限!
★ 用 PgBouncer 之类的连接池中间件可以缓解
N+1 是最常见也最容易修的问题——100 篇文章加载作者和评论会发 201 条 SQL,每条 1ms 往返就是 200ms 纯等待。两种预加载策略要分清:joinedload 适合多对一(一条 SQL 搞定),selectinload 适合一对多(额外一条 IN 查询,不会产生笛卡尔积——joinedload 加载 100 篇文章的评论会返回 1000 行重复数据)。只取需要的列也很有效——列表页不加载正文常能省 90% 的传输量。计数要用 query.count() 而不是 len(query.all()),判断存在用 exists() 而不是 if query.all()。连接池有个容易被忽略的算术:总连接数 = worker 数 × (pool_size + max_overflow)——8 个 worker 配 30 就是 240 个连接,很可能超过数据库上限。
三、缓存的层次与选择
★ ★四层缓存(从近到远)★:
┌────────────────────────────────────────────────────────┐
│ ① ★请求内(g)★ 同一请求内不重复查 ★零成本★ │
│ ② ★进程内(lru_cache)★ 不变的数据 ★极快,不共享★│
│ ③ ★跨进程(Redis)★ ★大多数场景★ 网络往返 <1ms │
│ ④ ★HTTP(ETag/CDN)★ ★请求根本不到应用★ ★最优★ │
└────────────────────────────────────────────────────────┘
★ ★原则:能在更外层挡住的,就不要放到内层★
★ ★① 请求内缓存(g)★:
def current_settings():
if "settings" not in g:
g.settings = load_settings()
return g.settings
★ 场景:一个请求里多处需要同一份数据(模板、视图、装饰器)
★ ★零失效问题★(请求结束就没了)
★ ★② 进程内缓存(lru_cache)★:
@lru_cache(maxsize=128)
def get_config_value(key): ...
★ ✓ 适合:★字典表、配置、编译好的正则/模板★
★ ✗ 不适合:★会变的业务数据★
- ★多 worker 下每进程一份 → 更新后各进程不一致★
- ★没有 TTL★(除非自己实现带时间的 key)
★ 带 TTL 的技巧:
@lru_cache(maxsize=32)
def _get(key, _bucket): ...
def get(key): return _get(key, int(time.time()) // 60) # ★每分钟换 key★
★ ★③ Redis 缓存(Flask-Caching)★:
# ★视图缓存★
@cache.cached(timeout=60, query_string=True,
unless=lambda: current_user.is_authenticated) # ★★登录用户不缓存★★
def public_list(): ...
# ★函数缓存(memoize,按参数)★
@cache.memoize(timeout=300)
def get_article_detail(article_id): ...
cache.delete_memoized(get_article_detail, 42) # ★精确删★
cache.delete_memoized(get_article_detail) # ★删该函数所有缓存★
# ★自定义 key(★需要含用户时★)★
def user_key():
return f"stats:{current_user.id}:{request.args.get('range','7d')}"
@cache.cached(timeout=300, key_prefix=user_key)
def my_stats(): ...
# ★手动模式(最灵活)★
def get_hot_articles():
key = "hot_articles"
data = cache.get(key)
if data is None:
data = compute_hot()
cache.set(key, data, timeout=300)
return data
★ ★④ HTTP 缓存(★最容易被忽略但收益最大★)★:
# 静态资源:★长缓存 + 文件名带 hash★
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 31536000
url_for("static", filename="app.js", v=BUILD_ID)
# API:ETag 条件请求
resp.set_etag(etag)
return resp.make_conditional(request) # ★匹配返回 304,body 为空★
★ ★304 的价值:省带宽 + 省客户端渲染★(服务端仍要算 ETag)
# 公开内容:让 CDN 缓存
resp.cache_control.public = True
resp.cache_control.max_age = 300
resp.headers["Vary"] = "Accept-Encoding" # ★★声明缓存维度★★
★ ★注意:带 Cookie 或用户相关的响应必须 private/no-store★
否则 ★CDN 可能把 A 用户的页面发给 B★(★重大事故★)
★ ★缓存什么最划算★:
✓ ★计算/查询昂贵 + 读多写少 + 允许短暂不一致★
首页热门列表、统计报表、字典表、第三方 API 结果
✗ ★个性化强的★(每个用户不同 → 命中率低)
✗ ★变化极快的★(缓存刚写就失效)
✗ ★本来就很快的★(缓存的网络往返可能比查询还慢)
四层缓存的原则是「能在更外层挡住的,就不要放到内层」。请求内用 g(零失效问题)、进程内用 lru_cache(只适合不变的数据,因为多 worker 下每进程一份且没有 TTL)、跨进程用 Redis、HTTP 层最优(请求根本到不了应用)。Flask-Caching 有几个实用参数:unless= 可以让登录用户跳过缓存、key_prefix 可以传函数来自定义 key、delete_memoized 能精确失效。HTTP 缓存最容易被忽略但收益最大——静态资源用「长缓存 + 文件名带 hash」,API 用 ETag 条件请求返回 304;但有个重大风险:带 Cookie 或用户相关的响应必须标 private/no-store,否则 CDN 可能把 A 用户的页面发给 B。判断「缓存什么最划算」:计算昂贵 + 读多写少 + 允许短暂不一致才值得,个性化强的(命中率低)和本来就很快的(缓存的网络往返可能比查询还慢)不值得。
四、缓存失效与三大经典问题
★ ★失效策略★:
① ★TTL 过期(最简单)★
cache.set(key, v, timeout=300)
✓ 实现简单、自动兜底
✗ ★有最长 300 秒的不一致窗口★
② ★写时主动删除(★推荐配合 TTL★)★
def update_article(art, data):
...; db.session.commit()
cache.delete(f"article:{art.id}") # ★主动删★
cache.delete_memoized(get_article_list) # ★相关列表也要删★
✗ ★难点:一次写要删哪些 key?★(容易漏)
③ ★版本号/代际(优雅处理"批量失效")★
ver = cache.get("articles:ver") or 1
key = f"articles:list:{ver}:{page}"
# 有写入时:cache.incr("articles:ver") ← ★★所有旧 key 自然失效★★
★ 好处:★不用枚举要删哪些 key★
④ ★写时更新(write-through)★
写数据库的同时更新缓存
✗ ★并发下容易写乱★(两个请求的顺序不可控)
★ 一般不如"删除"安全 —— ★删除是幂等的★
★ ★★三大经典问题★★:
① ★缓存穿透★:查询★不存在★的数据
攻击:/api/user/-1 循环请求 → ★每次都打到数据库★
✓ ★缓存空值★(短 TTL,如 60 秒):
data = cache.get(key)
if data is None:
data = query()
cache.set(key, data if data else "__NULL__", timeout=60)
if data == "__NULL__": return None
✓ ★布隆过滤器★(大规模场景)
✓ ★参数校验★(id <= 0 直接返回 404)
② ★★缓存击穿★★:★热点 key 过期的瞬间★
一个 QPS 5000 的热点 key 过期
→ ★5000 个请求同时发现缓存空 → 全部打到数据库★
✓ ★★单飞(single-flight):加分布式锁★★
def get_hot():
v = cache.get(KEY)
if v is not None: return v
if redis.set(LOCK, "1", nx=True, ex=10): # ★★只有一个拿到锁★★
try:
v = compute()
cache.set(KEY, v, timeout=300)
finally:
redis.delete(LOCK)
return v
time.sleep(0.05) # ★其他请求短暂等待★
return cache.get(KEY) or compute() # 兜底
✓ ★逻辑过期★:value 里存过期时间,过期后★返回旧值 + 异步刷新★
(★用户始终不等待,最平滑★)
③ ★缓存雪崩★:★大量 key 同时过期★
场景:启动时预热了 10000 个 key,TTL 都是 300 秒
→ ★300 秒后同时失效★
✓ ★TTL 加随机抖动★:
timeout = 300 + random.randint(0, 60)
✓ ★多级缓存★(本地 + Redis,本地扛第一波)
✓ ★熔断降级★(数据库压力大时返回兜底数据)
★ ★缓存一致性的现实认知★:
★ 只要用了缓存,就★一定存在不一致窗口★
→ ★问题不是"如何做到强一致",而是"能容忍多久"★
┌──────────────────┬──────────────────────────┐
│ 商品价格/库存 │ ★秒级甚至不缓存★ │
│ 文章内容 │ 分钟级可接受 │
│ 统计报表 │ ★小时级完全没问题★ │
│ 字典表/配置 │ 重启才更新也行 │
└──────────────────┴──────────────────────────┘
★ ★先和产品确认容忍度,再决定 TTL★
★ ★序列化开销(容易忽略)★:
Redis 存的是字节 → 每次读写都要 pickle/json 序列化
★ 大对象(几 MB)的序列化可能比查数据库还慢!
✓ ★只缓存需要的字段★,不要缓存整个 ORM 对象
✓ ★ORM 对象通常不能直接 pickle★(含 session 引用)→ 缓存 dict
失效策略里推荐「写时主动删除 + 较短 TTL 兜底」——主动删保证及时、TTL 保证漏删了也不会一直脏;而**「版本号/代际」能优雅处理批量失效**(incr 一下所有旧 key 自然失效,不用枚举要删哪些)。三大经典问题:缓存穿透(查不存在的数据,缓存空值或布隆过滤器)、缓存击穿(热点 key 过期瞬间 5000 个请求同时打到数据库,用分布式锁做单飞,或者用**「逻辑过期」返回旧值 + 异步刷新**让用户始终不等待)、缓存雪崩(大量 key 同时过期,TTL 加随机抖动)。最重要的现实认知是:只要用了缓存就一定存在不一致窗口,问题不是「如何强一致」而是「能容忍多久」——库存价格要秒级甚至不缓存、统计报表小时级完全没问题,先和产品确认容忍度再决定 TTL。还有个容易忽略的点:大对象的序列化可能比查数据库还慢,而且 ORM 对象通常不能直接 pickle(含 session 引用),应该缓存 dict。
五、并发模型与部署调优
★ ★WSGI 的根本约束★:
★一个 worker 同时只能处理一个请求★(同步模型)
→ ★请求耗时 100ms → 单 worker 最多 10 QPS★
→ ★总吞吐 ≈ worker 数 / 平均请求耗时★
★ ★gunicorn 的 worker 类型★:
┌──────────────┬────────────────────────────────────────┐
│ ★sync(默认)★│ ★一进程一请求★;简单可靠 │
│ │ ✗ ★IO 等待时完全浪费★ │
│ ★gthread★ │ ★进程内多线程★(--threads 4) │
│ │ ✓ ★IO 密集时提升明显★(GIL 在 IO 时释放)│
│ ★gevent★ │ ★协程,单进程数千并发★ │
│ │ ✗ ★需要 monkey patch,某些库不兼容★ │
│ uvicorn(ASGI)│ 真异步,★需要 async 应用★ │
└──────────────┴────────────────────────────────────────┘
# ★典型配置★
gunicorn -w 4 --threads 4 -k gthread \
--timeout 60 --max-requests 1000 --max-requests-jitter 100 \
"myapp:create_app()"
★ ① workers ≈ ★CPU 核数 × 2 + 1★(CPU 密集)
IO 密集可以更多,或用 gthread/gevent
② ★--max-requests 定期重启 worker★(★兜住内存泄漏★)
③ ★--timeout 要大于最慢的请求★(否则 worker 被杀)
④ ★--preload 能省内存★(fork 前加载代码),
★但会让 --max-requests 的重启失去 CoW 优势,且不能热重载★
★ ★算例:worker 数怎么定★
假设:平均请求 50ms(其中 40ms 在等数据库)
★sync★:单 worker = 20 QPS,8 workers = ★160 QPS★
★gthread(8×4)★:IO 等待时线程切换 → ★接近 600 QPS★
★ ★IO 占比越高,多线程收益越大★
★ 但要注意:★线程数 × worker 数 = 数据库连接需求★
★ ★外部调用必须处理★:
✗ @app.post("/register")
def register():
user = create_user()
send_welcome_email(user) # ★★同步发邮件,阻塞 2 秒★★
return {"ok": True}
✓ 异步化:
send_welcome_email.delay(user.id) # ★Celery★
✓ 至少加超时:
requests.get(url, timeout=(3, 5)) # ★★连接3秒/读5秒★★
★ ★没有超时的外部调用是生产事故的头号来源★
→ 对方卡住 = ★你的 worker 全部被占满 = 整站不可用★
★ ★静态文件不要走 Flask★:
✗ Flask 提供 static/(★占用 worker、无 sendfile★)
✓ ★Nginx / CDN★
✓ 受保护文件用 ★X-Accel-Redirect★
★ ★压缩交给 Nginx★:
gzip on; gzip_types application/json text/css application/javascript;
★ 应用层做压缩会占 CPU 且难以复用
★ ★数据库连接数的算术(★容易爆★)★:
gunicorn 8 workers × pool_size 10 × max_overflow 20 = ★240 连接★
PostgreSQL 默认 max_connections = 100 → ★★直接连不上★★
✓ 调小 pool_size(每 worker 5 个通常够)
✓ 或上 ★PgBouncer★(连接复用)
WSGI 的根本约束是「一个 worker 同时只能处理一个请求」——所以总吞吐 ≈ worker 数 / 平均请求耗时。worker 类型的选择:sync 简单可靠但 IO 等待时完全浪费、gthread(多线程)在 IO 密集时提升明显(GIL 在 IO 时会释放)、gevent 单进程能扛数千并发但需要 monkey patch。gunicorn 配置里有几个必设项:--max-requests 定期重启 worker 兜住内存泄漏、--timeout 要大于最慢的请求。最重要的一条运维经验是:没有超时的外部调用是生产事故的头号来源——对方服务卡住,你的 worker 会全部被占满导致整站不可用,所以 requests 必须传 timeout,能异步化的(发邮件、推送)一律丢给 Celery。还有个容易爆的算术:8 workers × (pool_size 10 + overflow 20) = 240 个数据库连接,而 PostgreSQL 默认 max_connections 只有 100——直接连不上,要调小 pool_size 或上 PgBouncer。
六、实践清单
★ ★优化的正确顺序★:
① ★测量★(找到真正的瓶颈,别猜)
② ★消灭 N+1★(性价比最高)
③ ★加索引★(EXPLAIN 确认)
④ ★减少数据量★(分页、只取需要的列)
⑤ ★外部调用异步化 + 加超时★
⑥ ★HTTP 缓存★(挡在应用之外)
⑦ ★Redis 缓存★(引入失效复杂度)
⑧ ★调 worker/线程数★
⑨ 加机器
★ ★缓存排在很后面★——它是"最后手段"而不是"第一反应"
★ 检查清单:
□ ★有 SQL 条数和耗时的监控★
□ ★列表接口做了预加载(joinedload/selectinload)★
□ ★列表页不加载大字段(正文/富文本)★
□ ★per_page 有上限★
□ ★所有外部调用都有 timeout★
□ ★耗时操作走 Celery★
□ ★@cached 加了 query_string=True★
□ ★用户相关的缓存 key 含用户标识★
□ ★多 worker 用 Redis 而不是 SimpleCache★
□ ★TTL 有随机抖动★
□ ★热点 key 有单飞保护★
□ ★缓存的是 dict 而不是 ORM 对象★
□ ★静态文件走 Nginx/CDN★
□ ★连接数算过(workers × pool ≤ 数据库上限)★
□ ★gunicorn 配了 max-requests 和 timeout★
★ ★常见"优化"反例★:
✗ ★没测量就加 Redis★ → 复杂度上升,问题还在
✗ ★缓存 ORM 对象★ → pickle 失败或反序列化后 session 已断
✗ ★缓存 key 不含用户★ → ★数据串号(最严重的事故)★
✗ ★用 lru_cache 缓存会变的数据★ → 多 worker 不一致
✗ ★盲目加 worker★ → 数据库连接爆了
✗ ★在缓存里放大对象★ → 序列化比查库还慢
★ 一句话总结:
★"Flask 应用的瓶颈几乎从不在框架,而在 N+1、慢查询和同步的外部调用;
优化的顺序是先测量、再消灭 N+1 和加索引、再减少数据量、
最后才轮到缓存;用 Flask-Caching 记得 query_string=True、
多 worker 用 Redis、key 里带上用户标识、TTL 加抖动。"★
优化的正确顺序是:测量 → 消灭 N+1 → 加索引 → 减少数据量 → 外部调用异步化 → HTTP 缓存 → Redis 缓存 → 调 worker → 加机器——缓存排在很后面,它是「最后手段」而不是「第一反应」。检查清单里最关键的四条:所有外部调用都有 timeout、@cached 加 query_string=True、用户相关的缓存 key 必须含用户标识、连接数算过(workers × pool ≤ 数据库上限)。反例清单里最严重的是**「缓存 key 不含用户导致数据串号」**——这是真正的生产事故。
记忆钩子:「★Flask 应用的性能瓶颈几乎从不在框架本身★(路由和模板都是编译后的函数调用,微秒级),★真正的瓶颈按频率排:① 数据库 N+1 ② 缺索引的慢查询 ③ 同步的外部调用 ④ 返回数据量太大 ⑤ worker 不够★。★优化第一原则:不测量就是瞎猜★——典型错误路径是『页面慢 → 加 Redis → 复杂度上升还是慢』,真实原因是一条没走索引的 SQL。★优化顺序:测量 → 消灭 N+1 → 加索引 → 减少数据量 → 外部调用异步化+加超时 → HTTP 缓存 → Redis 缓存 → 调 worker → 加机器★,★缓存排在很后面,是最后手段不是第一反应★。N+1 修复要分清:★joinedload 适合多对一(一条 SQL),selectinload 适合一对多(额外一条 IN 查询,不产生笛卡尔积)★——joinedload 加载 100 篇文章的评论会返回 1000 行重复数据;★列表页不加载正文常能省 90% 传输量★。★缓存分四层:请求内用 g(零失效问题)、进程内用 lru_cache(只适合不变的数据,多 worker 下每进程一份且没 TTL)、跨进程用 Redis、HTTP 层最优(请求根本不到应用)★——★原则是能在更外层挡住就别放内层★。Flask-Caching 两个必踩的坑:★① @cache.cached() 的默认 key 只用 request.path 不含查询串 → 分页第 2 页会显示第 1 页内容,必须加 query_string=True★;★② 多 worker 下必须用 Redis 而不是默认的 SimpleCache★(每进程一份内存缓存,命中率 1/N 且数据不一致)。★最严重的缓存事故是 key 不含用户标识导致数据串号★,同理★带 Cookie 的响应必须标 private/no-store,否则 CDN 可能把 A 用户的页面发给 B★。★缓存的难点是失效不是写入★:推荐★『写时主动删 + 较短 TTL 兜底』★,批量失效用★版本号/代际(incr 一下旧 key 全失效)★。三大经典问题:★穿透★(查不存在的数据 → 缓存空值或布隆过滤器)、★击穿★(热点 key 过期瞬间 5000 请求打到数据库 → ★分布式锁单飞★或★逻辑过期返回旧值+异步刷新★)、★雪崩★(大量 key 同时过期 → ★TTL 加随机抖动★)。★现实认知:只要用了缓存就一定有不一致窗口,问题不是『如何强一致』而是『能容忍多久』★。部署要点:★总吞吐 ≈ worker 数 / 平均请求耗时★,★IO 密集用 gthread(GIL 在 IO 时释放)★,★gunicorn 必配 —max-requests(兜内存泄漏)和 —timeout★;★没有超时的外部调用是生产事故头号来源★(对方卡住 = worker 全占满 = 整站不可用);★连接数算术容易爆:8 workers × (pool 10 + overflow 20) = 240,而 PG 默认 max_connections 只有 100★。还有:★缓存 dict 不要缓存 ORM 对象★(含 session 引用不能 pickle),★大对象序列化可能比查库还慢★。」
七、常见误区与追问
- 误区:页面慢就加缓存。 缓存是最后手段而不是第一反应。它引入了一整套新问题:失效逻辑、一致性窗口、Redis 的运维和可用性、序列化开销、以及最危险的缓存串号(key 设计错了会把 A 用户的数据给 B 看到)。而且很多时候它根本没解决问题——如果慢的原因是一条扫了 200 万行的 SQL,加了缓存只是让「第一个用户」和「缓存过期后的那个用户」继续等,P99 延迟依然很糟。正确的顺序是先测量找到真正的瓶颈(用 debugtoolbar 看 SQL 条数、用 EXPLAIN 看执行计划),然后按性价比处理:消灭 N+1(改一行查询,收益 10~100 倍)、加索引(收益可能上千倍)、减少返回的数据量(分页、不查大字段)、外部调用异步化。这些做完之后如果还慢,再考虑缓存——那时你也更清楚该缓存什么。
- 误区:用了
@cache.cached(timeout=60)分页接口就快了。@cache.cached()的默认缓存 key 只包含request.path——所以/articles?page=1、/articles?page=2、/articles?status=draft全部共用同一份缓存:用户翻页时看到的永远是第一次被缓存的那一页。必须加query_string=True,它会把查询参数排序后哈希进 key。而比这更严重的是「用户维度」:如果响应内容和当前登录用户有关(「我的订单」「我的收藏」),默认 key 里没有任何用户标识,结果就是第一个访问的用户的数据被缓存下来发给了所有人——这是最严重的一类生产事故(数据泄露)。解法有两个:用unless=lambda: current_user.is_authenticated让登录用户跳过缓存,或者用key_prefix=传一个函数把用户 ID 拼进 key。原则很简单:缓存 key 必须包含所有会影响响应内容的因素。 - 误区:Flask-Caching 装上就能用,配置用默认的就行。 默认的
CACHE_TYPE是SimpleCache——它把数据存在进程内存里。单进程开发时一切正常,但生产环境 gunicorn 起 8 个 worker 就有8 份互相独立的缓存:① 命中率只有 1/8(同一个 URL 打到不同 worker 就要重新计算);② 数据不一致——一个 worker 处理了写请求并删掉了自己的缓存,其他 7 个 worker 里的旧数据依然会被返回,用户刷新页面时会看到内容在新旧之间来回跳;③ worker 重启(比如--max-requests触发)缓存全丢。所以多 worker 部署必须用RedisCache(或 Memcached)。同样的道理适用于Flask-Limiter的默认memory://存储——限流计数分散在各进程,「每分钟 10 次」实际变成了「每分钟 10×N 次」。这两个都属于「看起来装好了、实际没生效」的典型。 - 误区:把 SQLAlchemy 查出来的对象直接扔进缓存。 通常会失败或产生诡异的 bug。ORM 对象内部持有对
Session的引用和一堆状态(identity map、加载标记、关系代理),pickle 时可能直接报错;即使侥幸序列化成功,反序列化出来的对象是「游离态(detached)」的——访问任何未加载的关联属性都会抛DetachedInstanceError。而且缓存一个 ORM 对象往往会连带把它的关联对象都拖进去,体积远超你的预期。正确做法是缓存纯数据:用 marshmallow/pydantic 序列化成 dict 再存,读出来后要么直接用 dict 渲染、要么按需重新查数据库。这里还有个容易忽略的成本:序列化本身是有开销的——如果缓存的对象有几 MB,pickle/unpickle 加上 Redis 的网络传输,可能比直接查数据库还慢,这时候缓存就是纯粹的负收益。 - 误区:给所有缓存设一样的 TTL 更好管理。 会引发缓存雪崩。典型场景:应用启动时预热了 10000 个 key、TTL 统一设成 300 秒——300 秒后它们会在几乎同一时刻集体失效,于是这一瞬间所有请求全部穿透到数据库,数据库连接池被打满、响应变慢、更多请求堆积,可能直接把整个系统拖垮。修复很简单:给 TTL 加随机抖动(
timeout = 300 + random.randint(0, 60)),把失效时间打散。与之相关的还有缓存击穿——单个热点 key 过期的瞬间,比如一个 QPS 5000 的首页数据,过期那一刻 5000 个请求同时发现缓存为空、同时去查数据库;解法是分布式锁做「单飞」(只让一个请求去重建缓存,其他的短暂等待后读缓存),或者更平滑的**「逻辑过期」——value 里存一个业务过期时间,到期后先返回旧值再异步刷新**,用户永远不用等待。 - 追问:
joinedload和selectinload该怎么选? 关键看关系的方向。joinedload生成一条带LEFT JOIN的 SQL——适合多对一/一对一(Article.author),因为每篇文章只有一个作者,JOIN 不会让结果集变大。但用在一对多上就有问题:加载 100 篇文章及其各 10 条评论,JOIN 的结果是 1000 行(每篇文章的数据重复 10 次),网络传输和 Python 侧的去重都变贵了——这就是笛卡尔积放大。selectinload则是发两条 SQL:先查 100 篇文章,再用WHERE article_id IN (...)一次性把所有评论查出来,行数不放大,而且能很好地嵌套(selectinload(Article.comments).joinedload(Comment.user))。经验法则:多对一用joinedload、一对多和多对多用selectinload;两者可以在同一个查询里组合使用。另外还有subqueryload(较老的方案,一般被selectinload取代)和raiseload——后者很有用:把关系设成raiseload后,任何忘记预加载的懒加载访问都会直接抛异常,可以在测试里用它强制暴露 N+1。 - 追问:为什么说「没有超时的外部调用是生产事故的头号来源」? 因为它会把别人的故障变成你的故障。假设你的注册接口里同步调用了短信服务商的 API,而对方的服务器出了问题——不是拒绝连接(那会快速失败),而是接受了连接但一直不返回。在 WSGI 同步模型下,每一个正在等待的请求都独占一个 worker:你有 8 个 worker,只要有 8 个用户在注册,整个网站的所有接口都会开始排队超时——首页打不开、登录不了、API 全挂,而你的服务器 CPU 和内存都很空闲,日志里也没有报错,排查时非常迷惑。防御是三层:① 所有
requests调用必须传timeout=(连接超时, 读取超时)(注意默认是永不超时);② 能异步化的一律异步化——发邮件、发短信、推送、同步到第三方,这些都不该在请求周期内完成,丢给 Celery;③ 加熔断(连续失败 N 次后直接快速失败一段时间,给对方恢复的机会,也保护自己)。 - 追问:gunicorn 的 worker 数该怎么定?多线程有用吗? 先理解约束:同步 worker 一次只能处理一个请求,所以总吞吐 ≈ worker 数 ÷ 平均请求耗时。经典公式
workers = 2 × CPU核数 + 1是针对 CPU 密集型应用的(多出来的 worker 用于填补上下文切换的空隙)。但大多数 Web 应用是 IO 密集的——一个 50ms 的请求里可能 40ms 都在等数据库返回,这时 CPU 是闲着的。所以多线程确实有用:-k gthread --threads 4让每个 worker 进程内跑 4 个线程,GIL 在等待 IO 时会释放,于是同样 8 个进程的吞吐可以从 160 QPS 提升到接近 600 QPS。更激进的选择是 gevent(协程,单进程扛数千并发),但需要 monkey patch,某些 C 扩展库不兼容。但线程/进程不能无限加,因为有两个硬约束:① 内存(每个 worker 是一份完整的应用副本);② 数据库连接数——workers × threads决定了并发查询数,而workers × (pool_size + max_overflow)决定了连接上限,8 workers × 30 = 240 个连接会直接打爆默认只有 100 连接的 PostgreSQL。所以调优时要三者一起算。
八、加强记忆
Flask 应用的性能瓶颈几乎从不在框架本身(路由匹配和模板渲染都是编译后的函数调用,微秒级),真正的瓶颈按出现频率排是:① 数据库 N+1 ② 缺索引的慢查询 ③ 同步的外部调用 ④ 返回数据量太大 ⑤ worker 数量不够。优化的第一原则是「不测量就是瞎猜」——典型的错误路径是「页面慢 → 加 Redis → 复杂度上升还是慢」,而真实原因往往是一条没走索引的 SQL。正确顺序是:测量 → 消灭 N+1 → 加索引 → 减少数据量 → 外部调用异步化并加超时 → HTTP 缓存 → Redis 缓存 → 调 worker → 加机器——缓存排在很后面,它是最后手段而不是第一反应。修 N+1 要分清两种预加载:joinedload 适合多对一(一条 SQL),selectinload 适合一对多(额外一条 IN 查询,不产生笛卡尔积)——用 joinedload 加载 100 篇文章的评论会返回 1000 行重复数据;另外列表页不加载正文常能省 90% 的传输量。缓存分四层:请求内用 g(零失效问题)、进程内用 lru_cache(只适合不变的数据,多 worker 下每进程一份且没有 TTL)、跨进程用 Redis、HTTP 层最优(请求根本到不了应用)——原则是能在更外层挡住的就别放到内层。Flask-Caching 有两个必踩的坑:① @cache.cached() 的默认 key 只用 request.path、不含查询串,导致分页第 2 页显示第 1 页的内容,必须加 query_string=True;② 多 worker 下必须用 Redis 而不是默认的 SimpleCache(每进程一份内存缓存,命中率只有 1/N 且数据不一致)。最严重的缓存事故是 key 不含用户标识导致数据串号,同理带 Cookie 的响应必须标 private/no-store,否则 CDN 可能把 A 用户的页面发给 B。缓存的难点是失效而不是写入:推荐**「写时主动删除 + 较短 TTL 兜底」,批量失效用版本号/代际**(incr 一下所有旧 key 自然失效)。三大经典问题:穿透(查不存在的数据 → 缓存空值或布隆过滤器)、击穿(热点 key 过期瞬间大量请求打到数据库 → 分布式锁单飞或逻辑过期返回旧值 + 异步刷新)、雪崩(大量 key 同时过期 → TTL 加随机抖动)。最重要的现实认知:只要用了缓存就一定存在不一致窗口,问题不是「如何做到强一致」而是「业务能容忍多久」。部署要点:总吞吐 ≈ worker 数 ÷ 平均请求耗时,IO 密集型用 gthread(GIL 在 IO 时会释放),gunicorn 必配 --max-requests(兜住内存泄漏)和 --timeout;没有超时的外部调用是生产事故的头号来源(对方卡住 = worker 全被占满 = 整站不可用);还要算清连接数——8 workers × (pool 10 + overflow 20) = 240 个连接,而 PostgreSQL 默认 max_connections 只有 100。最后两条:缓存 dict 而不是 ORM 对象(后者含 session 引用,反序列化后会抛 DetachedInstanceError),大对象的序列化开销可能比查数据库还慢。