首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >两个字符让Django接口快了8倍:一次险些翻车的线上性能排查实录

两个字符让Django接口快了8倍:一次险些翻车的线上性能排查实录

作者头像
腾讯云开发者
发布2026-06-05 10:20:15
发布2026-06-05 10:20:15
10
举报

关注腾讯云开发者,一手技术干货提前解锁👇

“在掌握数据之前就下结论,是最大的错误。人会不知不觉地扭曲事实,让事实去迎合理论,而不是让理论服从事实。” 阿瑟·柯南·道尔,《波希米亚丑闻》,夏洛克·福尔摩斯

事情是怎么炸出来的

5月某日下午,某直播业务监控系统开始告警,外部模块请求OSS的数据API出现抖动,成功率大幅下降。

出问题的接口是 get_svr_info。

它跑在直播运营系统 OSS 里。项目本身很有年代感:Python 2.7.5、Django 1.11.3、MySQL 5.5。物理机,单体服务,十年前的技术栈,十年前的写法。没有k8s,没有微服务。它立项的时候,Python 3还没稳定,Django 2.0还是社区的讨论稿。

get_svr_info 的任务很简单:外部系统问“某个平台有哪些服务器”,它就把服务器信息查出来返回。

问题在于,全量场景时,它一次可能返回 10000+ 台设备,响应体最大约 7MB。外部模块又会高频调用。

我早就知道它慢在哪里。

至少,我以为我知道。

老代码大概是这种形态:

代码语言:javascript
复制
class GetServerInfoView(View):
    def get(self, request):
        result = {"code": 0, "msg": "ok", "servers": []}
        # 一次查出很多 Server model 对象
        queryset = Server.objects.filter(...)
        # 每个 model 对象再转换成对外返回的 dict
        result["servers"] = [
            self.gen_server_info(server)
            for server in queryset
        ]
        return StreamingHttpResponse(
            json.dumps(result),
            content_type="application/json",
        )
    def gen_server_info(self, server):
        return {
            "ip": server.outer_ip,
            "lan_ip": server.inner_ip,
            "idc_name": server.idc_name,
            "isp_name": server.isp,
            "status": server.status,
            # 还有一堆服务器字段...
        }

看懂这段不需要懂业务。

它的问题很朴素:

  1. 一次查很多行。
  2. Django ORM 把每行变成一个 model 对象。
  3. Python 再通过 server.xxx 一个字段一个字段读。
  4. 每台设备再重新构造一个 dict。
  5. 最后把整个大 JSON 丢给 StreamingHttpResponse。

我的判断是:这是典型老 Django 项目的 ORM 对象构造开销。

Django 从数据库拿到原始行数据后,会为每一行创建一个 Server model 实例,把字段值转换成 Python 对象并填到实例属性里;后面 gen_server_info() 再通过 server.outer_ip、server.idc_name 这类属性访问把值读出来,重新组装成 dict。数据量一大,这些 model 实例创建、字段转换、属性访问和 dict 重建都会变成实实在在的 CPU 消耗。

这个问题过去不是没人知道,只是一直能跑。

老系统里最常见的债就是这样:不是不想还,是它一直没把桌子掀了。既然没掀桌,那就先让它继续坐着。

直到 5 月 7 日,外部模块扩容了 30%,QPS 也增加了 30%。

请求压力一上来,get_svr_info 的抖动就冒出来了。

大家一边盯着智研曲线,一边快速沟通回退方案。紧急取消扩容,把新增压力撤掉。监控指标很快恢复正常,成功率也拉了回来。

那口气才算松下来:年终奖保住了。

系统稳住以后,团队成员开始复盘。

接下来就该还技术债了。

我说:这个接口我知道,老代码一直有性能问题,主要是 ORM model 对象和 dict 构造的 CPU 开销。我们把它改成 values(),让数据库结果直接变成 dict,再减少字段查询,应该很快就能解决。

于是我写下需求单:优化 get_svr_info 接口性能。

这是故事开始的地方。

01

第一刀:把 ORM model 改成 values 直出

第一反应,是把老代码里最重的部分干掉。

以前是这样:

代码语言:javascript
复制
queryset = Server.objects.filter(...)
servers = [
    self.gen_server_info(server)
    for server in queryset
]

这里的 server 是 Django model 实例。

每个实例都要构造对象,每个字段都要通过属性访问拿出来。全量 10000+ 台设备时,这种写法就像在 CPU 上铺了一层地毯,走一步粘一下。

优化后改成这样:

代码语言:javascript
复制
rows = Server.objects.filter(...).values(*db_fields)
servers = [
    self.row_to_output(row)
    for row in rows
]

values() 返回的是 dict。

它为什么能降低 CPU 消耗?

简单说,Django 正常查询 model 时,会把数据库返回的每一行包装成一个 model 实例。这个过程不只是“拿数据”,还包括创建 Python 对象、填充字段、处理描述符、准备属性访问等步骤。

而 values() 不构造完整 model 实例,它直接返回普通 dict。我们只要字段值,不需要 model 上的各种能力,比如 save()、关系对象、字段描述符。既然用不上,就没必要付这笔 CPU 账。

这就像去仓库拿货。

model 实例是把货装进精美礼盒,再贴标签,再附说明书。

values() 是直接把货装进袋子递给你。

我们这个接口只是把服务器信息读出来返回,袋子就够了。

然后进一步可以得出结论:既然已经是 dict 了,为什么还要重新构造一个新 dict?

老逻辑是这样:

代码语言:javascript
复制
def gen_server_info(server):
    return {
        "ip": server.outer_ip,
        "lan_ip": server.inner_ip,
        "idc_name": server.idc_name,
        "isp_name": server.isp,
        "status": server.status,
        # 很多字段...
    }

这意味着每台设备都会创建一个新的 dict。

10000+ 台设备,就是 10000+ 个 dict。每个 dict 里几十个 key。CPU 和内存分配都不免费。

所以我把它改成尽量复用 values() 返回的 dict,原地改字段名、补常量、做少量转换:

代码语言:javascript
复制
def row_to_output(row):
    if "outer_ip" in row:
        row["ip"] = row.pop("outer_ip")
    if "inner_ip" in row:
        row["lan_ip"] = row.pop("inner_ip")
    if "isp" in row:
        row["isp_name"] = row.pop("isp")
    return row

这就是当时最有信心的点:让数据库直接吐 dict,Python 少造对象,少搬字段,少做无意义劳动。

除了这个,我还做了字段裁剪。

以前调用方哪怕只要 ip,数据库也可能先把一堆字段都查出来,再由 Python 过滤。优化后先根据 fields 反推真正需要的数据库字段:

代码语言:javascript
复制
OUTPUT_TO_DB = {
    "ip": "outer_ip",
    "lan_ip": "inner_ip",
    "idc_name": "idc_name",
    "idc_id": "idc_name_id",
    "isp_name": "isp",
    "status": "status",
}
def resolve_fields(fields):
    output_fields = {"ip", "lan_ip", "idc_name", "isp_name", "status"}
    db_fields = set()
    for field in output_fields:
        db_field = OUTPUT_TO_DB.get(field)
        if db_field:
            db_fields.add(db_field)
    return output_fields, db_fields

到这里,我的心态基本是:稳了。

老 ORM 问题修了,dict 构造也省了,字段也少查了。

这不得起飞?

02

第一盆冷水:只快了 1.12 倍

为了避免“我觉得很快”的玄学,我写了一个对比脚本。

脚本做两件事。

第一,用现网真实的字段组合请求优化前和优化后的逻辑,每组跑多次,记录平均耗时。

第二,对返回数据做一致性校验。因为这是给外部模块用的老接口,优化可以快,但不能改返回。

结果第一眼还行:

代码语言:javascript
复制
14 ok / 14 total
total field diffs = 0

说明返回数据没变。

第二眼就不太行:

代码语言:javascript
复制
overall speedup = 1.12x

只快了 1.12 倍。

当场沉默。

更麻烦的是,越大的响应越没效果。

小平台、小字段,约 75KB,能有 1.34x。

小平台、全字段,约 700KB,反而只有 0.94x。

全量场景、全字段,约 7MB,只有 1.03x。

如果 ORM 和 dict 构造是最大瓶颈,数据越多,优化应该越明显。

但实测刚好相反。

响应越大,优化越没用。

这就是第一次打脸。

03

第二轮怀疑:是不是 JSON 太大?

既然 7MB 响应很大,我开始怀疑 JSON 序列化。

Python 标准库自带 json.dumps,稳定、通用、兼容性好。但它不是为极限性能设计的。

ujson 是 UltraJSON 的简称,是一个更快的 JSON 编解码库,底层用 C 实现,在大 JSON 场景下通常比标准库更快。它很适合这种“大量 dict/list 转 JSON 字符串”的接口。

于是我把主路径从标准库 json.dumps 换成 ujson.dumps:

代码语言:javascript
复制
try:
    from ujson import dumps as fast_dumps
except ImportError:
    fast_dumps = json.dumps

再跑。

整体 speedup 从 1.12x 到 1.23x。

有提升,但不多。

这说明 json.dumps 确实有成本,但不是那个 13 秒大洞。

就像你家漏水,你换了个更快的拖把,地是干了一点,但水管还在喷。

04

第三轮怀疑:是不是网络太慢?

7MB 响应,听起来很像网络问题。

这个项目的请求链路大概是:

代码语言:javascript
复制
client -> nginx -> uWSGI -> Django view

Django view 负责执行业务逻辑,uWSGI 负责承载 Python Web 应用,nginx 站在最前面做反向代理、连接管理和一些 HTTP 层能力。

既然响应体很大,我开始看 nginx 能不能帮忙。

JSON 很适合压缩,字段名重复,结构重复。于是我在 nginx 里打开 gzip,让 nginx 对接口响应做压缩:

代码语言:javascript
复制
location /get_svr_info {
    uwsgi_pass 127.0.0.1:8888;
    include uwsgi_params;
    gzip on;
    gzip_types application/json;
    gzip_proxied any;
    gzip_comp_level 5;
    gzip_min_length 1024;
}

gzip 确实生效。

7MB 响应压到了约 400KB,压缩比 17.5x。

但耗时不漂亮:

代码语言:javascript
复制
不压缩:
Size  7,007,734 B
TTFB  1.07s
Total 13.67s
gzip:
Size  400,155 B
TTFB  3.05s
Total 14.85s

压缩生效了。

接口更慢了。

这事非常反直觉。

如果真是网络慢,7MB 变 400KB,不说飞起来,至少应该明显变快。结果它不仅没快,还更慢。

这时候我意识到:错了,我一直在错的地方挖坑。

05

第四轮证据:本机回环也只有 573KB/s

要判断是不是网络问题,最简单的办法是让请求在服务端本机发起。

也就是直接在预发布机器上 curl 自己,让流量走 lo 回环网卡。

命令大概这样:

代码语言:javascript
复制
curl -s -o /dev/null \
  -H "Accept-Encoding: identity" \
  -w "TTFB: %{time_starttransfer}s | Total: %{time_total}s | Speed: %{speed_download} B/s\n" \
  "$GET_SVR_INFO_URL"

输出非常关键:

代码语言:javascript
复制
TTFB: 0.995s
Total: 12.221s
Size: 7007734 bytes
Speed: 573431 B/s

本机回环,只有 573KB/s。

这就不对了。

lo 网卡不是小水管,它通常是 GB/s 级别。外部机器请求大概 512KB/s,本机请求大概 573KB/s,两者几乎一个量级。

这说明:慢不在外部网络,慢在服务端把响应体吐出来的过程。

到这里,“网络慢”“带宽不够”“gzip 能救”这些猜想基本都可以先放下。

06

TTFB 把真凶圈出来了

这次最关键的数字不是 Total,而是 TTFB。

TTFB 是从发起请求到收到第一个字节的时间。

Total 是整个响应下载完成的时间。

Total 减去 TTFB,大致就是第一个字节之后,body 传完的时间。

7MB 场景里:

代码语言:javascript
复制
TTFB  ≈ 1.0s
Total ≈ 13.6s

这意味着 SQL、ORM、dict 转换、JSON 序列化,基本都在第一个 1 秒里完成了。

剩下 12 秒多,不是在“生成 JSON”,而是在“把已经生成好的 JSON 发出去”。

于是问题变成:一个已经在内存里的 7MB 字符串,为什么要花 12 秒才能发完?

我重新盯着老代码最后一行:

代码语言:javascript
复制
eturn StreamingHttpResponse(json.dumps(result), content_type="application/json")

然后突然反应过来:

json.dumps(result) 是字符串。

StreamingHttpResponse 要的是可迭代对象。

而 Python 字符串,本身就是可迭代对象。

代码语言:javascript
复制
list(iter("hello"))
# ["h", "e", "l", "l", "o"]

真相浮出水面。

07

真凶:以为在流式输出,其实是假流式

旧代码用了 StreamingHttpResponse。

从名字看,它很像是在做流式输出。听起来很专业,也很适合大响应。

但这里的问题是:代码并没有真正分块生成数据。

它先把所有服务器数据查完,再把完整结果 json.dumps 成一个 7MB 字符串,最后把这个完整字符串传给 StreamingHttpResponse。

这不是真流式。

这是假流式。

Django 1.11 里的逻辑可以简化成这样:

代码语言:javascript
复制
class StreamingHttpResponse(HttpResponseBase):
    def _set_streaming_content(self, value):
        self._iterator = iter(value)
    @property
    def streaming_content(self):
        return map(self.make_bytes, self._iterator)

如果你传的是 list:

代码语言:javascript
复制
StreamingHttpResponse(["hello"])

它迭代一次,吐出 "hello"。

如果你传的是字符串:

代码语言:javascript
复制
StreamingHttpResponse("hello")

它迭代 5 次,分别吐出 "h"、"e"、"l"、"l"、"o"。

功能上完全正确。

客户端最后拿到的 body 还是 "hello"。

但性能上非常要命。

7MB JSON 大约 700 万个字符。StreamingHttpResponse(big_string) 会让 Django、uWSGI、nginx 这条链路处理接近 700 万个小 chunk。每个字符都要过一遍 make_bytes(),再交给后面的链路。

这就像你要搬一箱水,本来可以整箱抱走,结果系统特别认真地帮你一滴一滴递。

态度很好。

效率感人。

这也解释了所有反常现象。

TTFB 只有 1 秒,是因为 JSON 其实很快生成了。

Total 要 13 秒,是因为大量时间花在逐字符输出。

响应越大越慢,是因为字符越多,迭代次数越多。

gzip 反而更慢,是因为上游逐字符慢慢吐,nginx 还要额外做压缩。

本机回环也慢,是因为瓶颈在 Python/uWSGI 输出,不在物理网络。

到这里,我一开始的判断就变成了半对半错。

ORM 有问题,dict 构造也有问题。

但它们不是最大的雷。

最大的雷,是这行看起来很专业的 StreamingHttpResponse。

08

正确的 StreamingHttpResponse 应该怎么用

Django 官方文档对 StreamingHttpResponse 的定位很明确:它用于把响应内容以 iterator 的形式流式传给浏览器,适合大文件下载、持续生成内容、或者不想把全部内容一次性放进内存的场景。

官方文档里也强调,它和 HttpResponse 不同,使用的是 streaming_content,也就是一个可迭代内容。

文档链接:Django StreamingHttpResponse 官方文档

(https://docs.djangoproject.com/en/1.11/ref/request-response/#streaminghttpresponse)

真正适合 StreamingHttpResponse 的代码应该长这样:

代码语言:javascript
复制
def stream_servers():
    yield '{"servers":['
    first = True
    for batch in query_server_batches():
        for server in batch:
            if not first:
                yield ","
            first = False
            yield json.dumps(server)
    yield "]}";
return StreamingHttpResponse(
    stream_servers(),
    content_type="application/json",
)

这个例子里,数据是一边查、一边生成、一边 yield。

这才叫流式。

而我们的旧代码是:

代码语言:javascript
复制
big_json = json.dumps(result)
return StreamingHttpResponse(big_json, content_type="application/json")

它已经把完整 JSON 放进内存了,再交给 StreamingHttpResponse。

这不是流式输出。

这是把一个大字符串交给 Django,让 Django 按字符串的迭代规则逐字符输出。

09

修复:一对方括号,两个字符,快了8倍。

最小修复非常小:

代码语言:javascript
复制
# 错误:字符串会被逐字符迭代
return StreamingHttpResponse(big_json, content_type="application/json")
# 正确:list 只迭代一次
return StreamingHttpResponse([big_json], content_type="application/json")

这一对方括号,就是标题里的“两个字符”。

两种写法,客户端拿到的 body 完全一样。

区别只在服务端怎么输出。

StreamingHttpResponse("...7MB...") 会按字符迭代,约 700 万次输出。

StreamingHttpResponse(["...7MB..."]) 会按 list 迭代,1 次输出完整字符串。

最终修复时,我选择更直接的写法:

代码语言:javascript
复制
return HttpResponse(fast_dumps(result), content_type="application/json")

因为 get_svr_info 并不是真流式接口。

它本来就是先查完、组装完、序列化完,再一次性返回。既然没有真正分块生成,就没必要挂着 StreamingHttpResponse 的名头。

不要把一个完整大字符串塞进去假装 streaming。

这不是流式,这是逐字朗读。

10

最终结果:13.6 秒降到 1.7 秒

修复后,用测试脚本重新跑:

代码语言:javascript
复制
Aggregate: 14 ok / 14 total
overall speedup = 4.00x
total field diffs = 0

这组数据最有意思的地方,不是每个场景都变快,而是“越大的响应,提升越夸张”。

最大的一组,全量字段,响应体约 7.0MB。定位到假流式问题之前,优化版仍然要 13593ms;修复之后只要 1731ms。单看这一刀就是 7.85x;如果和原始版本比,是 8.21x。

另一组全量 8 字段,响应体约 2.6MB。耗时从 5400ms 降到 1049ms,提升 5.15x,相比原始版本提升 6.00x。

全量 7 字段,响应体约 2.3MB。耗时从 5047ms 降到 997ms,提升 5.06x,相比原始版本提升 5.97x。

再看小一点的响应,全量小字段,响应体约 662KB。耗时从 1986ms 降到 894ms,提升 2.22x,相比原始版本提升 3.52x。

小平台全量字段,响应体约 645KB。耗时从 1861ms 降到 674ms,提升 2.76x。

小平台只返回 ip,isp 这类小字段,响应体约 67KB。这个场景本来就不大,最终优化版是 614ms,相比原始版本提升 1.47x。

规律非常清楚:

响应越大,提升越明显。

这正好符合 StreamingHttpResponse(big_string) 的问题特征:性能损耗和字符数成正比。

核心变化可以概括成三类:

  1. StreamingHttpResponse(json.dumps(result)) 改成 HttpResponse(fast_dumps(result))。
  2. ORM model 实例迭代改为 .values(),减少对象构造和点属性访问。
  3. 根据 fields 裁剪 DB 字段,并尽量复用 values() 返回的 dict 原地输出。

这三类优化里,第一类贡献最大。

后两类也有价值,但它们更像是给发动机换了好机油;真正让车跑不起来的,是轮子上还拴着一根铁链,名字叫 StreamingHttpResponse(big_string)。

11

本次排查总结的经验

11.1 经验能提出假设,数据负责打脸

我一开始的经验判断不是完全错。

values() 有用。

字段裁剪有用。

减少 dict 构造也有用。

但这些都不是最大瓶颈。

如果只凭经验,我可能会继续在 ORM 上抠细节,甚至开始怀疑 MySQL、索引、网络、机器负载。

真正把方向扭过来的,是 TTFB 和本机回环测试。

11.2 TTFB 是 HTTP 接口性能排查的快刀

一行命令就能把时间切开:

代码语言:javascript
复制
curl -w "
TTFB:  %{time_starttransfer}s
Total: %{time_total}s
Size:  %{size_download} bytes
Speed: %{speed_download} B/s
" -o /dev/null "$URL"

如果 TTFB 接近 Total,重点看服务端处理,比如 SQL、RPC、序列化。

如果 TTFB 很小,但 Total 很大,重点看 body 输出、网络传输、代理缓冲、压缩。

这次 TTFB 1s / Total 13s,其实早就告诉我:SQL 和 ORM 不是最大头。

只是我一开始太相信自己了。

11.3 老代码最可怕的是“看起来没错”

这行代码错不明显:

代码语言:javascript
复制
return StreamingHttpResponse(json.dumps(result), content_type="application/json")

它不报错。

它返回正确。

它上线很多年。

它甚至看起来还挺高级,毕竟名字里有 Streaming,让你以为“我们已经对大量数据场景做过流式优化了”。

但它会把 7MB 字符串变成 700 万次小输出。

这类问题最危险,因为它不会立刻炸。它只是稳定地慢,一直慢到某天流量上来,把系统拖下水。

12

写在最后

这次最有意思的地方,是我一开始非常自信。

我知道这个接口有老问题:10000+ 台设备,一次查询,走 ORM,对象构造重,dict 转换也重。

我也确实把这些问题改了。

但真正让接口从 13.6 秒降到 1.7 秒的,是最后发现的那个隐藏炸弹:

代码语言:javascript
复制
StreamingHttpResponse(json.dumps(big_data))

它把一个完整大字符串,当成可迭代对象逐字符输出。

最终结果:

代码语言:javascript
复制
全量场景:13.6s -> 1.7s
最大场景提升:8.21x
整体平均提升:4.00x
数据一致性:14/14 MATCH

这次排查最有价值的地方,不是最后改对了哪一行代码。

而是它再次提醒我:性能问题不能靠资历投票,也不能靠直觉结案。

需求只是起点,经验只是猜想,数据才是答案。

再说得朴素一点:

拍胸脯之前,先跑 curl -w。

-End-

原创作者|成帅涵

感谢你读到这里,不如关注一下?👇

你对本文内容有哪些看法?同意、反对、困惑的地方是?欢迎留言,我们将邀请作者针对性回复你的评论,欢迎评论留言补充。我们将选取1则优质的评论,送出腾讯云定制文件袋套装1个(见下图)。6月12日中午12点开奖。

扫码领取腾讯云开发者专属服务器代金券!

图片
图片
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 腾讯云开发者 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 01
  • 02
  • 03
  • 04
  • 05
  • 06
  • 07
  • 08
  • 09
  • 10
  • 11
  • 12
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档