返回资讯中心
外部精选
软件工程
#开发者实时流

Django团队分享生产环境大规模缓存实践经验

来源标题:Caching at Scale in Django: Lessons from Production

一个多租户Django API团队在两年流量增长约20倍后,靠刻意且激进地使用缓存扛住了负载。相比之下,查询优化、升级主库、只读副本等方案要么效果有限,要么会增加复制延迟问题。团队按总数据库耗时对查询排序,以定位最需要缓存的环节。

Hacker News 最新johnnymetz2026/11/4 · 2 天前24 分钟阅读

以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。

我的团队运行着一个多租户的 Django API,多年来我们一直用常规方式扩展它:先换更大的 Postgres 主库,然后加只读副本,再配合几个短 TTL、失效逻辑不太完整的谨慎缓存。后来流量在两年内涨了大约 20 倍,真正扛住这波负载的,是刻意且激进的缓存策略。

flowchart LR C[Clients] --> LB[Load balancer] LB --> W[Django web servers] W --> R[(Redis)] W --> P[(Postgres primary)] W --> RR[(Postgres read replica)]

为什么用缓存,从哪里开始

我们的数据库 CPU 一直居高不下。每次修复都只是争取到一点时间,随后数据库又再次过载:

查询调优起初有帮助,但在一定规模下,即使一条调优良好的查询,每秒运行数千次也过于昂贵。纵向扩展到更大的主库是一次性、昂贵的余量提升,而增长很快会把它消耗掉。用只读副本横向扩展仍然要执行每一条查询,还会带来复制延迟的 bug。Postgres 自带的缓存保存的是页和索引,而不是查询结果,所以一个热点读取在每次调用时仍然消耗 CPU,即使它触及的所有内容都在内存中。缓存则不同:它消除工作量,而不是增加容量。

要找出最严重的瓶颈,按总数据库时间而不是单次查询延迟来给查询排序。一条 2 ms 的查询每秒运行数千次,比一条 500 ms 的查询每分钟运行一次消耗的数据库时间更多。对我们 Web 进程的生产 CPU 分析也印证了这一点:热点路径正是那些高调用量的路径。从列表顶部往下处理。对我们来说,这意味着先处理整个端点的响应。

全局缓存,按用户组装

每个用户一份缓存响应是最直观的形态:

def document_list(user): return get_or_set( f"user:{user.id}:documents", lambda: serialize(visible_documents(user)) ) # Redis: one copy per user user:1:documents -> [{id: A, title: "Roadmap", can_edit: true}, {id: B, title: "Budget", can_edit: true}] user:2:documents -> [{id: A, title: "Roadmap", can_edit: false}] user:N:documents -> [{id: A, title: "Roadmap", can_edit: false}, {id: B, title: "Budget", can_edit: false}]

当用户共享数据时,这种做法就行不通了。在一个有数千用户看到相同文档的工作区里,每个文档都被复制进每个用户的 key,所以重命名一个文档会使所有副本失效,它们还会同时重新填充。

改为把响应拆成两层:

按用户:该用户可见的文档 ID,即昂贵的权限查询。全局:每个文档一份序列化的基础数据,所有人共享。获取用户的文档 ID,在一次 get_many 中获取这些文档的基础数据,然后组装:

def document_list(user): doc_ids = get_or_set(f"user:{user.id}:doc-ids", lambda: visible_doc_ids(user)) bases = get_or_set_many( doc_ids, key=lambda doc_id: f"doc:{doc_id}", build=build_base ) group_ids = cached_group_ids(user) return [ { **base, "can_edit": ( base["owner_id"] == user.id or bool(base["editor_group_ids"] & group_ids) ), } for base in bases ] # Redis: one copy per document user:1:doc-ids -> [A, B] user:2:doc-ids -> [A] user:N:doc-ids -> [A, B] doc:A -> {id: A, title: "Roadmap", owner_id: 1, editor_group_ids: {7}} doc:B -> {id: B, title: "Budget", owner_id: 1, editor_group_ids: set()}

热缓存永远不会访问数据库,失效范围从数千个 key 缩小到一个:重命名一个文档只需清除 doc:A,而不是每个用户的副本。

这是用一次数据库查询换取几次 Redis 调用,这没问题:一次 Redis 调用远比哪怕最简单的 Postgres 查询便宜。不过便宜不等于免费,所以要控制调用次数:用 get_many 和 set_many 批量处理,并用类似 assertNumQueries 的缓存调用断言来防止缓存 N+1;我们写了一个叫 assertNumCacheCalls。

有一条规则保证安全:共享的基础数据不能依赖于请求者是谁。陷阱在于像 can_edit 这样的字段。如果按请求用户序列化它,第一个填充缓存的用户就替所有人做了决定。改为存储输入(owner、editor group ID),在组装时根据用户缓存的组来计算 can_edit。

长 TTL,精确失效

短 TTL 感觉安全,因为数据经常刷新,漏掉的失效会自行恢复。Django 的缓存默认五分钟。在规模下,这在两个方向上都是错的:

对热路径来说太短。用户离开时条目就过期了,所以当成千用户同时开始一个班次时,每个请求都同时未命中,重新填充会让数据库过载。对写入来说太长。创建文档的用户期望立即看到它,而不是五分钟后。所以无论如何失效都必须正确。我们在写入时用 Django signals 立即失效,并运行七天 TTL。具体数字取决于你的工作负载;我们选七天而不是三十天,是为了让不再被读取的条目自然过期,而不是让 Redis 膨胀。漏掉的失效可能让 Redis 中的数据陈旧数天,所以我们认真对待失效 bug,并显式测试它们:

每个 Django signal 都有测试,断言其写入清除了正确的 key。冷缓存和热缓存的查询次数用 assertNumQueries 固定,这样悄悄开始访问数据库的缓存会让测试套件失败。一个 Hypothesis 状态机运行随机写入序列,并在每次写入后断言缓存响应等于未缓存响应。它在生产前捕获了真实 bug,同时兼作跨用户泄漏检查。更多失效经验:

(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →