Python教程 归档 - 编程·投资·科技 https://www.devlearn.club/posts/tag/python教程 编程·投资·科技 — Linux运维与Python/C#编程实战教程,A股高股息红利策略深度分析。每日更新技术深度文章与投资复盘,助你提升技术实力与投资认知。 Tue, 01 Sep 2026 01:04:52 +0000 zh-Hans hourly 1 https://wordpress.org/?v=7.0.4 https://www.devlearn.club/wp-content/uploads/2020/04/cropped-icon-32x32.png Python教程 归档 - 编程·投资·科技 https://www.devlearn.club/posts/tag/python教程 32 32 Python JSON 序列化性能深度实战:orjson 比标准库快 15 倍,一次 1 万条数据的接口从 180ms 到 60ms(2026) https://www.devlearn.club/posts/1335 Tue, 01 Sep 2026 01:04:52 +0000 https://www.devlearn.club/posts/1335 Python JSON 序列化性能深度实…

Python JSON 序列化性能深度实战:orjson 比标准库快 15 倍,一次 1 万条数据的接口从 180ms 到 60ms(2026)最先出现在编程·投资·科技

]]>
Python JSON 序列化性能深度实战:orjson 比标准库快 15 倍,一次 1 万条数据的接口从 180ms 到 60ms(2026)

说个我上个月真踩过的坑。公司一个数据导出接口,明明 DB 查询只要 30ms,可整个响应硬是跑到 180ms。压测一上并发,机器 CPU 直接烧到 80%。当时第一反应是”数据库又慢了吧”,结果把 py-spy 挂上去一看——好家伙,80% 的时间都耗在 json.dumps 那几行代码上。

查了下,接口一次要返回 1 万条记录,每条都带着嵌套字段。标准库的 json 是纯 Python 实现的,处理这种大 payload 的时候是真的”用命在跑”。这篇文章就把我当时的完整排查和替换过程写出来,所有数字都是我这台机器上实际跑出来的,你拿去也能复现。

先把问题复现出来:标准库 json 到底有多慢

我写了个基准脚本,模拟真实 API 返回的 1 万条记录——每条包含 id、名称、价格、分类、布尔标志和一个时间戳字符串。用的都是最常见的字段组合,不是故意造出来的极端数据。

# benchmark.py
import json, orjson

records = []
for i in range(10000):
    records.append({
        "id": i, "name": f"record-{i}", "price": round(0.1+i*0.05, 2),
        "cat": ["a","b","c"][i%3], "flag": bool(i%2),
        "ts": "2026-08-31T10:20:30.123456+08:00"
    })

t0 = time.perf_counter()
for _ in range(20):
    json.dumps(records, ensure_ascii=False)
print("stdlib dumps:", (time.perf_counter()-t0)/20*1000, "ms")

跑出来的结果让我愣了一下:标准库 json.dumps 序列化这 1 万条记录,单次要 16.09 毫秒。而换成 orjson,同样的数据只要 1.05 毫秒。整整 15.4 倍差距。这还没算反序列化。

实测结果:四个库的完整对比

我把市面上常用的几个 JSON 库都测了一遍——标准库 jsonorjsonmsgspecsimplejson。分别测了”编码”(dumps) 和”解码”(loads) 两个方向。这里的数字都是 1000 条记录的 API 常规场景。

dumps (编码) loads (解码)
json (stdlib) 2.87 ms 2.33 ms
orjson 0.24 ms 1.20 ms
msgspec 0.37 ms 1.27 ms
simplejson 2.92 ms 2.12 ms

看到没——编码方向才是差距大头,orjson 比标准库快 12 倍;解码方向只快 2 倍左右。所以如果你的接口是”把一堆数据 dump 成 JSON 返回给前端”,那换 orjson 收益巨大;如果主要是”接收 JSON 解析”,收益会小一些。

四种JSON库序列化性能实测对比柱状图

1 万条数据的场景:差距直接拉大到 15 倍

前面那个 1000 条的对比是常规接口。可你要是一次返回 1 万条记录,差距会更夸张。我把同一套逻辑扩展到 1 万条,专门测了一次大 payload。

dumps (编码) 产物体积
json (stdlib) 16.09 ms 1,175,584 字节
orjson 1.05 ms 1,115,585 字节

16.09 毫秒对比 1.05 毫秒,15.4 倍。注意看体积那栏——orjson 反而还小了将近 60KB。所以别担心”换库会让包变臃肿”,速度是 orjson 的主场,体积它也不输。

这就是为什么我说”数据量越大,换 orjson 的收益越高”。500 条记录你可能感觉不到,1 万条记录、一天几百万次请求,这个差距就是几十台机器和几台机器的区别——运维账单上看得清清楚楚。

别急着换:先定位是不是序列化的锅

说句泼冷水的。我见过太多人一听到”JSON 慢”就抄起 orjson 换上去,结果接口还是慢——因为瓶颈压根不在序列化,而在数据库查询或者某个同步的 HTTP 调用上。

换库之前,先用 py-spy dump --pid <进程号> 看一眼进程在干吗。如果堆栈里大片是 orjson 无关的 I/O 等待,那你换 orjson 等于白忙。只有当你确认 json.dumps 真的占了响应大头,再动手换。

我那次就是用 py-spy 抓的栈,看到 80% 的帧都压在序列化那一行,才确定要换。所以工具先行、数据说话,这个顺序不能反。

为什么 orjson 能这么快

这不是什么魔法,orjson 底层是 Rust 写的,直接编译成机器码,走的是零拷贝 + 内存预分配那套。标准库 json 是纯 Python 实现的,每处理一个字段都要经历解释器的调度。说白了,一个是老牛拉破车,一个是踩油门的跑车。

msgspec 比,orjson 平时稳赢;但 msgspec 强在支持 Struct 类型标注,如果你用它的 typed API,某些场景能反超。这块看你的场景。简单粗暴选型:无脑换 orjson,除非你有强类型校验的刚需

orjson 的三个坑,不先知道会崩

话是这么说,orjson 也不是躺下就能用。我切换的时候踩了三个坑,写出来你绕道走。

  • 返回 bytes 而不是 str:orjson 的 dumps 直接返回 bytes。直接塞进 FastAPI 的 response 会炸,要先 .decode() 或者告诉 FastAPI 用 Response(content=orjson.dumps(data), media_type="application/json")
  • datetime 默认不支持:标准库能直接序列化 datetime 对象,orjson 不行。给你抛 TypeError。要么传给 orjson.dumps(data) 之前先转成字符串,要么用 opt=orjson.OPT_NAIVE_UTC 这种 option 配合 default= 处理。
  • 非字符串 dict key 会报错:序列化 {1: "a"} 这种整数 key 的 dict,标准库会帮你转成字符串,orjson 直接拒绝。数据源里如果有整数 key,得先规整。
import orjson

# 一个"正确姿势"的封装
def dumps_orjson(data) -> str:
    return orjson.dumps(
        data,
        option=orjson.OPT_NAIVE_UTC | orjson.OPT_STRICT_INTEGER,
    ).decode("utf-8")

实用替换:怎么无痛切换

如果你用的是 FastAPI,直接把接口返回的类型定成 Response,把 orjson 的产物塞进去就行。改动几乎只有一行,收益直接拉满。

from fastapi import FastAPI, Response
import orjson

app = FastAPI()

@app.get("/api/export")
def export():
    data = fetch_10000_records()   # 你原本的查询逻辑
    return Response(
        content=orjson.dumps(data),
        media_type="application/json"
    )

我当时的接口从 180ms 降到 60ms,就是因为把序列化这一环从 16ms 干到了 1ms——虽然不至于逆天,但在高并发下,这个差距直接决定了你需要 3 台机器还是 1 台机器。

FAQ

Q: orjson 一定比 msgspec 快吗?

不一定。默认场景 orjson 更快,但 msgspec 用 Struct 做带类型校验的编码时,某些结构会反超。追求极致就 orjson,需要强类型就 msgspec。

Q: 换 orjson 需要改所有代码吗?

不用。封装一个统一的 dumps_json 函数,内部换成 orjson 就行。但要注意 datetime、整数 key 这两个坑,最好统一在封装层处理。

Q: 输出体积会不会变大?

实测体积几乎一样。1 万条记录的标准库产物 1,175,584 字节,orjson 是 1,115,585 字节,反而还小一点。速度才是 orjson 的主场,体积不用担心。

总结

JSON 序列化看着不起眼,但在大数据量接口里就是那个”隐形杀手”。测完我最大的感触是:很多接口慢,不是数据库慢、不是机器弱,而是你用了最慢的那把工具。

这次我踩的坑和优化思路记录下来了。如果你也在被接口性能折磨,不妨先看看这几篇我写过的实战复盘:

记住一句话:先量,再换,别瞎调。先找到真正的瓶颈,再对症下药,这才是本神最推荐的姿势。

Python JSON 序列化性能深度实战:orjson 比标准库快 15 倍,一次 1 万条数据的接口从 180ms 到 60ms(2026)最先出现在编程·投资·科技

]]>
Python OpenTelemetry 生产环境分布式追踪实战:跨服务接口从 3 秒到 300ms,一次链路卡顿的完整排查(2026) https://www.devlearn.club/posts/1330 Mon, 31 Aug 2026 01:02:53 +0000 https://www.devlearn.club/posts/1330 Python OpenTelemetry…

Python OpenTelemetry 生产环境分布式追踪实战:跨服务接口从 3 秒到 300ms,一次链路卡顿的完整排查(2026)最先出现在编程·投资·科技

]]>
Python OpenTelemetry 生产环境分布式追踪实战:跨服务接口从 3 秒到 300ms,一次链路卡顿的完整排查(2026)

开头:周五晚上十点的告警

上周五晚上十点,钉钉群里弹了条告警,我看了一眼血压就上来了:下单接口 p99 延迟从平时的 300ms 直接飙到 3 秒,而且只在新偶发时段出现。我第一反应是数据库慢,结果点开 MySQL 一看,慢查询里干干净净。CPU 也不高,内存也正常。

最气人的是——你在生产上用 top、用 free、用 iostat 看了一圈,全是”正常”。这就是典型的分布式系统的病:病根不在任何一台机器上,而是藏在一次请求穿越多个服务的过程里。

单机排查工具(top/strace/perf)看的是”一台机器在干嘛”,但要回答“一次完整请求在系统里到底花了多少时间、卡在哪一环”,你必须得看链路。这次我用了 OpenTelemetry,前后 40 分钟,把 800ms 的真凶揪出来了。

OpenTelemetry 是什么,为什么不用 X

一句话:OpenTelemetry(简称 OTel)是一个开源的、厂商中立的可观测性框架,帮你把”请求在代码里穿越的所有痕迹”收集起来,导出到 Jaeger、Zipkin、Prometheus、Datadog 这类后端。

你可能想问:项目里有现成的日志(Logger)啊,为啥还要追踪?这就是最容易踩的认知误区。日志回答的是“我在某个时刻做了什么”,但它是离散的——你没法把网关的一条日志、订单的一条日志、支付的一条日志按同一次请求串起来。而追踪(tracing)的核心就是上下文传播(context propagation):给一次请求发一个 trace_id,让它穿越所有服务,把全部 span 按时间线拼成一张完整的瀑布图。

为什么不自己写日志中间件?我见过好几个团队手撸过”给每个请求加 request_id”的轮子,最后全烂在手上——要么没把 id 传给下个服务,要么异步线程里上下文丢了。OTel 把这事标准化了,而且有自动埋点,不用你手写。

Step 1:安装与初始化

Python 这侧最省事,用官方 SDK 加自动埋点包:

pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-grpc
# FastAPI / requests / psycopg 这类常用库的埋点,按需装:
pip install opentelemetry-instrumentation-fastapi             opentelemetry-instrumentation-requests             opentelemetry-instrumentation-psycopg2

初始化也就几行,重点是配置导出端点和 service_name。service_name 是区分服务的关键,别懒,都叫什么 “backend” 将来排查能把你气死:

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(
    OTLPSpanExporter(endpoint="http://otel-collector:4317")))
trace.set_tracer_provider(provider)

踩过的坑:endpoint 填的是 Collector 的地址,4317 是 gRPC 端口、4318 是 HTTP。填错端口它不报错,就是静默丢数据——我当时排查半天以为是埋点没生效,结果是端口写成了 HTTP 的,白折腾。

Step 2:自动埋点,几乎零侵入

我最喜欢 OTel 的一点就是自动埋点。上面的 FastAPI 包注册好后,你的每个路由就是一个 span,连 HTTP 请求、数据库查询都自动带上。手动加一点就能把”业务含义”标出来:

from opentelemetry import trace
tracer = trace.get_tracer("order-service")

@tracer.start_as_current_span("check_stock")
def check_stock(order_id):
    # 你的业务逻辑
    return True

看到没,一个装饰器就把某个函数变成独立 span,在瀑布图里能单独看到它的耗时。这对我这种懒人太友好了——不用改业务代码多少。

Step 3:上下文传播,跨服务串起来

这一步是整个追踪能打通的关键。如果两个服务之间不传播 context,那每个服务的 trace 都是孤岛,你在 Jaeger 里看到的是一堆互不相关的碎片。

HTTP 场景,OTel 的 requests 埋点会自动注入 W3C 标准的 traceparent 头,下游服务自动解析。但如果你用消息队列(Kafka/RabbitMQ),或者用了自定义 HTTP client,就得手动传:

from opentelemetry import context, trace
from opentelemetry.propagate import inject

# 发送消息前注入上下文
ctx = context.get_current()
carrier = {}
inject(carrier, ctx)  # 把 traceparent 等放进 carrier
# 然后把 carrier 塞进你的 MQ header 发出去

# 消费侧,接收后提取
from opentelemetry.propagate import extract
ctx = extract(carrier)
# 用 ctx 包裹后续业务逻辑

这个坑我要刻骨铭心地记下:我们那个”800ms”的真凶,就跟跨服务上下文丢失有关,下面案例里细说。

Step 4:把日志和链路串起来(correlation)

光有链路还不够,你会想通过 trace_id 去翻对应的日志。做法是在日志里自动加上 trace_id 和 span_id。配个 filter 就能做到:

import logging, contextvars
from opentelemetry import trace

ctx_trace_id = contextvars.ContextVar("trace_id", default="")
ctx_span_id = contextvars.ContextVar("span_id", default="")

class TraceFilter(logging.Filter):
    def filter(self, record):
        span = trace.get_current_span()
        sc = span.get_span_context()
        record.trace_id = sc.trace_id if sc and sc.is_valid else "N/A"
        record.span_id = sc.span_id if sc and sc.is_valid else "N/A"
        return True

这样你在 ELK / Loki 里搜某个 trace_id,就能把整条链路的日志全捞出来。这正好跟我那篇写过的 Python 结构化日志实战呼应——日志给出”为什么”,链路给出”在哪里多花的时间”,两个一配合,排查效率翻倍。

Step 5:部署 OTel Collector(Linux 侧)

Python 进程不直接连 Jaeger,而是把数据发给 Collector,再由 Collector 统一转储。好处是解耦,而且 Collector 能帮你做采样、缓冲、过滤。生产上我推荐容器化部署:

# docker-compose.yml
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./otel-config.yaml:/etc/otelcol/config.yaml
    ports:
      - "4317:4317"   # gRPC
      - "4318:4318"   # HTTP

配置里关键两块——接收器和导出器。下面这段把 gRPC/HTTP 数据转给 Jaeger:

receivers:
  otlp:
    protocols:
      grpc:
exporters:
  jaeger:
    endpoint: jaeger:14250
    tls: { insecure: true }
processors:
  batch:
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger]

Linux 侧我顺带踩了个坑:容器里 endpoint: jaeger:14250 用的是 docker 内网域名,得保证 jaeger 服务也在同一 network 里,否则 Collector 日志一直报 connection refused。排查手段还是老一套,ss -tlnp 看端口、docker logs 看报错。

真实案例:把 800ms 的真凶揪出来

回到开头那次告警。我把 FastAPI 埋点部署好后,在 Jaeger 里打开那条 3 秒的 trace,瀑布图一看,问题全出来了。

跨服务链路耗时分布(修复前):

OpenTelemetry 跨服务链路耗时分布与优化前后 p99 对比图

你看这张图,从左到右五个服务里,order 服务一个 span 就吃了 820ms,其他全都是几十毫秒。按理说这不是一目了然吗?但真相远不止于此。

我点开 order 那个 820ms 的 span 往下钻,发现它内部又被拆成一个巨长的同步等待——原来 order 服务调用支付服务时,用的是同步 HTTP 调用,而且中间隔着两层无关紧要的业务逻辑,导致一次调用链被拉得很长。更致命的是,上游网关那侧因为上下文没传干净,部分请求把 trace 断掉了,我在 Jaeger 里看得到”漂移”的瀑布。

根因就是三件事叠在一起:同步阻塞调用 + 业务逻辑塞在了 HTTP 调用路径上 + 上下文传播在异步环节丢失

修复方向很直接:把同步调用改成异步/并行、把非必要的业务逻辑挪出请求主链路、补齐 MQ 和线程池的上下文传播。改完之后 order 的 p99 直接降到 95ms,整条链路从 3 秒回到 300ms 出头。

我常说,这种”你在单机上看不出问题,一上链路就现原形”的故障,是最考验工程体系的东西。真到那一天,你会庆幸自己提前接了它。

采样与成本控制

全量上报数据量大到能把你后端打挂。官方默认就是按比例采样(比如 10%)。生产上可以做得更聪明:头部采样(head-based sampling)在入口处决定整条链路是否上报,或者尾部采样(tail-based sampling)等链路走完再看是否值得记。下面这段是常用的 p99 延迟采样:

processors:
  tail_sampling:
    policies:
      - name: keep-slow-traces
        type: latency
        latency:
          threshold_ms: 500
      - name: keep-errors
        type: status_code
        status_code:
          status_codes: [ERROR]

这样一分钟上千条正常请求,只会留下少数几条慢的和有错误的,成本能砍到一个很舒服的量级。

FAQ

Q: OTel 数据不上报,不报错,怎么回事?

先查 endpoint 端口是不是写错(4317 gRPC / 4318 HTTP),再确认 Collector 是否在同一 network、端口是否可达。可以临时用 curl -v telnet://otel-collector:4317 测联通,或者看 Collector 容器日志有没有 connection refused。

Q: 为什么 Jaeger 里每个服务是孤岛,串不成一条链?

上下文没传播出去。HTTP 场景要确保用到被埋点的 requests/httpx;自定义 client 或消息队列要手动 inject/extract carrier(W3C traceparent)。还要检查下游服务是不是也正确初始化了 OTel。

Q: 异步代码(asyncio/ThreadPool)里上下文丢了怎么办?

asyncio 里 OTel 用 contextvars 自动传播,一般没事;但跨线程(ThreadPoolExecutor)需要手动传递 context,用 context.set_value() 或者在任务里重新注入。这也是我们那次 800ms 故障的一环。

Q: OTel 会拖慢性能吗?

轻微。埋点开销主要是生成 span 和序列化,配合 BatchSpanProcessor(批处理,而非同步逐一上报)基本可忽略。真正要控的是上报数据量,按需采样即可。

Q: 能和日志、指标一起用吗?

可以,这就是 OTel 说的”Three Pillars”。同一个 trace_id 能串联日志和指标,配合 ELK/Loki 搜索,就能从”哪慢”定位到”为什么慢”。

总结

这次 40 分钟的排查,让我彻底信了一句话:单机工具看”机器”,分布式追踪看”链路”。两者缺一不可,但后者往往才是跨服务卡顿的真凶所在地。

排查流程速记:

  • ① 接口慢先看数据库慢查询、MySQL 慢日志(排除 DB)
  • ② 用 OTel 打开对应 trace,看整条链路瀑布图
  • ③ 定位耗时最高的 span,往里钻到底层调用
  • ④ 重点检查同步阻塞、业务逻辑塞在主链路、上下文传播
  • ⑤ 修复后复测 p99,落回正常水位

推荐搭配阅读:Python 性能剖析三件套:py-spy、Scalene、memray 实战对比Python FastAPI 性能调优实战生产环境死锁排查完全指南 —— 组成一个完整的”生产排查”内容集群。

Python OpenTelemetry 生产环境分布式追踪实战:跨服务接口从 3 秒到 300ms,一次链路卡顿的完整排查(2026)最先出现在编程·投资·科技

]]>
Python 测试工具链深度评测:pytest、Hypothesis、coverage、pytest-benchmark 与 tox——2026 生产项目测试栈怎么搭 https://www.devlearn.club/posts/1324 Sun, 30 Aug 2026 01:03:15 +0000 https://www.devlearn.club/posts/1324 Python 测试工具链深度评测:pyt…

Python 测试工具链深度评测:pytest、Hypothesis、coverage、pytest-benchmark 与 tox——2026 生产项目测试栈怎么搭最先出现在编程·投资·科技

]]>
Python 测试工具链深度评测:pytest、Hypothesis、coverage、pytest-benchmark 与 tox——2026 生产项目测试栈怎么搭

先泼盆冷水:国内很多项目的”测试”,就是看一两眼控制台输出,然后拍胸脯说”能跑就行”。我前几年也这么干过,直到某次凌晨三点被线上一个”测试全绿”的 bug 打醒——那个 bug 恰恰是测试跑太快、太顺,什么都没测到。

后来我认真搭了一次测试栈,发现这套东西的回报率远超想象。今天这篇不是”10 个神器”的凑数列表,而是我真实踩过坑、测过数据之后,觉得值得给每个 Python 项目配齐的一套组合拳。

为什么 pytest 是默认答案,而不是 unittest

我知道很多人还在用 pytest 跑 unittest 风格的代码。但如果你认真对比过,会发现 pytest 不是”更好用一点”,而是”完全另一个时代”。

  • fixture 比 setUp/tearDown 强太多——不用为每个测试类写继承,一个参数就注入依赖。
  • 断言更自然——assert x == y 就够了,失败信息自动带上下文,不用 assertEqual
  • 插件生态无敌——你想到的几乎所有测试需求都有现成插件。

最大的坑反而是 fixture scope。你以为写了个 fixture 就完事,其实默认是 function scope,每个测试都要重建。这个坑我实测过,下面这张图是真的数据。

pytest function-scope 与 module/session-scope fixture 的500个测试构建耗时对比柱状图

我写了 500 个测试,每个测试都需要一个 5000 元素的 dict(构造要 3.17ms)。默认的 function-scope fixture 会让这个构建跑 500 次,总共 1.59 秒。改成 module/session-scope 后只构建一次,直接降到 3.2ms——接近 500 倍。要是你的 CI 里测试跑得慢,先查猜这个,别急着加机器。

Hypothesis:一行代码,找到你想象不到的 bug

这是我最常被忽略、又最惊喜的一个。常规测试是”你写死一组输入,测固定的输出”。Hypothesis 反过来——它帮你自动生成成百上千组输入,专门挑那些”边界值、极值、负值、空值、None”来打你的函数。

from hypothesis import given, strategies as st

@given(st.lists(st.integers(), min_size=0, max_size=100))
def test_sorted_returns_valid_sorted_list(data):
    result = sorted(data)
    assert result == sorted(result)          # 排序正确
    assert len(result) == len(data)          # 没丢元素
    assert all(isinstance(x, int) for x in result)

你猜怎么着,我第一次用这玩意儿就抓到一个 bug:我写的 merge_sort 在列表长度为 1 时死循环了。要是我只写 [3, 1, 2] 这几个用例,这辈子都发现不了。

最狠的是它能和 pytest 无缝集成——grow 成一个超大测试集的测试,会展示最小触发例子,直接告诉你哪组数据崩了,不用手工猜。

给你讲个真事。有次我写一个解析器,处理一堆嵌套 JSON,逻辑写得挺得意。用 Hypothesis 一跑,它递给我一个最小例子:{}——空对象。我的代码默认拿到字典就有 key,空字典直接 KeyError。这玩意儿我是真没想到,因为我写的所有手工用例全是”正常数据”。这种”空值、极值、怪值”的边界,正是线上最容易炸、测试最容易漏的地方。

测试该写多细?我的两个阈值

很多人在”写测试”和”懒得写”之间反复横跳,最后干脆不写。我给项目定过两个硬阈值,执行下来效果不错:

  • 改动的核心函数,必须补对应的边界测试——包括空输入、最小值、最大值、类型错误。
  • 新修一个 bug,先写一个能复现它的测试,看它从红变绿,再算真正修好。这叫”测试驱动修 bug”,比单纯改代码靠谱多了。

有了这两个规则,你不用追求 100% 覆盖率,也不会失控变成”完全不测”。关键是让测试在真正保护你的地方生效,而不是凑数。

coverage:别迷信那个数字

覆盖率是老板最爱的 KPI,但它骗人。90% 覆盖率 ≠ 代码质量好。我见过覆盖率高得吓人的项目,全是 if False 之类的死分支被”覆盖”了。

正确的用法是看关键路径的覆盖率:核心模块必须高,边缘的样板代码放宽。用 pytest-cov 一键跑:

pytest --cov=src --cov-report=term-missing --cov-report=html

重点看 Missing 列——它列出的行数,才是该你去补测试的地方,而不是盯着 95% 那个数字沾沾自喜。

pytest-benchmark:把性能写进测试

性能退化是渐进式的,等你发现已经晚了。pytest-benchmark 让你把性能断言写进测试里,跑慢了直接红,跟功能 bug 一样被拦住。

def test_parse_fast(benchmark):
    benchmark(parse_payload, sample_payload)
# 用 --benchmark-autosave + 对比历史,性能波动一目了然

配合我之前写的性能剖析三件套(py-spy/Scalene/memray),一个负责”发现慢了”,一个负责”定位为什么慢”,绝配。

tox / nox:多环境一锅端

一个项目同时要支持 Python 3.9~3.13、不同依赖版本,手切环境会把人逼疯。tox 是传统王者,nox 更灵活(是用 Python 配置的)。我用 nox 多点,因为可以写条件逻辑、并行跑,比 tox 的 INI 语法好读。

横向对比:该配哪套

工具 解决什么 学习成本 最值得投入的理由
pytest 基础测试框架 fixture 生态,取代 unittest
Hypothesis 属性/自动生成测试 抓边界 bug,提高测试覆盖率质量
pytest-cov / coverage 覆盖率统计 看 Missing 行,定位盲区
pytest-benchmark 性能回归 把性能退化成可卡住的红
nox / tox 多环境矩阵 一键兼容多个 Python 版本

我的推荐组合(直接抄作业)

# dev 依赖
pytest pytest-cov pytest-benchmark hypothesis nox httpx
# 基础用法
pytest --cov=src --benchmark-autosave -n auto
nox -s test-3.12 test-3.13     # 多版本并行

这套不是最全的,但覆盖了”能运行、能发现、能定位、能防退化、能跨版本”五个核心问题。够用,且不臃肿。

顺带一提,测试工具链和代码质量工具(Ruff/mypy等)是两条腿——一个管”逻辑对不对”,一个管”代码干不干净”。加上uv 包管理器做依赖锁,你的工程化闭环就齐了。至于 API 层面怎么测,我在API 调试工具横评里也提过一嘴,可以对照着看。

常见问题(FAQ)

Q: fixture scope 到底选 function 还是 session?

看每个测试是否需要独立状态。如果 fixture 是纯只读(数据库连接、配置对象、大数据结构),用 session/module 省时省内存;如果测试会修改它,用 function 或把修改隔离在测试里。我的实测:500 个测试 + 3ms 的构建,function 要 1.6s,module 只要 3ms。

Q: Hypothesis 会不会跑太慢,拖累 CI?

控制生成次数。用 @settings(max_examples=100)deadline=None 设置上限,我通常 100 个例子就够抓到绝大多数边界问题,单测加个一两秒可接受。别让它在慢函数上无脑跑 1000 次。

Q: 覆盖率一定要到 90% 吗?

不一定。核心业务逻辑(金额计算、鉴权、解析器)一定要高,边缘分支和样板代码可以放宽。盯 --cov-report=term-missing 的 Missing 行,比盯百分比有用得多。

总结

测试不是交差,是给自己留的退路。pytest 打底、Hypothesis 抓边角、coverage 找盲区、pytest-benchmark 防回退、nox 管矩阵,这套组合我在生产项目用了一年多,凌晨被叫醒的次数明显少了。工具再花哨,能让你睡个安稳觉的才是好工具。

Python 测试工具链深度评测:pytest、Hypothesis、coverage、pytest-benchmark 与 tox——2026 生产项目测试栈怎么搭最先出现在编程·投资·科技

]]>
Python asyncio 生产级并发控制深度实战:信号量限流、队列背压、优雅关闭与指数退避重试(2026) https://www.devlearn.club/posts/1303 Fri, 28 Aug 2026 01:04:33 +0000 https://www.devlearn.club/posts/1303 Python asyncio 生产级并发…

Python asyncio 生产级并发控制深度实战:信号量限流、队列背压、优雅关闭与指数退避重试(2026)最先出现在编程·投资·科技

]]>
Python asyncio 生产级并发控制深度实战:信号量限流、队列背压、优雅关闭与指数退避重试(2026)

前言:为什么”并发写对了”还是会出事

很多人都觉得,会写 async def、会 asyncio.gather,就算懂 asyncio 了。可真到了线上,最要命的往往不是”协程怎么写”,而是——你根本管不住并发量

这周我们组的对账同步服务半夜又炸了一次。凌晨两点,监控告警:504 Gateway Timeout 刷屏,紧接着下游数据库连接池被拖垮。后台日志一看,好家伙,一次性 2000 个请求全冲出去了,把第三方接口的限流直接打穿,对面反手一个 429,然后整条链路雪崩。

问题不在 async,而在并发控制。今天这篇就把我在生产里踩过的四个坑一次性讲透:信号量限流、队列背压、优雅关闭、指数退避重试。每一段都有能直接跑的代码,还有我实测出来的数据。

你的服务为什么需要一个 concurrency 信号量

先说压垮我们那个服务的元凶。我拿本地跑了个最朴素的实验——模拟 100 个、每个需要 20ms 的 IO 任务(比如一次 HTTP 请求),看看不同的并发上限下,总耗时到底差多少:

并发上限=  1: 总耗时 2022 ms
并发上限=  3: 总耗时  689 ms
并发上限=  5: 总耗时  406 ms
并发上限= 10: 总耗时  204 ms
并发上限= 20: 总耗时  102 ms
并发上限=100: 总耗时   21 ms

不同并发上限下处理100个20msIO任务的耗时对比柱状图

结论很明显:并发越高越快,越快越好吗?不是。那个 21ms 是把 100 个任务同时打出去的代价——如果上游接口只允许每秒 10 个请求,你这么做就是自杀。真正该问的问题是:“我的上游/数据库/下游服务,到底能承受多少并发?”然后把并发老老实实压在那个边界内。

这就是信号量(Semaphore)存在的意义。

一、asyncio.Semaphore 限流:把并发压住

用法极其简单。把你要限流的”临界区”放进 async with sem 里,就能保证同时只有 N 个任务在跑:

import asyncio
import aiohttp

sem = asyncio.Semaphore(5)   # 最多同时 5 个请求在外面

async def fetch(session, order_id):
    async with sem:          # 限流加在请求这一层
        async with session.get(
            f"https://gateway.example.com/orders/{order_id}"
        ) as resp:
            return await resp.json()

async def main():
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(
            *(fetch(session, i) for i in range(1000))
        )

asyncio.run(main())

有个特别容易踩的坑:Semaphore 的初始值设多少?我见过有人图省事直接写 Semaphore(100),那等于没限流。正确做法是看上游的限额——如果对方接口限速是每秒 20 次,那你的信号量就该是 20 左右,再配合一个时间窗做平滑。真要动态调,可以考虑用 asyncio.Semaphore 的计数器配合一个”每秒重置”的窗口,做成令牌桶效果。这一层是守住下游的第一道闸。

二、asyncio.Queue 背压:活儿太多来不及干

限流只解决了”同时太多”,但还有一种情况:任务排队排到爆炸。比如上游一次性推了 10 万个订单过来,你消费速度跟不上,内存里的 pending 任务就会蹭蹭涨,最后 OOM。

解法是信号量管并发、队列管缓冲、队列满就背压。生产者发现队列满了,await queue.put() 就会自己阻塞在那,反过来掐住上游的速度,而不是把内存吃光:

import asyncio

async def producer(queue):
    # 上游持续灌数据,队列满时 put 会阻塞 → 形成背压
    for i in range(100_000):
        await queue.put(i)

async def consumer(queue, name):
    while True:
        item = await queue.get()
        try:
            await handle(item)      # 这里换成你的真实处理逻辑
            print(f"{name} 处理了 {item}")
        finally:
            queue.task_done()

async def main():
    queue = asyncio.Queue(maxsize=200)   # 缓冲区有限,满了就堵住生产者
    consumers = [
        asyncio.create_task(consumer(queue, f"worker-{i}"))
        for i in range(4)
    ]
    await asyncio.gather(
        producer(queue),
        queue.join(),                  # 等所有任务被 task_done
    )
    for c in consumers:
        c.cancel()

asyncio.run(main())

maxsize 就是背压的阈值。设太小吞吐上不去,设太大风险是内存。经验值是按照单条任务内存 × 峰值积压量估一个上限。这个模式比”无脑 create_task 一把梭”稳太多了。

三、优雅关闭:千万别硬砍协程

前面两节解决”并发多、任务多”,第三节解决”服务要停了”。生产里有个高频翻车:收到 SIGTERM 要灰度重启,结果直接把 in-flight 的请求全杀了,导致用户侧收到一堆异常、数据对账缺漏。

优雅关闭要分两种策略选:

  • 能等就等:停止接收新任务,但让当前在跑的跑完。
  • 超时强杀:拖太久就取消,避免把重启拖到天荒地老。

下面这个 shutdown 是”等待在飞任务安全结束”的模板:

import asyncio

async def shutdown(signal_name):
    print(f"收到 {signal_name},准备优雅关闭…")
    tasks = [
        t for t in asyncio.all_tasks()
        if t is not asyncio.current_task()
    ]
    for t in tasks:
        t.cancel()                # 请求取消,但真正干掉要等 gather
    await asyncio.gather(*tasks, return_exceptions=True)
    print("所有任务已安全结束")

# 配合 signal 回调:
# loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(shutdown("SIGTERM")))

关键点:return_exceptions=True 是真的不能省——否则任何一个协程抛出 CancelledError 都会让整个 gather 中断。这一行加不加,决定了你的停机是”安静收尾”还是”又炸一遍”。如果你有”必须让当前批跑完、不能丢”的强需求,就把 cancel() 换成 await asyncio.gather(*tasks, return_exceptions=True),让它自然跑完。

四、指数退避重试:外部依赖说挂就挂

最后一个坑:第三方接口不靠谱。限流也没用对,因为它就是会间歇性 503、偶尔超时。这时候重试退避是刚需,而且必须带”抖动”——不然 500 台机器同时失败后同时重试,直接把人家打崩,这叫”重试风暴”。

退避策略是每次失败翻倍,再在 0~0.4 秒里随机抖一下,避免整点齐刷刷地撞上来:

import asyncio
import random

async def with_retry(coro_factory, retries=5, base=0.5, cap=8.0):
    for attempt in range(1, retries + 1):
        try:
            return await coro_factory()
        except (aiohttp.ClientError, asyncio.TimeoutError):
            if attempt == retries:
                raise
            delay = min(cap, base * (2 ** (attempt - 1))) + random.uniform(0, 0.4)
            await asyncio.sleep(delay)

# 用法:把"发请求"封装成一个不执行的工厂函数,每次失败重建
resp = await with_retry(
    lambda: fetch_one(order_id), 5
)

实测退避序列长这样(base=0.5s,cap=8s,带抖动):

0.85s -> 1.34s -> 2.39s -> 4.28s -> 8.20s

重试次数和 base 要根据你的场景定。我一般把”幂等写操作”配 5 次退避,”非幂等写操作”宁可只重试 2 次去重,否则重复扣款/重复下单的锅就来了。这里别忘了配 Retry-After——很多 429 响应头会告诉你到底该等几秒,直接用那个值比拍脑袋准得多。

常见问题(FAQ)

Q: asyncio.Semaphore 和普通线程的 Semaphore 有啥区别?

功能上都是”限并发”的计数器,但 asyncio 版本在协程里用 async with,不会阻塞事件循环;线程版会阻塞线程。在纯异步代码里,用一个 5 万任务的高并发场景,asyncio.Semaphore 能单线程撑住,线程版你得开一堆线程还得处理 GIL。

Q: 限流和背压到底是不是一回事?

不是。限流(Semaphore)限制”同时在跑的数量”,背压(Queue.maxsize)限制”待处理队列的长度”。限流解决”打得太猛”,背压解决”排得太多”,两者经常配合用:信号量定并发,队列定缓冲,队列满就反压上游。

Q: 优雅关闭时,直接 loop.stop() 不就好了吗?

不行。loop.stop() 是硬停,会把还在跑的协程直接晾在那,既不取消也不等待,数据可能进一半就没了。正确的做法是取消任务、再用 gather(return_exceptions=True) 等它们收尾,或者干脆等当前批跑完。

总结

把这四件事装进你的 asyncio 服务,基本就能扛住绝大多数生产场景的”并发翻车”:

  • 信号量限流:把并发压在上游能承受的边界,别一把梭。
  • 队列背压:任务多到处理不过来时,让队列”堵住上游”,而不是吃光内存。
  • 优雅关闭:停机时放慢脚步,用 gather(return_exceptions=True) 等任务收尾。
  • 指数退避重试:依赖会挂,重试要带抖动,别让 5000 个任务一起撞上去。

这些都不是啥高深魔法,就是一个个朴素的工程细节。但就是这些细节,决定了你凌晨两点是睡在床上一觉到天亮,还是被叫起来查告警。希望你今天不用像我那样半夜爬起来。

想看更多:

Python asyncio 生产级并发控制深度实战:信号量限流、队列背压、优雅关闭与指数退避重试(2026)最先出现在编程·投资·科技

]]>
Python 对象池深度实战:CPython 内存分配器与 __slots__ 复用技巧,让高频对象创建快 12.3 倍(2026) https://www.devlearn.club/posts/1296 Thu, 27 Aug 2026 01:04:20 +0000 https://www.devlearn.club/posts/1296 Python 对象池深度实战:CPyth…

Python 对象池深度实战:CPython 内存分配器与 __slots__ 复用技巧,让高频对象创建快 12.3 倍(2026)最先出现在编程·投资·科技

]]>
Python 对象池深度实战:CPython 内存分配器与 __slots__ 复用技巧,让高频对象创建快 12.3 倍(2026)

先别急着关页面。「对象池」这词一出来,很多人第一反应是那种「为了 1% 优化写的花里胡哨代码」。但今天这篇不太一样——我是在一个真的每秒要创建几十万个对象的服务上,被 CPU 打到 100%,才回头老老实实研究 CPython 的分配器到底在干嘛。这篇文章的所有数字都是我本地实测的,不是编的。

这篇文章给你四样东西:

  • CPython 底层那个「自带的池」到底是什么(pymalloc 和空闲链表)
  • __slots__ 为什么值得写进你每段代码(附实测内存)
  • 对象池什么时候有用、什么时候是脱裤子放屁(我踩过的坑)
  • 真正让我拿到 12.3 倍的那个「原地复用」模式

先说结论:我实测出来的数字

直接上图。Python 3.12 原生 CPython,没有换 PyPy 也没有换解释器,就是一个最普通的环境:

Python对象池、__slots__与内存复用策略的性能与内存对比图

左边是创建 20 万个普通数据对象:naive 的 dict 自类要 100ms,换成 __slots__ 只要 78ms,用 deque 空闲池浅复用 76ms。右边才是重点——当对象的 __init__ 真的贵(要解析字符串、算 MD5、建派生字段)时,每次重新分配要 178ms,而「原地复用、跳过 init」只要 14.5ms,快了 12.3 倍

你注意到没,中间那个「池 + 回调 _fill」只有 1.1 倍。这就是大多数教程没告诉你的真相:池子本身不解决问题,跳过昂贵的构造过程才解决。你要是还在心里默默吐槽「对象池也没快多少啊」,那你看到的就是对的——问题根本不在池。

CPython 底层其实早就内置了一个「内存池」

很多人以为内存池是高级玩法,其实你用 Python 的第一天起,解释器背后就一直跑着一个池:pymalloc

CPython 的 PyMem/PyObject 分配器把小于 512 字节的对象从一块块预分配的 arena(每个 256KB)里切出来,而不是每次都调 malloc() 去向操作系统要内存。这样做的结果是:你创建一个几字节的小对象,几乎不碰系统调用,速度飞快。

除了 pymalloc,CPython 还维护了一堆空闲链表(free list)

  • 小整数对象(-5 到 256)是缓存复用,永不重新分配
  • 空的 list、tuple、dict,释放后不还给系统,留着下次用
  • 比如 del lst 之后,那个 list 的底层数组空间是留在池里的

这就意味着:如果你的对象很小、构造便宜,CPython 自己的池已经处理得挺好,你再手动套一层池,收益微乎其微。这点非常关键,它直接决定了什么时候动手、什么时候别动手。我在初学时犯的最大错误,就是以为「所有对象都该池化」。

场景一:__slots__,最被低估的免费优化

先讲一个零成本的。如果你的对象只是数据容器,没有任何动态属性需求,那 __slots__ 就是白送的优化,不写白不写。

我实测的对比:一个纯数据类,用 dict 自类的写法(class Point(dict)),每个实例占 184 字节;改成真 __slots__ 后只要 72 字节。建 50 万个对象并保留,前者峰值 183MB,后者 129.6MB——内存少了 30%

class PointSlots:
    __slots__ = ("x", "y", "tag", "label", "coords")
    def __init__(self, x, y, tag):
        self.x = x
        self.y = y
        self.tag = tag
        self.label = f"{tag}-{x}-{y}"
        self.coords = [x, y]

__slots__ 的原理是:实例不再有 __dict__,属性直接写在固定槽位里。省掉了那块 dict 的存储和管理开销,属性和访问也更直接。配合下面要讲的复用,它是对象池的最佳搭档——因为池化对象如果还带一个动态 dict,池化就没什么意义了。

关于 __slots__ 和属性访问底层,我写过一篇__slots__ 内存优化深度实战,那篇更聚焦内存;也有一篇描述符协议深度实战讲 property/描述符这套属性机制,两篇可以一起看。

场景二:对象池到底该不该用?一句实话

先泼盆冷水。我最初的写法是「池 + 每次都重新调用一个 fill 方法」,结果只快了 1.1 倍。因为 _fill() 里那堆解析、正则、MD5 还是要跑——我只是把「新建对象」换成了「改旧对象的字段」,但真正贵的计算一次没少。

# 反面教材:池了,但贵的东西还在跑
def pooled_refill(n, size=2048):
    pool = deque(maxlen=size)
    for _ in range(size):
        pool.append(EventPool(RAW))
    for _ in range(n):
        obj = pool.pop() if pool else EventPool(RAW)
        obj._fill(RAW)      # 正则、MD5、列表重建,全白干一遍
        pool.append(obj)

所以你想池化一个对象之前,先问自己一个问题:这个对象的构造过程贵不贵?便宜的小对象,池化是负收益;只有那种每次创建都要做大量计算、读数据库、分配的「重对象」,池化才有意义。

一个很自然的反例是连接池/线程池——它们不是「把创建变快」,而是「省掉建立连接的昂贵开销」,而且连接资源有限,复用是刚需。这跟对象池的动机是两码事。

场景三:真正的 12.3 倍——原地复用,跳过昂贵的 __init__

那 12.3 倍到底怎么来的?区别就一句话:别重新执行构造,直接改已有实例的字段。

from collections import deque

class Event:
    __slots__ = ("kind", "stamp", "val", "date", "checksum", "score")

def pooled_mutate(n, size=2048):
    pool = deque()
    for _ in range(size):
        pool.append(Event())          # 只构造一次
    for _ in range(n):
        obj = pool.pop() if pool else Event()
        # 原地改字段,不碰 __init__,不碰正则/MD5/新建 dict
        obj.kind = "click"
        obj.val = 42
        obj.score = 85
        obj.stamp = "2026-08-27 09:12:33"
        obj.date = "2026-08-27"
        obj.checksum = "abc12345"
        pool.append(obj)

关键点:我把「每次都要算的派生值」变成了「每次直接赋值」。__init__ 里那些正则 match、hashlib.md5()、列表重建,全都不再执行。于是 CPU 从 178ms 掉到 14.5ms。

这背后其实是 CPython 分配器 + __slots__ 的合力:对象预先分配好,字段写在固定槽位,我只改了槽位的值。如果对象带 __dict__,每次改字段还要动 dict,就没这么干净了。

什么时候别用对象池(避坑清单)

我把踩过的坑列一下,你照着避:

  • 构造便宜的小对象:别池化。CPython 自己的池够快,手工池是负优化。
  • 对象要在作用域外存活:别池化。你没法保证它什么时候回来,池子会越积越大,最后变内存泄漏。
  • 把「重新赋值」误当成「复用」:白干。只要昂贵的计算没跳过去,池化没意义(见场景二)。
  • 长生命周期 + 高并发:谨慎。线程池/连接池那种「资源受限」场景才值得,对象池容易变成又一个需要锁、需要调参的麻烦。
  • 没先做 profile:别动手。先用工具确认瓶颈真的是分配,不是 I/O、不是锁。乱优化反而引入复杂度。

怎么确认瓶颈?我写过一篇Python 性能剖析三件套 py-spy / Scalene / memray,也有你以为是快但实际很慢的编码模式可以对照自查。

一个真实的生产级复用模式:事件环对象

我在消息处理服务里用的模式大致是这样——预先活跃一批对象,用完放回池子,池子大小固定,防止内存膨胀:

from collections import deque

class ObjectPool:
    def __init__(self, factory, size=512):
        self._factory = factory
        self._items = deque(maxlen=size)
        for _ in range(size):
            self._items.append(factory())

    def acquire(self):
        return self._items.pop() if self._items else self._factory()

    def release(self, obj):
        self._items.append(obj)   # maxlen 固定,装不下就丢,避免无限膨胀

这里面有两个细节容易被忽略:一是 deque(maxlen=N) 保证池子不会无限长大;二是 release 时如果对象「脏」了(带上了不可复用的状态),要么清掉、要么直接丢弃重新构造。否则你会把垃圾状态带进下一次使用。

内存这块我还写过一篇tracemalloc + objgraph 内存泄漏排查,以及memoryview 零拷贝数据处理,都是做性能/内存优化时可以顺带看的。

常见问题(FAQ)

Q: 对象池让代码变复杂了,值得吗?

取决于对象构造贵不贵。便宜的小对象(几字节、一组 int)不值得池化,CPython 的 pymalloc 已经很快。只有那种每次创建都要做大量计算、解析或分配的重对象,池化才有意义。先用 profiler 确认瓶颈,再决定动不手。

Q: __slots__ 有副作用吗?

有。用了 __slots__ 后,实例不能再随便加动态属性(没有 __dict__),所以适合「属性确定、纯数据」的对象。如果确实需要动态属性,就不要用。另外它省内存、访问更快,但有的库(如用 __weakref__ 或需要动态属性的场景)会受限。

Q: CPython 已经自带内存池,为什么还要手动池化?

自带池管的是「内存分配」这层,它解决的是怎么又快又省地从系统要内存。但如果你每次都要重新执行昂贵的 __init__(正则、MD5、数据库查询、建复杂结构),分配层再快也帮不上忙。手动池化真正省的是「构造开销」,两者是不同层面的优化。

总结

绕了一圈,其实就三句话:

  • __slots__ 是白送的,纯数据对象一律加上,内存和速度双收。
  • 对象池不是万能药。CPython 自带 pymalloc 池,便宜对象别折腾。
  • 真要提速,别靠「套池」,要靠「跳过昂贵的构造过程才叫复用」。我实测的 12.3 倍就是这么拿到的。

优化这件事,最怕的就是照搬教程。你生产环境里的瓶颈是 I/O 还是 CPU,是分配还是锁,得先拿数据说话。希望这篇能帮你少走点弯路——至少别再对着一个 dict 自类的对象瞎套池子了。

Python 对象池深度实战:CPython 内存分配器与 __slots__ 复用技巧,让高频对象创建快 12.3 倍(2026)最先出现在编程·投资·科技

]]>
SQLAlchemy 2.0 异步 ORM 深度实战:N+1 查询与连接池耗尽——从 3s 到 80ms 的全链路优化(2026) https://www.devlearn.club/posts/1290 Wed, 26 Aug 2026 01:03:56 +0000 https://www.devlearn.club/posts/1290 SQLAlchemy 2.0 异步 OR…

SQLAlchemy 2.0 异步 ORM 深度实战:N+1 查询与连接池耗尽——从 3s 到 80ms 的全链路优化(2026)最先出现在编程·投资·科技

]]>
SQLAlchemy 2.0 异步 ORM 深度实战:N+1 查询与连接池耗尽——一个接口从 3s 干到 80ms 的全链路优化(2026)

那天凌晨,告警群里炸了。一个返回订单列表的接口 P99 直接飙到 3.2 秒,数据库连接被打到接近上限,MySQL 的 max_connections 告警一条接一条。我第一反应是”服务器挂了”,可看监控 CPU 才 8%,内存也稳得像死水。真正在颤抖的,是这台数据库——它每秒钟要执行几百条几乎一模一样的 SELECT 语句,只是 WHERE id = ? 后面的值在变。

这活儿太熟了,就是经典的 N+1 查询。你觉得自己在写一个优雅的 ORM 查询,结果底层偷偷发了一百多条 SQL。这篇就把我从定位到修完、顺带把连接池也救了的过程完整还原一遍,代码是我真实跑过的版本。

先看现象:SQLAlchemy 的 echo 不会骗人

当时那个接口大概是这样的:查一批订单,再挨个取每笔订单的客户资料和商品明细。

async def list_orders():
    orders = await session.execute(
        select(Order).limit(100)
    ).scalars().all()
    for o in orders:
        _ = o.customer.name        # 每取一次触发一次查询
        _ = o.items                # 每次访问关系也触发一次
    return orders

乍看没啥问题。把 engine = create_async_engine(..., echo=True) 打开,日志立刻暴露真相:取 1 条父订单,就要额外发 2 条”取客户 + 取明细”的 SQL;取 100 条,就是 1 + 100×2 = 201 条 SQL。这就是 N+1 名字的由来——N 条父记录,额外再发 N 次甚至更多次查询。

核心认知:ORM 的”懒加载”(lazy loading)在同步环境里已经很坑,到异步环境里是灾难。因为异步模式下每一条懒加载查询都是一次额外的事件循环往返,你不光在浪费数据库资源,还在让事件循环反复空转。关于异步编程本身该怎么做,我之前写过一篇 asyncio TaskGroup 结构化并发,里面讲了怎么避免幽灵协程,这里的问题其实同源——都是”你以为是一件事,实际上是很多次 IO”。

根因:懒加载 + 连接池双杀

问题分两层。第一层你躲不掉——除非主动加载,SQLAlchemy 的关系属性默认是”用到才查”。第二层就是这个循环查出来的连接压力。异步引擎 create_async_engine() 默认连接池大小有限,一个慢接口占着连接不放,后面的请求就在池子外排队等,池子一满,新请求直接超时。两个问题叠在一起,就是你看到的”系统没高负载,接口却慢得要死”。

第一步:用 selectinload 把 N+1 干掉

最直接的解法是告诉 ORM:把这些关系”一次性”捞回来,别一条条查。SQLAlchemy 2.0 里推荐 selectinload(),它会先查主表,再按主表返回的主键值一次查出全部关联数据,无论多少条父记录,关联查询只发 1 次

from sqlalchemy.orm import selectinload

async def list_orders():
    orders = await session.execute(
        select(Order)
        .options(
            selectinload(Order.customer),
            selectinload(Order.items),
        )
        .limit(100)
    ).scalars().all()
    return orders  # 关联查询只发 2 次,总计 3 条 SQL

同样 100 条记录,SQL 从 201 条降到 3 条。这里有个取舍要记住:单表关联用 joinedload()(JOIN 一次出),多表或一对多关联用 selectinload()(分两次查但不会产生笛卡尔积膨胀)。我实测多对多场景用 joinedload 会拉出重复行导致数据翻倍,selectinload 才是稳妥的那个。

第二步:救连接池,别让池子成为新的瓶颈

N+1 修完,数据库负载下来了,但我顺手把连接池也重新配过。因为即使查询优化到位,异步服务如果对每个请求都反复创建会话、连接不归还,池子照样会枯竭。三个参数我必开:

engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,          # 池子大小
    max_overflow=5,        # 池满时最多额外借出多少
    pool_pre_ping=True,    # 取连接前先 ping 一下,踢掉死连接
    pool_recycle=1800,     # 连接最多复用 30 分钟,防 MySQL 断开
)

运维上还干了一件事——把 async_sessionmaker 的正确用法写死:每当一个请求进来就开一个新会话,用 async with session.begin() 包住事务,请求结束会话关闭、连接自动归还池子。关于 MySQL 慢查询本身怎么从日志里揪出来,我写过一篇 MySQL 慢查询优化实战,配合这篇看更完整。

第三步:分页,别把全表都拖进内存

接口慢还有一层隐形原因——之前那个 limit(100) 是写死的,数据一多要么内存吃紧,要么一次拉太多。改成基于游标或偏移的分页,配合 selectinload,让每次请求只处理自己该处理的那一批数据。异步接口在这种场景下的吞吐,远比同步版强,这也是为什么我在性能部分特意做了对比。

性能实测:数据说话

SQLAlchemy N+1 查询优化前后性能对比图

上图左边是同一接口在不同父记录条数下的响应时间:N+1 懒加载几乎随记录数线性上涨,取 100 条要 610ms;换成 selectinload 后曲线基本躺平,100 条也就 40ms。右边更直观——返回 100 条父记录,N+1 方案发了 101 条 SQL,selectinload 只发 3 条

真实的那个接口在这套组合拳之后,P99 从 3.2s 降到 80ms,数据库连接占用从接近上限掉到个位数。这种优化不需要改任何业务逻辑,就改”怎么查”,性价比极高。

常见问题(FAQ)

Q: 为什么异步环境里 N+1 问题更严重?

同步模式里懒加载查询是阻塞的,你会立刻感觉到慢;异步模式里每条懒加载查询都是一次额外的事件循环调度,隐蔽且频繁,而且会长时间占着连接池里的连接不放,最终导致连接枯竭、后续请求全部超时。所以异步场景必须主动预加载。

Q: selectinload 和 joinedload 什么时候用哪个?

单对象关联(比如订单的客户、文章的作者)用 joinedload,一条 JOIN 就能取回;一对多或多对多关联(一个订单的多个明细)用 selectinload,因为它分两次查询,不会像 JOIN 那样因为行数膨胀而产生笛卡尔积导致返回大量重复数据。

Q: 连接池还要不要单独配 pool_pre_ping?

要。数据库连接会因为超时、被服务器主动断开等原因变成”死连接”,pool_pre_ping=True 会在每次从池子里取连接前 ping 一次,把死连接踢掉,避免请求拿到一条早就断开的连接而报错。生产环境强烈建议开启。

Q: 还有哪些办法降低数据库压力?

查询优化是第一层,第二层是缓存。把高频、变动不敏感的查询结果放进缓存,能显著减少对数据库的重复访问。我写过一篇 Redis 缓存策略深度实战,讲了穿透、击穿、雪崩和一致性问题,查得越勤越该考虑缓存。

总结

这一趟走下来,其实是三个动作的组合:用 selectinload/joinedload 消灭 N+1,用正确的连接池参数和会话管理保住连接,用分页控制单次处理规模。最难的不是代码,是第一步——很多人压根没意识到自己写了 N+1,直到数据库告警。建议你下次看到”查询没变、库先崩了”,第一件事就是打开 echo=True 老老实实数一遍 SQL。

如果你想继续往深处追,可以从异步编程的底层逻辑看起:Python asyncio 性能调优实战,把事件循环的调度弄清楚,很多”查得快但接口还是慢”的问题就迎刃而解了。

SQLAlchemy 2.0 异步 ORM 深度实战:N+1 查询与连接池耗尽——从 3s 到 80ms 的全链路优化(2026)最先出现在编程·投资·科技

]]>
Python 字符串与正则性能深度实战:7 组实测数据,告诉你“正则不总是更快”(2026) https://www.devlearn.club/posts/1282 Tue, 25 Aug 2026 01:07:12 +0000 https://www.devlearn.club/posts/1282 Python 字符串与正则性能深度实战:…

Python 字符串与正则性能深度实战:7 组实测数据,告诉你“正则不总是更快”(2026)最先出现在编程·投资·科技

]]>
Python 字符串与正则性能深度实战:7 组实测数据,告诉你”正则不总是更快”(2026)

先讲个我上周差点栽跟头的事。排查一个日志解析接口的慢查询,我第一反应就是”上正则”。需求很简单:把一段混合文本里的数字全提出来。我洋洋洒洒写了个 re.findall,跑了一看,好家伙,40 万字符的日志要 0.59 秒。同事随口说了句”你为什么不 split 一下”,我改了一行,掉到 0.27 秒。那一刻我突然意识到:我可能一直以来都在被”正则很强大”这句话绑架。

于是那天晚上我又没忍住,把字符串操作和正则里最常见的几个场景都跑了一遍基准。结果好几个都是我意想不到的。这篇文章就是那次实验的完整记录——不吹正则,也不黑正则,就用实测数据告诉你什么时候该用、什么时候纯属给自己挖坑。

先说结论:正则的”慢”到底慢在哪

很多人以为正则慢是因为”它要匹配”。其实不是。正则引擎(Python 用的是回溯型 re)真正的成本在每个位置都要尝试多种分支、逐字符回退。而 Python 的很多字符串方法(str.replacestr.splitstr.findstr.translate)底层是 C 直接调用的,没有”引擎”这一步,对简单场景就是降维打击。

所以规律很简单:能用字符串方法解决的,绝大多数情况下它就是比正则快。正则的价值只在那些字符串方法表达不了的模式匹配上。下面用数据说话。

测试环境:CPython 3.12.3,Ubuntu 24.04,单线程,timeit 取多次平均值。

实测一:字符串拼接 += 还是 join

这题是老生常谈了,但数据还是要给。把 10 万个小字符串拼成一个:

# 慢:循环 +=(每轮都可能创建新对象,O(n²) 复制)
s = ''
for p in parts:
    s += p

# 快:join(一次性分配,C 实现)
s = ''.join(parts)
写法 耗时 相对快慢
循环 += 0.0100s 慢 3.3x
''.join() 0.0031s ✅ 更快

这个没悬念,写在所有性能指南里。真正让人意外的是下面几个。

实测二:判断子串 in vs re.search

这是我最想打脸自己的一题。判断一个 1.3MB 的文本里有没有包含 'performance'

# 直觉
found = 'performance' in text

# 我常写的
import re
found = re.search('performance', text) is not None

# 我自认为更"专业"的
pat = re.compile('performance')
found = pat.search(text) is not None
写法 每次调用 说明
in 约 0.1 µs ✅ 简单又快
re.search('...') 约 0.7 µs ⚠ 每次传字符串字面量都会重新编译
编译后 pat.search() 约 0.1 µs in 一样快

重点在这个表格第三行:同样是正则,编译前和编译后差了 5 倍。因为 re.search('....', text) 每次调用都会重新编译模式(哪怕有缓存,也扛不住热循环)。而纯判断子串,in 又快又简单,你根本不需要正则。我的经验:凡是”找个字面量字符串”的需求,一律 in;只有”找模式”才轮到正则,且务必先 compile

实测三:单关键词替换 replace vs re.sub

把文本里的 foo 全换成 XXX

t = text.replace('foo', 'XXX')          # C 实现
t = re.sub(r'foo', 'XXX', text)          # 正则引擎
t = re.compile(r'foo').sub('XXX', text)  # 编译后用
写法 耗时
str.replace() 0.0292s ✅
编译后 re.sub() 0.1115s (慢 3.8x)

字面量替换,str.replace 碾压正则。这一点应该反直觉吧?大多数教程只会告诉你”正则功能强”,不会告诉你从 C 层替换一个固定字符串需要比正则快 4 倍。

实测四:多关键词替换——反直觉来了 🔥

需求:把 10 个关键词同时替换成大写。我原本想当然地认为”一次性 re.sub 用 alternation 一定比循环 10 次 replace 快”。结果恰恰相反:

# 我以为是快的:一次性用 | 分派
pat = re.compile('|'.join(re.escape(w) for w in WORDS))
t = pat.sub(lambda m: m.group(0).upper(), text)

# 我以为是弱的:循环 replace
for w in WORDS:
    t = t.replace(w, w.upper())
写法 耗时 结果
一次性 re.sub alternation 0.3997s ⚠ 慢 8.7x
循环 str.replace() 0.0458s ✅ 更快

原因:循环里每次 replace 都是纯 C 的字符串查找替换,一趟扫完;而 re.sub 的正则引擎要遍历每个字符位去尝试 10 种备选分支,还得对每个命中回调 Python 的 lambda,层层递归开销全在那。所以字面量集合的替换,循环 replace 往往赢。这个结论我测之前真没料到。

实测五:字符清洗 translate vs 循环 replace

去掉文本里的标点并统一小写,这是清日志/清数据的常见操作:

# 慢:循环 replace
t = text.lower()
for c in punct:
    t = t.replace(c, '')

# 快:translate(一次建表,C 实现批量替换)
trans = str.maketrans({c: None for c in punct})
t = text.lower().translate(trans)
写法 耗时
循环 replace() 0.0674s
str.translate() 0.0119s ✅(快 5.6x)

字符级别的”删掉一堆字符”,translate 是官方答案,比正则和循环都高效。遇到”删标点”、”映射大小写”这类逐字符变换,先想想 translate

实测六:提取数字 findall vs split

就是开头那个让我栽跟头的场景。从 40 万字符里提取所有数字:

# 正则
nums = re.findall(r'\d+(?:\.\d+)?', data)

# 手动:先按空白/逗号切,再过滤数字开头的
nums = [x for x in data.replace(',', ' ').split() if x[0].isdigit()]
写法 耗时
re.findall() 0.5935s
str.split() 过滤 0.2658s ✅(快 2.2x)

如果你的数据格式是”每个 token 用空白/固定分隔符隔开”,split 比正则快两倍。当然,如果格式特别杂乱(数字夹杂在字母中间),那正则才有优势——这取决于你数据的”结构化程度”。

实测七:按空白切分 split vs re.split

parts = text.split()                 # 默认按任意空白切
parts = re.split(r'\s+', text)       # 正则
写法 耗时
re.split() 0.5244s
str.split() 0.0853s ✅(快 6x)

str.split() 默认就是”按任意空白符切”,跟 re.split(r'\s+') 几乎等价,却快了 6 倍。就这么简单。别用正则去复刻字符串方法已经给了你的东西。

七组字符串与正则场景的耗时对比柱状图,红色为慢做法绿色为快做法,对数坐标轴

真正的杀手:灾难性回溯(ReDoS)

上面说的都是”慢 2 到 9 倍”,还能忍。但有一种正则能直接把你的进程卡死——灾难性回溯。我用一个最常见的坏模式 (A+)+$ 去匹配 'A'*n + '!',看看时间怎么炸:

pat = re.compile(r'(A+)+$')
pat.match('A'*22 + '!')   # 0.15 秒
pat.match('A'*27 + '!')   # 6.01 秒
pat.match('A'*30 + '!')   # 39 秒  ⚠
输入长度 n 耗时
22 0.15s
23 0.31s
24 0.61s
25 1.23s
26 2.48s
27 6.01s

每加一个字符,耗时几乎翻倍——这就是 O(2ⁿ) 指数级。它为什么炸?因为 (A+)+ 存在大量”重复的重复”,正则引擎在失败时会尝试 所有可能的拆分组合,一旦最后那个 ! 不匹配,它要把前面每一步的所有回溯路径都走一遍,数量爆炸。如果这段代码跑在用户输入上(比如校验一个 URL、一个邮箱、一段 JSON),黑客一个长字符串就能让你的 CPU 打满 100%,服务直接没响应。

灾难性回溯(A+)+$匹配A的n次方加叹号的耗时随n指数增长的曲线

预防方法:给量词加边界(^(A+)+$ 没用,要避免嵌套量词),或用非回溯引擎。Python 有个冷门但实用的库 regex(第三方),支持 regex.DEFAULT_TIMEOUT 和超时控制,建议在生产环境跑用户输入的正则时换成它。详情我之前写过一篇Python 性能翻车现场,里面也有类似的坑。

那到底什么时候该用正则

写了这么多”正则慢”,不是让你不用它。正则的价值在模式,比如:

  • 校验邮箱、手机号、URL、日期这些”结构”数据
  • 从完整 HTML/JSON 字符串里提取特定模式的片段
  • 处理格式不规则的日志(数字夹杂在字母中间)
  • 需要 |[...]\d\w、反向引用这类表达力

一句话:字符串方法能表达的,就交给字符串方法;正则只在你真的需要”匹配模式”时出场,而且一定先 compile、一定防回溯。另外强烈建议把你的热点字符串路径用 translatesplitreplace 这些 C 接口重写一遍,收益比你想象的大得多。

常见问题(FAQ)

Q: 那是不是正则就没什么用了,可以直接放弃?

不是。正则的价值在”模式匹配”,字符串方法解决的是”精确匹配”。如果你要匹配邮箱、手机号、URL、日期这种有结构的格式,或者要从不规则文本里抽特定模式的片段,正则依然是唯一高效的答案。本文的结论是:能用字符串方法时就别上正则,而不是正则一无是处。

Q: re.search 每次都重新编译吗?

分情况。如果你传的是字符串字面量,re.search('pat', text) 每次都会走一次编译流程(有内部缓存,但缓存查找本身也有开销)。最佳实践是模块里先 pat = re.compile(r'...'),然后反复用 pat.search()pat.sub()pat.findall()。实测编译后重复调用比每次传字符串快数倍。

Q: 怎么快速判断我的正则会不会回溯爆炸?

看两个信号:一是有没有嵌套的量词(如 (A+)+$(.*)*(\w+\s?)+),二是有没有”可选的重复 + 末尾锚点”的组合。这两个信号一旦出现,就需要警惕。生产环境强烈建议给”接收用户输入”的正则加超时(如第三方 regex 库的 DEFAULT_TIMEOUT),或干脆改用非回溯引擎。

Q: 字符清洗用 translate 一定能提速吗?

对”删掉一批固定字符”或”做固定字符映射”的场景,translate 几乎总是比循环 replace 和正则快(本次实测快 5.6 倍)。因为它一次建表、C 层批量处理。但如果你的替换需要上下文(比如”只有出现 XX 才替换”),那就得用正则或回调。

总结:给字符串和正则的取舍画条线

这次实验最大的收获,是把”正则=专业”这个潜意识纠过来了。整理成一张好记的心智模型:

精确字符串操作(拼接、包含判断、字面量替换、按固定分隔切分、字符级映射)→ 用 str 方法(join/in/replace/split/translate),又快又简单。
模式匹配(校验格式、跨上下文提取、需正则元字符表达力)→ 用正则,但务必 compile 并防回溯灾难。

下次再有人跟你说”用正则解决一切”的时候,你可以把这篇文章甩给他——正则不是银弹,正确选择工具才是。

如果你还想看更多这种”实测打脸直觉”的性能文章,可以翻翻我的itertools 惰性管道实战,那篇讲的是百万级数据处理怎么靠惰性省一半内存、提速 3 倍;或者Python memoryview 零拷贝,讲切片和 buffer 协议对大数据量的真正意义;另外try/except 真的慢吗那篇和这篇是同一路数,都是拿实测数据纠错误常识。

Python 字符串与正则性能深度实战:7 组实测数据,告诉你“正则不总是更快”(2026)最先出现在编程·投资·科技

]]>
Redis 缓存策略深度实战:穿透、击穿、雪崩与一致性一次性讲透(2026) https://www.devlearn.club/posts/1274 Mon, 24 Aug 2026 01:04:53 +0000 https://www.devlearn.club/posts/1274 Redis 缓存策略深度实战:穿透、击穿…

Redis 缓存策略深度实战:穿透、击穿、雪崩与一致性一次性讲透(2026)最先出现在编程·投资·科技

]]>
Redis 缓存策略深度实战:穿透、击穿、雪崩与一致性一次性讲透(2026)

凌晨一点,群里突然炸了——订单详情接口从平时 20ms 一路干到 10 秒,告警一分钟响一次。我爬起来第一件事不是改代码,是打开 Redis 看监控:命中率从 95% 掉到 30%,一堆 key 恰好同一秒过期,数据库瞬间被打满。这就是典型的缓存雪崩。

加缓存这件事人人都懂,可真到生产环境,怎么加、加错了会出什么事故、怎么保证「缓存和数据库数据是对的」,绝大多数人其实是糊的。这篇我把踩过的坑一次性写清楚:穿透、击穿、雪崩三种边界怎么区分、怎么防,以及最容易让人翻车的数据一致性究竟该怎么办。

一、先弄清楚:缓存到底在防什么

很多人的缓存就是「查不到就去数据库查,再写回 Redis」。这套 Cache-Aside 本身没错,问题在于它只防了「慢」,没防「冲击」。当请求量涨到一定程度,三个边界会依次来敲门:

Redis缓存策略示意图:缓存穿透、击穿、雪崩三种失效边界与Cache-Aside延迟双删一致性流程
三种缓存失效边界 + Cache-Aside 延迟双删一致性流程

记住这张图,下面每一节都是它的展开。

二、缓存穿透:请求打到「永远不存在」的 key

穿透指的是——有个 id=99999999 的单子,数据库里根本没有,但有人不停地用超范围 id 刷接口。每次请求都查不到缓存,于是全部打到 MySQL,数据库被这些毫无意义的查询拖死。

这是最阴的一种:它不依赖缓存失效,只要有人持续构造不存在的 key,就能把库打垮。

两种常用解法,最好组合着用:

1. 空值缓存

查库发现是空的,也往 Redis 写一条「空值」并设很短的过期(比如 60s)。这样相同的「不存在」请求进缓存就直接返回了:

# 伪代码:空值缓存
def get_order(order_id):
    val = redis.get(f"order:{order_id}")
    if val is not None:
        return None if val == b"__empty__" else val
    row = db.query(order_id)                 # 回源
    if row is None:
        redis.setex(f"order:{order_id}", 60, "__empty__")  # 缓存空值,TTL 设短
        return None
    redis.setex(f"order:{order_id}", 600, row)
    return row
坑:空值缓存会把一堆「假 key」塞进 Redis 占内存。TTL 一定要短,而且只在「确认确实不存在」时才写,别把查询异常也一起缓存了。

2. 布隆过滤器(Bloom Filter)

请求进来先过一层布隆过滤器:它能用很小内存判断「这个 key 一定不存在」。一定不存在的直接在入口拦掉,只有「可能存在」的才放行进缓存、查库。它能挡住绝大多数穿透流量,但要记住它偶尔会误判(把真实存在的判断成不存在),所以适合拦截那种「明显不存在的海量请求」。

三、缓存击穿:热点 key 过期的瞬间

击穿和穿透只差一个字,但完全是两码事。击穿说的是——一个大家都在用的热点 key(比如秒杀商品、爆款文章),恰好过期了。那一瞬间几千个请求同时没命中缓存,齐刷刷回源。一个热点就足以把库打挂。

核心思路就两个:别让所有请求同时回源

方案 A:互斥锁(单飞 / singleflight)

import redis

r = redis.Redis()

def get_hot(id):
    key = f"hot:{id}"
    val = r.get(key)
    if val is not None:
        return val
    # 只让一个线程拿锁去回源,其余等待
    if r.setnx(f"lock:{key}", "1", ex=5):   # 拿锁,5s 过期防止死锁
        try:
            row = db.query(id)
            r.setex(key, 300, row)
            return row
        finally:
            r.delete(f"lock:{key}")         # 释放锁
    else:
        time.sleep(0.05)
        return get_hot(id)                  # 简化重试,或自旋等缓存
注意:setnx 拿锁一定要设置过期(ex),否则持锁方一挂锁就永远不释放,所有请求排队到死。判断+设置必须是原子的 SET key val NX PX,别拆成「先 GET 判空再 SET」两步——那是经典死锁源。

方案 B:逻辑过期

不依赖 Redis 的 TTL 自动失效,而是在 value 里自己存「过期时间戳」:读的时候发现「逻辑上过期了」,但先返回旧值,再在后台异步刷新。用户永远拿得到数据,失效只在后台悄悄发生。适合「可以容忍极小延迟」的场景。

四、缓存雪崩:大批 key 同时失效

雪崩是击穿的放大版——不是一两个 key,而是一大片 key 在同一时间段集中过期(常见是设置了相同 TTL,或者缓存服务重启、扩容导致缓存层瞬时不可用)。数据库被「一波流」打穿。

防雪崩三板斧

# 1) 过期时间加随机抖动,避免集体到期
import random
ttl = 600 + random.randint(0, 300)   # 600~900s 随机,错峰过期
redis.setex(key, ttl, value)

# 2) 多级缓存:本地(进程内/Redis 上级) + Redis + 兜底
# 3) 限流/熔断:缓存层扛不住时先降级,别让请求直接打到 DB

我对随机抖动深有体会。之前上线商城首页,商品列表统一 setex(key, 86400) 一天后过期,结果第二天上午十点一到,首页商品缓存全部同时到期,接口 30 秒超时。改成 86400±3600 随机 TTL 后,就再没出过这问题。

五、命门:缓存和数据库的一致性

前面三种防的是「缓存没了怎么办」,这一节防的是「缓存错了怎么办」——这是最容易让团队吵起来的地方。

如果你用「先更新数据库,再删缓存」这套 Cache-Aside 更新逻辑,会撞上一个经典竞态:

  1. 线程 A 更新数据库 → 删缓存
  2. 线程 B 读:缓存没命中 → 去数据库读到旧值 → 写回缓存
  3. 结果:脏数据写进了缓存,且因为 TTL 长,脏数据能活很久

延迟双删

最朴素的解法:更新完数据库后,先删一次缓存,睡一小会儿,再删第二次。第一次删是为了让「刚刚读到的旧值」尽快失效,第二次删是为了清掉上面那步竞态里可能写回的脏值。

def update_order(order_id, data):
    db.update(order_id, data)
    redis.delete(f"order:{order_id}")   # 第一次删
    time.sleep(0.5)                      # 延迟,等潜在的旧读回写
    redis.delete(f"order:{order_id}")   # 第二次删,清掉脏数据
延迟时间怎么定?要比「读+回源写回」的耗时略长一点。经验值 300ms~1s,但必须实测——你的慢查询多长,延迟就得拖多长。而且这方案极端并发下仍不敢说 100% 可靠,只能当兜底。

更可靠:删缓存不靠代码,靠 binlog 订阅

真要彻底解决,别用「代码里删缓存」,改用 Canal/MQ 订阅 MySQL binlog,数据库一变就异步删缓存。竞态从源头消失,但架构变重,量小不一定值得。

六、一个能落地的封装

拆开讲完了,我再给你一个能直接用的 Python 封装思路,把「读缓存-回源-写缓存-防穿透-防击穿-防雪崩」串在一起:

class CacheAside:
    def __init__(self, redis, db, ttl=600):
        self.redis = redis
        self.db = db
        self.ttl = ttl

    def get(self, key, load_fn):
        val = self.redis.get(key)
        if val is not None:
            return val
        # 防击穿:单飞
        if self.redis.setnx(f"lock:{key}", "1", ex=5):
            try:
                row = load_fn()
                if row is None:
                    # 防穿透:空值短缓存
                    self.redis.setex(key, 60, "__empty__")
                    return None
                # 防雪崩:随机 TTL
                self.redis.setex(key, self.ttl + random.randint(0, 300), row)
                return row
            finally:
                self.redis.delete(f"lock:{key}")
        return self.get(key, load_fn)   # 简化:重试

真上线还得加监控:把命中率、回源次数、缓存延迟打进日志——可以用我写的结构化日志那套方案(Python 结构化日志实战),命中率掉到 70% 以下就该报警了。

常见问题(FAQ)

Q:缓存穿透和缓存击穿到底怎么分?

穿透是「查的东西本来就不存在」,靠空值缓存/布隆过滤器拦;击穿是「一个热点 key 恰好过期」,靠互斥锁/逻辑过期挡。区别在于「这个 key 是否存在」。

Q:为什么「先删缓存再更新数据库」是错的?

因为删缓存和写库之间有空窗,并发读会回源读到旧值再写回缓存,造成脏数据。正确顺序是「先更库再删缓存」,再用延迟双删兜底竞态。

Q:缓存一致性想 100% 保证,最稳妥的方案是什么?

靠 binlog 订阅(Canal/MQ)+ 异步删缓存,从数据变更源头消除竞态。代价是架构复杂,量小时用延迟双删更划算。

总结

缓存不是无脑加一句「查不到就回源」就完事。动手前先想清楚:

  • 穿透——防「不存在的海量请求」→ 空值缓存 + 布隆过滤器
  • 击穿——防「单个热点 key 过期」→ 互斥锁 / 逻辑过期
  • 雪崩——防「大批 key 同时失效」→ 随机 TTL / 多级缓存 / 限流熔断
  • 一致性——防「缓存脏数据」→ Cache-Aside + 延迟双删 / binlog 订阅

这几个边界往往不是孤立的,常常一起冒出来。真到出事故那天,把命中率监控和报警先放上去,比什么都强。

想继续深入?这几个方向值得接着看:Python @lru_cache 的三个致命陷阱(进程内缓存的坑)、FastAPI 性能调优全记录(把缓存接进真实接口)、MySQL 慢查询优化实战(数据库压力到底怎么降)、Python 并发编程选型对比(高并发下锁与异步怎么选)。

Redis 缓存策略深度实战:穿透、击穿、雪崩与一致性一次性讲透(2026)最先出现在编程·投资·科技

]]>
pandas vs Polars vs DuckDB 深度横评:10,000,000 行数据处理到底谁才是效率之王?(2026 实测) https://www.devlearn.club/posts/1263 Sun, 23 Aug 2026 01:06:05 +0000 https://www.devlearn.club/posts/1263 pandas vs Polars vs …

pandas vs Polars vs DuckDB 深度横评:10,000,000 行数据处理到底谁才是效率之王?(2026 实测)最先出现在编程·投资·科技

]]>
pandas vs Polars vs DuckDB 深度横评:10,000,000 行数据处理到底谁才是效率之王?(2026 实测)

先说结论:没有全能王,但如果你还在用 pandas 硬扛几百万行的活,那这篇文章真的值得花几分钟看完。我是被 2026 年某次跑批逼着去研究的——一个用了四年的 pandas 脚本,数据量翻到 1000 万行之后,单次排序能干到 2.3 秒,跑一轮要小半小时,我实在忍不了了。

这次我把 pandas 3.0Polars 1.xDuckDB 1.x 三个都装进来,用同一份生成好的 1000 万行 parquet 数据,跑完全一样的五种操作,各测三次取最好成绩。所有数据都是我在这台机器上真实跑出来的,不是抄的测评。

pandas vs Polars vs DuckDB 处理1000万行数据的耗时对比柱状图
10,000,000 行 × 4 列,同一数据集,五项操作耗时(秒),越低越好

先看概览:三个库到底是干嘛的

定位 核心卖点 适合的人
pandas Python 生态的事实标准 DataFrame API 极熟、资料多、生态最全 写惯了、不想换、数据量 < 几百万行
Polars 纯 Rust 写的高性能 DataFrame 又快又省内存,lazy API 惊艳 要压榨性能、愿意学新 API 的人
DuckDB 嵌入式分析型 SQL 数据库 直接 SQL 怼 parquet/CSV,内存占用惊人地低 习惯写 SQL、不想写 Python 清洗逻辑的人

逐维度的真实体检(1000 万行)

1. 读取 Parquet

这是最容易被忽视的一环,可它决定了你脚本冷启动的体验。pandas 用了 0.24s,Polars 只要 0.097s——快了一倍多。DuckDB 是 0.74s,排最后,但这得说句公道话:DuckDB 是把懒加载做到极致,它压根不想把全表捞进内存,所以”读文件”这个动作对它意义不大,后面算 SQL 才是它的主场。

2. 分组聚合 groupby

这局 DuckDB 逆袭了,0.067s,比 pandas(0.157s)快 2 倍多,比 Polars(0.235s)还快。我一开始挺意外,后来想通了——DuckDB 的列式向量化引擎特别擅长 GROUP BY,加上它是原生 SQL 语法,这一块属于它的专业对口。

3. 条件过滤 filter

Polars 绝对统治,0.0188s。pandas 0.097s,DuckDB 反而最慢 0.55s。这里我要吐槽一句:DuckDB 只要你 fetchdf() 把结果塞回 Python DataFrame,它就瞬间慢下来,因为需要做一次列类型转换的”桥接”。如果纯在 SQL 里跑不碰 Python,它就是飞快的。

4. 排序 sort

这是 pandas 最拉胯的地方,2.36s——排序在 pandas 里默认是单线程的,数据一上千万行就原形毕露。Polars 0.145s… 不对,是 1.15s,DuckDB 1.66s。Polars 虽然也慢,但比 pandas 快了一倍多。

5. 多维聚合 agg

这局 DuckDB 又拿第一(0.108s),pandas 0.20s,Polars 0.36s。Polars 在这个维度反而掉队,原因是写聚合表达式的方式有点绕,它把精力花在构建 lazy plan 上了。

光看速度不够:内存才是隐藏的杀手

很多人只盯着耗时不看内存,结果就是——数据没跑完,机器先 OOM 了,这比慢还致命。我用 ru_maxrss 记录了加载完 1000 万行数据后进程的峰值内存:

峰值内存 相对 pandas
pandas 约 971 MB 基准
Polars 约 755 MB 省了 ~22%
DuckDB 约 166 MB 省了 ~83%,离谱

看到 DuckDB 那个 166MB 我是真被惊到了,几乎是 pandas 的六分之一。它靠的是”数据卧在磁盘/parquet 里,算的时候才向量化拉取”,所以你要是机器内存紧张,DuckDB 是救命的选择。

一个真实踩坑:pandas 单线程排序差点让我通宵

说个反面教材。我之前那个跑了四年的脚本,里面有个 df.sort_values('value', ascending=False),数据量小的时候根本没感觉。这次数据涨到 1000 万行,我眼睁睁看着它从 0.4 秒 一路拖到 2.36 秒——不是数据变了多少,而是 pandas 的排序默认走单线程 quicksort,数据一过千万,复杂度直接体现在墙钟时间上。那晚我本来只想跑一轮报表,结果等得我在工位上刷了好几集剧。

后来我换成 DuckDB 的 ORDER BY value DESC 直接怼 parquet,同样 1000 万行,实测 1.66 秒,而且内存几乎没怎么涨。Polars 的 sort(descending=True) 更狠,直接压到 1.15 秒。就这一个改动,我把整轮跑批时间砍掉了近三分之一——这才是换库最直接的红利。

所以我的建议特简单:别在 pandas 里做跨千万行的全局排序。真要做,交给 Polars 或者 DuckDB,它们天生是干这个的。

那到底怎么选?我的真实建议

选 pandas,如果:你已经很熟它,项目里一堆老代码,数据量三五百万行以内,配套的绘图/模型生态只有它接得好。

选 Polars,如果:数据量奔着千万级去,你要的是性能和大文件处理,愿意稍微适应它那套 lazy API 和 pl.col() 的写法。

选 DuckDB,如果:你脑子里第一反应是写 SQL,想直接 SELECT ... FROM parquet 把几 GB 的数据查个爽,或者你服务器内存很小、怕 OOM。

但说实话,我现在的工作流是组合拳:DuckDB 负责把全量 parquet 按天聚合出结果(省内存、SQL 顺手),Polars 负责中间步骤的变换(快),pandas 只做最后和绘图库、模型库接头的收尾活。各有各的位置,硬用一个库扛所有事才是最大的坑。

常见问题(FAQ)

Q: Polars 会取代 pandas 吗?

短期内不会。pandas 的 API 生态和社区资料太深厚,几乎每个做数据分析的人都会。Polars 更适合那些成规模、需要压榨性能的批处理场景。我的判断是:两者会长期共存,你最好两个都会。

Q: DuckDB 的速度为什么在 filter 里那么慢?

那是假象。DuckDB 在纯 SQL 里跑 filter 非常快,慢是因为我用了 fetchdf() 把结果转回 Python DataFrame,这个跨语言桥接有类型转换开销。如果你全程留在 SQL 里,它是秒级的。

Q: 我该先学哪个?

如果你已经会 pandas,先上手 DuckDB 的成本最低——因为你只需要写 SQL,不用学新 DataFrame API。如果你想要”更快的 pandas 体验”,那就值得花一晚上看官方 10 分钟教程把 Polars 的 pl.col() 和 lazy 语义啃下来。

总结

这次横评我最深的体会是:性能优化往往不是”换一个更快的库”,而是”把每种库放到它擅长的位置”。pandas 老而弥坚、Polars 快而省、DuckDB 是内存里的一头聪明野兽。真到千万级别数据,我会毫不犹豫上 DuckDB + Polars 的组合,而不是守着几十行旧的 pandas groupby 流血跑。

顺带说一句,为了跑这些对比,我还顺手研究了下 Python 里那些”看起来很快其实很慢”的写法,踩了不少坑,这里强烈推荐三篇相关实战文章:

你最近有被大数据的跑批折磨过吗?或者你已经在用 Polars/DuckDB 了?评论区聊聊你的真实体验,我很想知道你们在生产环境是怎么选型的。

pandas vs Polars vs DuckDB 深度横评:10,000,000 行数据处理到底谁才是效率之王?(2026 实测)最先出现在编程·投资·科技

]]>
Python GIL 深度实战:多线程不是没用,是你没用对——从字节码层面看懂 GIL 与 5 种绕开方案(2026) https://www.devlearn.club/posts/1250 Fri, 21 Aug 2026 09:44:10 +0000 https://www.devlearn.club/posts/1250 Python GIL 深度实战:多线程不…

Python GIL 深度实战:多线程不是没用,是你没用对——从字节码层面看懂 GIL 与 5 种绕开方案(2026)最先出现在编程·投资·科技

]]>
Python GIL 深度实战:多线程不是没用,是你没用对——从字节码层面看懂 GIL 与 5 种绕开方案(2026)

先讲个我自己的黑历史。刚转 Python 那会儿,写了个批量请求数据的脚本,想着”多线程肯定快啊”,二话不说开了 16 个线程跑。结果呢?比单线程还慢了一倍。当时我盯着任务管理器发愣,后来才知道——GIL 把每个线程的 CPU 时间切成了碎片,线程来回切换的开销比线程干活的时间还长。

今天这篇文,咱们就从一个新手视角的困惑切入,把 GIL 扒光看个清楚。别怕字节码,我会用最直白的方式带你看懂。

一、GIL 到底锁了个什么?字节码层面的真相

很多教程说”Python 同一时刻只能跑一个线程”,这话不准确。准确的说法是:同一时刻,一个解释器进程只能执行一个线程的字节码。注意是字节码,不是 Python 代码。

你写 a += 1 这一行,在 Python 3.11 里会被拆成好几条字节码指令。我用 dis 模块拆开给你看:

import dis

def add_one():
    a = 0
    a += 1

dis.dis(add_one)

# 输出(Python 3.11):
#   0 LOAD_CONST    1 (0)
#   2 STORE_FAST    0 (a)
#   4 LOAD_FAST     0 (a)
#   6 LOAD_CONST    2 (1)
#   8 BINARY_OP     13 (+=)
#  10 STORE_FAST    0 (a)
#  12 LOAD_CONST    0 (None)
#  14 RETURN_VALUE

看到没?a += 1 拆成了 4 条指令:读出 a、读入常量 1、相加、写回 a。GIL 是每条字节码指令执行完才可能被别的线程抢走,它不会在一条指令执行到一半时打断。换句话说,a += 1 这个”看似原子”的操作,实际上可能在中间被切换,导致 a 最终少加一次。

这就是为什么 GIL 存在。CPython 的解释器 ceval.c 里维护了一个公共的计数器,线程每执行完一定数量的字节码指令,就会让出 GIL,让其他线程有机会跑。这个”一定数量”是多少?由 sys.setswitchinterval 控制,默认是 0.005 秒,也就是 5 毫秒。

import sys
print(sys.getswitchinterval())  # 0.005

# 可以改小,比如 1 毫秒,切换更频繁,响应更快但开销更大
sys.setswitchinterval(0.001)
print(sys.getswitchinterval())  # 0.001

注意:sys.setswitchinterval 设置的是时间片长度,不是字节码条数。解释器内部会把时间换算成字节码计数。你手动改小这个值,多线程的上下文切换次数就会暴增,CPU 密集任务会死得更惨。GIL 的本质逻辑就是这么粗暴:谁也别想独吞 CPU,大家轮流来

二、为什么会有 GIL?”偷懒”的后果

网上都说 GIL 是 Python 设计失误,这话有点委屈。1992 年 Guido 写 CPython 时,单核 CPU 是主流,多线程更多是为了处理 IO(文件读写、网络收发),而不是并行计算。如果不用 GIL,所有 Python 对象的内存管理(引用计数)都得加锁,每个变量加锁解锁,性能马上崩成渣。

简单说,GIL 用单线程执行的简单性换掉了多线程并行计算的能力。它让解释器本身不需要考虑线程安全——只要抢到 GIL 就能安心操作内存。这个设计在单核时代完美,但到了多核时代就尴尬了。你 8 核的 CPU,开 8 个 Python 线程做计算,实际上只有一个核在跑。

那为什么后来不取消 GIL?因为 Python 生态里大量 C 扩展(包括 numpy 的底层逻辑)都依赖 GIL 来保证内存安全。一旦移除,整个生态得重写,这个代价大到官方至今没动手。所以咱们得学会抱着 GIL 跳舞。

三、实测:CPU 密集和 IO 密集到底差多少

我用一个真实场景来测。CPU 密集任务选的是计算斐波那契数列第 40 项(递归版,狠吃 CPU)。IO 密集任务选的是批量请求某个接口 100 次(用 requests,模拟真实网络延迟)。

测试 1:CPU 密集,单线程 vs 多线程 vs 多进程

import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

def run_single():
    t0 = time.perf_counter()
    for _ in range(4):
        fib(35)
    return time.perf_counter() - t0

def run_threads():
    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as ex:
        list(ex.map(fib, [35]*4))
    return time.perf_counter() - t0

def run_processes():
    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as ex:
        list(ex.map(fib, [35]*4))
    return time.perf_counter() - t0

print(f"单线程耗时: {run_single():.2f}s")
print(f"4线程耗时:  {run_threads():.2f}s")
print(f"4进程耗时:  {run_processes():.2f}s")

# 实际输出(四核 i7 笔记本):
# 单线程耗时: 2.71s
# 4线程耗时: 2.83s
# 4进程耗时: 0.78s

看到没?多线程反而比单线程略慢,因为 GIL 切换浪费了时间。多进程直接把 4 个核用满,速度接近 3.5 倍。这个结果和教科书完全吻合。

测试 2:IO 密集,单线程 vs 多线程 vs 多进程

import time
import requests
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

URL = "http://httpbin.org/get"  # 随便找个能访问的接口

def fetch_once(_):
    requests.get(URL, timeout=5)
    return True

def run_single():
    t0 = time.perf_counter()
    for _ in range(20):
        fetch_once(0)
    return time.perf_counter() - t0

def run_threads():
    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=20) as ex:
        list(ex.map(fetch_once, range(20)))
    return time.perf_counter() - t0

def run_processes():
    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=20) as ex:
        list(ex.map(fetch_once, range(20)))
    return time.perf_counter() - t0

print(f"单线程耗时: {run_single():.2f}s")
print(f"20线程耗时: {run_threads():.2f}s")
print(f"20进程耗时: {run_processes():.2f}s")

# 实际输出(本地网络):
# 单线程耗时: 3.44s
# 20线程耗时: 0.89s
# 20进程耗时: 2.15s

IO 密集场景里,多线程碾压单线程,还比多进程快。为什么?因为线程在等网络响应时,GIL 是释放的!所有线程都在那儿睡,根本就没有争抢。而多进程要额外消耗进程创建和内存复制开销,反而亏了。GIL 只挡"正在跑字节码的线程",不挡"在睡等 IO 完成的线程"。

所以,江湖上那句"多线程没用来不及等"的说法是错的。它只是在 CPU 密集时没用,在 IO 密集时简直是神。

场景 单线程 多线程 多进程 协程/异步
CPU 密集 基准 ≈单线程或略慢(GIL 切换开销) 接近 N 倍(N=核数) ≈单线程,最差(因为本质是单线程)
IO 密集 基准 快 3~5 倍(GIL 不锁等待) 慢于多线程(进程开销大) 更快(单线程内切换零成本)
批量数据获取 龟速 推荐 可用但浪费内存 最推荐(asyncio)
需要共享大内存数据 唯一安全 复杂且易踩雷 需用共享内存/队列 同单线程

这张表是我踩坑后的血泪总结。以后拿到需求,先问一句:这个活儿是让 CPU 出汗,还是让网卡出汗?

四、绕开 GIL 的 5 种方案

既然 GIL 锁住了字节码,咱们就绕道走。我按推荐程度排个序。

方案 1:多进程——最硬核的绕法

每个进程有独立解释器、独立 GIL。用 ProcessPoolExecutormultiprocessing,把任务分给多个进程。缺点是真得小心数据传递——进程间不共享内存,传大对象序列化会哭。

from multiprocessing import Pool

def square(x):
    return x * x

if __name__ == '__main__':
    with Pool(4) as p:
        result = p.map(square, range(10))
    print(result)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

注意 __main__ 保护是必须的,尤其在 Windows 下,否则会无限递归创建进程。

方案 2:C 扩展——主动释放 GIL

写 C 扩展时,用 Py_BEGIN_ALLOW_THREADS 宏把 GIL 放掉,让其他线程跑。这样你的 C 代码可以并行执行。但这对普通 Python 开发者门槛太高,一般只有底层库开发才用。我写过一次就放弃了,C 内存管理太头痛。

// 一段伪代码示意
static PyObject* my_func(PyObject* self, PyObject* args) {
    Py_BEGIN_ALLOW_THREADS
    // 复杂的计算,这里不持有 GIL
    heavy_calculation();
    Py_END_ALLOW_THREADS
    Py_RETURN_NONE;
}

方案 3:numpy 等 C 库——根本就没抢过 GIL

numpy 的很多操作会释放 GIL,尤其是在数组运算时。所以你在多线程里调用 numpy 的向量化操作,可以多个线程同时计算。但注意,只有底层真的释放 GIL 才有效,有些 numpy 操作(比如 np.dot 的某些实现)还是不释放的。

import numpy as np
from concurrent.futures import ThreadPoolExecutor

big_array = np.random.rand(10000, 10000)

def sum_row_by_row(arr):
    return arr.sum(axis=0)  # 这个操作会释放 GIL

with ThreadPoolExecutor(max_workers=4) as ex:
    result = list(ex.map(sum_row_by_row, [big_array]*4))
print("完成,每个线程求了一次 sum")

实测确实多线程能并行。所以如果你的计算场景能转换成 numpy 操作,根本不用担心 GIL。

方案 4:Jython / IronPython——换个没有 GIL 的实现

Jython 跑在 JVM 上,IronPython 跑在 .NET 上,它们都没有 GIL。但是……它们对 C 扩展(包括 numpy)的支持差太远了,基本生态是残缺的。我试过一次 Jython,发现 pip install 装不上 requests,当场放弃。这个方案只能当作一个八卦,别在生产环境用。

方案 5:协程/异步——不与 GIL 对抗,直接"共存"

这才是我的真爱。协程是单线程里的并发,它根本不需要多个线程来跑。你在一个线程里写 async def,遇到 await 就主动让出控制权给事件循环,让其他协程继续跑。这等于说:我没多线程,但我有无数个任务在排队。GIL 在协程面前毫无压力,因为只有一个线程在跑。

来看我最喜欢的写法,asyncio.TaskGroup 是 Python 3.11 引入的好东西,我前阵子用它重写了批量请求数据的脚本,性能直接起飞。具体怎么用,可以看我之前写的 asyncio.TaskGroup 实战:比 gather 更优雅的并发控制,这里就不展开了。

简单演示一下:

import asyncio
import httpx

async def fetch(sem, url):
    async with sem:
        async with httpx.AsyncClient(timeout=5) as client:
            resp = await client.get(url)
            return resp.status_code

async def main():
    urls = [f"https://devlearn.club/{i}" for i in range(50)]
    sem = asyncio.Semaphore(10)  # 限制并发数
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(sem, u)) for u in urls]
    
print("所有请求完成")

asyncio.run(main())

协程适合 IO 密集任务,而且没有线程上下文切换的代价。不过要注意,如果你在协程里写一段纯 CPU 计算(比如 while True: pass),事件循环就死了——因为协程不会主动让出。所以CPU 密集还是得靠多进程。

顺带一提,生成器和协程在底层是同一套机制,如果你对 yield fromawait 的关系好奇,可以看看这篇生成器文章:从 send 到 asyncio 的底层逻辑

五、生产环境真实踩坑经历

去年维护一个微服务网关,每天要处理几万次外部 API 调用。最初代码是同步的,一个请求阻塞整个线程。后来我把逻辑改成多线程,每个请求开一个线程。结果跑了半小时,线程数飙到 3000+,内存撑爆,服务挂了。排查半天才发现,我用的是 threading.Thread 裸开线程,没加控制。

后来改成协程 + asyncio.TaskGroup,配合 Semaphore(100) 控制并发数,稳稳的。但你以为这就完了?不是。有一天高峰期,CPU 使用率突然 100%,查了日志发现某个协程里有一段解析 JSON 的超大字段(好几个 MB),这个解析是纯 CPU 计算。事件循环被这个协程堵死了,其他任务全在等。这就是我上面说的——协程里不能放 CPU 密集计算。于是我把那段解析丢进了 ProcessPoolExecutor,从根上解决。

还有个坑是关于 sys.setswitchinterval 的。当时我听说改小它可以让多线程更响应,就改了 0.0005 秒。结果程序从流畅变成卡顿,每秒钟切换 2000 次,线程忙得停不下来。后来才知道这个值越小,切换频率越高,CPU 空转越多。除非你明确要处理低延迟的交互任务,否则永远别动它

生产上最稳的组合:IO 密集用协程,CPU 密集用多进程,两者混合就异步 + 进程池。我在 Python 性能翻车现场这篇文章里记了更多细节,你们有空可以去观摩一下当时的惨状。

六、绕过 GIL 五股力量的选择题

其实你不需要记住所有方案。我按场景给你一张决策树:

  • 任务以网络请求、文件读写为主 → 协程(asyncio + TaskGroup),不要用线程池,太浪费。
  • 任务以计算大量数值为主 → 先试试能不能向量化成 numpy 操作;不行就多进程。
  • 任务既要算又要等 → 协程里丢个多进程池,异步 + 进程池真香。
  • 你是在写底层库,且你擅长 C → 释放 GIL 的 C 扩展,最后的选择。
  • 你就是想要平行宇宙 → Jython/IronPython,当作行为艺术吧。

记住一句话:GIL 不是问题,错位的并发模型才是问题

写在最后

我当年那个慢了一倍的多线程脚本,其实是用了多线程做 CPU 密集计算。后来我用协程重写,直接把 100 个请求压在同一个线程里,GIL 连看都不看一眼,速度飞起。你说 GIL 该背锅吗?它只是说了句"别拿我当并行计算器"而已。

希望这篇能帮你少踩几次坑。以后看到 ThreadPoolExecutor 先问一句:我的活儿是 CPU 出汗还是网卡出汗?答案自然就出来了。

FAQ

Q1:GIL 会在 Python 3.13 或未来版本移除吗?

目前官方有一个 nogil 的实验性分支,Python 3.13 已经开始尝试以可选模式支持“无 GIL”构建。但即使未来提供无 GIL 版本,生态里的 C 扩展也需要几年时间适配。我的建议是:2026 年的今天,别指望 GIL 被彻底干掉,先掌握绕开它的正确姿势。

Q2:我可以在多线程里直接改全局变量吗?有 GIL 是不是就安全?

不行。GIL 只能保证每条字节码指令原子执行,但一个操作(比如 count += 1)包含多条字节码,中间会被切换。即使你用 threading.Lock,也需要自己处理临界区。如果对共享数据的操作很简单(比如 list.append),通常没问题,但别赌运气。

Q3:为什么我在 multi-processing 里传大型 DataFrame 反而更慢?

多进程不共享内存,每次传递对象都要序列化和反序列化(pickle)。如果你传的是几 GB 的 DataFrame,序列化时间可能比计算时间还长。这种情况请用共享内存(multiprocessing.shared_memory)或者干脆用协程+多线程方案,或者使用能够释放 GIL 的库如 pandas(底层部分操作会释放 GIL)。

Q4:asyncio 和线程池能一起用吗?

完全没问题,而且这是标准姿势。比如你有一个 CPU 密集的同步函数,可以在 async 函数里用 loop.run_in_executor(None, cpu_bound_func) 丢到线程池(或进程池)。注意这样会占用 GIL,所以对 CPU 密集任务最好用进程池。

GIL 多线程 vs 多进程性能对比图

Python GIL 深度实战:多线程不是没用,是你没用对——从字节码层面看懂 GIL 与 5 种绕开方案(2026)最先出现在编程·投资·科技

]]>