所属专题:Python 专题导航:基础语法、工具与实践

Django 性能优化实战:SQLite 查询、锁等待与缓存边界

Django 性能优化实战:SQLite 查询、锁等待与缓存边界 - 暂无配图,技术文章默认封面

Django 性能优化实战:SQLite 查询、锁等待与缓存边界

Django 网站变慢时,问题不一定来自模板或网络。一个使用 SQLite、uWSGI 多进程和访问记录中间件的站点,可能同时遇到慢查询、N+1 查询、写锁等待、缓存不共享和后台统计聚合过重等问题。单纯增加 worker、把缓存时间调长,往往只能改变现象,不能消除瓶颈。

本文以小型 Django 内容站为例,建立一套从数据到代码的性能排查流程。重点回答四个问题:慢在哪里,怎样用证据确认,SQLite 在什么场景下仍然合适,以及什么时候应该迁移到 PostgreSQL 等服务型数据库。文中的命令和代码是可复查的示例,不把一次本地测试结果写成所有站点都能得到的性能承诺。

文章重点与站内其他性能文章区分开:网站响应速度慢怎么排查关注 DNS、TLS、Nginx、uWSGI 和浏览器之间的完整请求链路;Django 文章页 Core Web Vitals 实战关注 LCP、CLS 和 INP;Django API 分页、缓存与数据库查询优化更偏向接口设计;本文把范围收窄到 Django ORM、SQLite 写入和进程级缓存。

数据库版本、Django 版本、硬件和并发模型都会影响结果。任何优化都应在自己的数据量、请求样本和部署方式下验证。

一、先把“Django 很慢”拆成具体问题

性能问题必须先分类。下面几种现象看起来相似,处理方法却完全不同:

现象 常见原因 第一份证据
每次请求都慢 查询过多、模板渲染或外部调用 请求耗时、查询数量
列表越长越慢 N+1 查询、未分页、排序不命中索引 SQL 数量、查询计划
偶尔卡住 SQLite 写锁、磁盘 I/O、进程争用 database is locked、慢请求时间
发布后页面仍显示旧内容 多进程 LocMemCache、代理缓存未失效 响应版本、缓存命中信息
后台统计越来越慢 全表聚合、缺少时间索引、访问记录过多 SQL 计划、表行数
CPU 不高但响应排队 worker 被 I/O 或锁等待占满 uWSGI 请求队列、进程状态

先记录问题出现的 URL、时间、用户类型和数据规模,再决定是否需要改 ORM、索引、事务、缓存或数据库。没有基线时,调高连接数和 worker 数量很容易让结果变得不可解释。

二、建立可重复的性能基线

记录请求层时间

可以先用 curl 区分首字节等待和响应体传输。连续请求至少运行几次,并记录中位数,而不是只看一次最好的结果:

url='https://blog.zenleak.cn/post/316/'
for i in 1 2 3 4 5; do
  curl --silent --show-error --location --output /dev/null \
    --write-out 'status=%{http_code} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n' \
    "$url"
done

TTFB 较高说明请求在服务器处理、代理或网络阶段等待较久;TTFB 正常但总时间较高,可能是响应体、压缩或网络带宽问题。这个命令不能告诉你某条 SQL 花了多久,需要结合应用日志或测试环境的查询统计。

使用 Django 测试统计查询数量

开发环境可以用 assertNumQueries 给关键视图建立回归约束。示例中的 URL 和用户登录方式要替换成项目实际配置:

from django.test import TestCase
from django.urls import reverse

from blogapp.models import Post


class PostListQueryTests(TestCase):
    def test_post_list_query_count_does_not_grow_with_rows(self):
        for index in range(20):
            Post.objects.create(
                title=f"测试文章 {index}",
                slug=f"query-test-{index}",
                content="内容",
                status="published",
            )

        with self.assertNumQueries(4):
            response = self.client.get(reverse("blogapp:post_list"))

        self.assertEqual(response.status_code, 200)

这个例子有两个限制:模型必填字段、作者和模板查询可能因项目不同而变化;查询数量 4 也不是通用标准。它的价值是锁定一个已知页面的基线,防止以后新增作者、标签或分类展示时悄悄引入 N+1 查询。

用日志关联请求和数据库操作

生产环境不应把完整 SQL、用户数据或敏感参数直接写入公共响应。应用日志至少应包括请求路径、状态码、耗时、查询数量和请求 ID。Django 的数据库查询日志适合开发和短时间诊断,长期生产日志应控制采样率和留存期限。

import time
import uuid

from django.db import connection


class RequestTimingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        started = time.perf_counter()
        request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:16]
        before = len(connection.queries)
        response = self.get_response(request)
        elapsed_ms = (time.perf_counter() - started) * 1000
        query_count = len(connection.queries) - before
        response["X-Request-ID"] = request_id
        # 接入项目日志系统时记录这些字段,不要把完整 SQL 放进响应头。
        request.logger.info(
            "request path=%s status=%s elapsed_ms=%.1f queries=%s request_id=%s",
            request.path,
            response.status_code,
            elapsed_ms,
            query_count,
            request_id,
        )
        return response

connection.queries 只有在 Django 调试查询记录启用时才会完整记录,生产环境不要为了统计查询而长期打开 DEBUG。更稳妥的方式是用数据库监控、采样日志或测试环境复现慢请求。上面的 request.logger 也只是示意,真实项目应在中间件初始化时注入标准 Python logger。

三、Django ORM:先解决 N+1 查询

什么是 N+1

假设文章列表先执行 1 次查询,模板每显示一篇文章的作者、分类和标签又各执行查询,那么 20 篇文章可能产生几十次额外访问。数据库单次查询不一定慢,但总数量会随列表长度增长。

常见症状是:

  • 首页只有几篇文章时感觉正常,分页数量增加后明显变慢;
  • 查询数量随列表行数线性增长;
  • 模板访问 post.author.username、post.category.name 或 post.tags.all;
  • 后台列表加入一个关联字段后,耗时突然上升。

外键和一对一关系可以通过 SQL JOIN 一次取出:

posts = (
    Post.objects
    .filter(status="published")
    .select_related("author", "category")
    .order_by("-published_at", "-id")[:20]
)

如果模板还访问了作者的其他字段,应在测试中确认没有触发额外查询。select_related 不适合无限扩展关联,关联表过多或字段过大时,JOIN 结果也可能变得笨重。

标签是多对多关系,适合单独查询后在 Python 中组装:

from django.db.models import Prefetch


visible_tags = Tag.objects.only("id", "name").order_by("name")
posts = (
    Post.objects
    .filter(status="published")
    .select_related("author", "category")
    .prefetch_related(Prefetch("tags", queryset=visible_tags))
    .order_by("-published_at", "-id")[:20]
)

prefetch_related 通常会增加一次或多次固定查询,但避免每篇文章重复查询标签。使用后仍要检查模板是否访问了没有预取的反向关系,尤其是评论、附件和相关文章列表。

only() 要谨慎使用

只选择模板需要的字段可以减少传输,但如果模板访问了未选择的字段,Django 可能再次查询数据库:

posts = (
    Post.objects
    .filter(status="published")
    .select_related("category")
    .only("id", "post_id", "title", "excerpt", "published_at", "category__name")
    .order_by("-published_at", "-id")[:20]
)

only() 不是默认优化项。先用查询统计确认瓶颈,再逐个减少字段,并执行真实模板测试。如果文章正文、图片字段或作者字段仍会被访问,过度 only() 反而会造成隐式查询。

四、分页、排序和索引要一起考虑

列表必须有上限

一次取出全部文章会同时增加数据库、Python 内存、模板渲染和响应体压力。分页不仅是用户界面功能,也是查询边界。分页大小应结合移动端阅读、页面响应体和数据库执行计划确定。

from django.core.paginator import Paginator


queryset = (
    Post.objects
    .filter(status="published")
    .select_related("category", "author")
    .order_by("-published_at", "-id")
)
paginator = Paginator(queryset, 20)
page = paginator.get_page(request.GET.get("page"))

分页参数必须经过 Django 的 Paginator 处理,不要把用户输入直接拼接进 SQL。深页分页在数据量很大时可能需要游标或基于主键的范围查询,但普通技术博客首先应保证查询有确定排序和合理上限。

排序要稳定

只按 published_at 排序时,如果多篇文章时间相同,分页边界可能在不同请求间变化。使用 -published_at, -id 这样的稳定排序,可以减少重复或漏项。排序字段要和过滤条件一起检查索引是否有帮助。

用查询计划验证索引

Django 可以输出数据库查询计划:

query = (
    Post.objects
    .filter(status="published")
    .order_by("-published_at", "-id")
)
print(query.explain())

SQLite 的执行计划会显示是否使用索引、是否创建临时排序结构。不要看到 SCAN 就立即加索引:小表全表扫描可能更快,新增索引还会增加写入、备份和磁盘空间成本。应使用接近生产规模的数据和真实过滤条件对比。

索引应匹配常用查询

常见文章查询可能同时过滤状态并按发布时间排序,组合索引有时比单字段索引更合适:

class Post(models.Model):
    # 字段定义省略
    class Meta:
        indexes = [
            models.Index(
                fields=["status", "-published_at", "-id"],
                name="post_status_pub_id_idx",
            ),
        ]

索引字段顺序必须根据实际 WHERE 和 ORDER BY 验证。索引名称要符合数据库长度和项目命名约定;迁移后应在备份、测试和生产发布流程中记录执行时间。不要为每个可能的查询都创建索引,写入频繁的访问记录表尤其要控制索引数量。

五、SQLite 锁等待:先减少写入,再谈超时

SQLite 的约束

SQLite 把数据库存储在文件中,适合开发、低并发内容站和读多写少的应用。它支持并发读取,但写入需要协调文件锁。多个 uWSGI worker 同时写访问记录、浏览量、评论或后台统计时,可能出现:

  • database is locked;
  • 请求耗时偶尔突然升高;
  • 清理任务与公共请求同时运行时变慢;
  • 写入高峰期间读请求也受到等待影响。

增大 timeout 不是根治

Django 的 SQLite timeout 只能让连接等待锁的时间更长。它可以减少短暂竞争导致的立即失败,但不能提高数据库写入吞吐;锁没有释放时,请求只是更久地排队。调整前先找出谁在写入、事务持锁多久以及是否真的需要每次请求写一行。

减少公共 GET 的同步写入

访问记录、阅读计数和统计聚合不一定需要阻塞用户响应。可以考虑:

  1. 只记录必要字段,避免把完整请求头和大段来源写入数据库;
  2. 对重复访问或静态资源请求采样,不让每个资源都产生记录;
  3. 将浏览量先写入进程内计数或队列,按时间窗口批量落库;
  4. 把清理和 GeoIP 补充任务安排在低峰期;
  5. 缩短事务范围,不要在事务中做网络请求或复杂计算。

批量写入也要控制批次大小。一次事务太大,会增加锁持有时间;事务太碎,则增加提交开销。具体批次应通过日志和数据库测试确定。

用原子表达式保护计数更新

如果确实需要给文章增加一次浏览量,应使用原子表达式,避免并发读改写覆盖:

from django.db.models import F


updated = (
    Post.objects
    .filter(pk=post.pk, status="published")
    .update(views=F("views") + 1)
)

这段代码减少了应用层先读取再写回的竞态,但仍然是一次数据库写入。在访问量较大的站点,应该评估是否要把计数放到专门的缓存或事件系统中,再按规则汇总到文章表。

六、访问记录表的查询和留存

访问分析后台常见的慢点不是单次访问,而是按天、状态码、爬虫类型、国家和设备组合筛选大表。站内Django访问分析后台设计介绍了字段和分类边界,Django部署后的日志排查与性能监控介绍了请求链路证据;数据库层面还要考虑留存、索引和聚合方式。

先确定留存期限

记录保留越久,后台查询和备份成本越高。本站目前按 30 天留存访问记录。清理任务应按时间字段批量删除,并记录删除数量和耗时:

from datetime import timedelta

from django.utils import timezone

cutoff = timezone.now() - timedelta(days=30)
deleted, _ = Visitor.objects.filter(timestamp__lt=cutoff).delete()
print(f"deleted={deleted} cutoff={cutoff.isoformat()}")

生产清理脚本需要按实际模型字段调整,并避免在高峰期一次删除数百万行。如果表很大,可以按主键或时间窗口分批删除,再执行必要的数据库维护。删除策略应与隐私政策保持一致,导出统计时也要避免暴露完整 IP。

只为真实筛选建立索引

后台常用筛选可能是时间范围、机器人分类和状态码。是否建立组合索引,要根据实际 SQL 和数据分布决定。索引不是越多越好:它会增加每次写入成本、备份体积和迁移时间。访问记录字段变化较多时,先用查询计划找出最常用、最慢且稳定的筛选。

聚合可以缓存,但要说明新鲜度

国家分布、设备占比和爬虫类型统计不必每次打开后台都全表扫描。可以按 5 分钟或 1 小时缓存聚合结果,并在界面显示统计时间。缓存的目标是避免重复计算,不应把过期的统计伪装成实时数据。

七、Django 缓存的进程边界

LocMemCache 不是共享缓存

Django 的 LocMemCache 存在于单个 Python 进程内。uWSGI 使用多个 worker 时,worker A 写入的缓存,worker B 不一定能读取到。它适合短期、低风险、允许不一致的临时结果,不适合要求所有进程立即看到同一版本的公共页面缓存。

常见误判包括:

  • 发布后只有部分请求显示新文章;
  • 清理一个 worker 的内存后,其他 worker 仍保留旧数据;
  • 以为设置了 5 分钟缓存,所有请求都能复用同一结果;
  • 把会话、权限或用户状态页面放进不合适的共享缓存。

根据一致性需求选择缓存层

目标 适合的层 主要注意点
CSS、JS、字体和图片 浏览器、Nginx、CDN 文件名要有版本指纹
公共文章 HTML 反向代理或共享缓存 发布后需要明确失效
查询结果 Redis 等共享缓存 键要包含版本和筛选条件
登录态页面 短缓存或不缓存 不能混用用户内容
单 worker 临时数据 LocMemCache 不保证跨进程一致

如果当前没有 Redis,不要为了“使用缓存”而把所有内容放入 LocMemCache。短缓存可以减少单进程内的重复计算,但公共页面仍应有正确的失效策略。发布流程清理 Django 缓存后,还要检查 Nginx 或 CDN 是否有另一层缓存。

缓存键必须包含影响结果的条件

列表页的缓存键至少要考虑路径、页码、语言、筛选条件和内容版本。权限、登录状态和 Cookie 也可能影响响应。一个简单的查询结果缓存示例:

from django.core.cache import cache


def get_published_post_count(version):
    key = f"published-post-count:v{version}"
    value = cache.get(key)
    if value is None:
        value = Post.objects.filter(status="published").count()
        cache.set(key, value, timeout=300)
    return value

示例中的 version 应由项目在发布或内容变更时更新,不能把任意用户输入直接作为缓存键。缓存失败时要能回退到数据库查询,不能因为缓存服务暂时不可用就让公共页面返回错误。

八、何时应该迁移到 PostgreSQL

SQLite 并不是“低级数据库”,它在读多写少的小型站点上简单、可靠、便于备份。但当下面条件长期同时出现时,迁移到 PostgreSQL 或其他服务型数据库的收益会增加:

  • 多个 worker 持续产生较高频率的写入;
  • 访问记录、评论、任务队列和统计都依赖同一个 SQLite 文件;
  • 锁等待已经影响公共页面和后台操作;
  • 需要更强的并发事务、复杂聚合或全文搜索能力;
  • 需要独立的备份、监控、连接池和故障恢复;
  • 数据库文件增长和备份窗口已经成为运维负担。

迁移不是只修改 DATABASES 中的 ENGINE。至少需要:

  1. 评估字段、索引、唯一约束和时区数据类型;
  2. 在测试环境创建目标数据库并执行 Django 迁移;
  3. 通过可靠导出导入数据,核对文章、用户、访问记录和关联表数量;
  4. 在接近生产的查询和并发下做回归;
  5. 安排短暂切换窗口,保留可恢复的 SQLite 备份;
  6. 验证后台、公共页面、定时任务、sitemap 和搜索提交流程。

如果主要问题是每个请求都写访问记录,先减少写入和批量聚合,往往比立刻迁移数据库更容易验证。迁移可以解决并发和能力边界,但不会自动修复 N+1 查询或无上限列表。

九、用一套小实验验证改动

性能优化应一次聚焦一个变量。建议为每次改动记录:

  • 代码版本和数据库规模;
  • URL、请求方法、登录状态和网络条件;
  • 请求次数、平均值、中位数和较慢分位数;
  • SQL 数量、最慢查询和查询计划;
  • CPU、内存、磁盘 I/O 和锁等待;
  • 缓存命中或未命中的原因;
  • 修改前后的错误率和内容正确性。

可以用 Django 测试客户端做一个简单的查询数量回归:

from django.test import Client


client = Client()
response = client.get("/posts/")
assert response.status_code == 200
print("response_bytes", len(response.content))

这段代码只验证状态和响应大小,不能代替真实浏览器、数据库并发和 uWSGI 测试。性能报告中要同时保留内容正确性检查,不能为了减少查询而漏掉作者、标签或权限信息。

观察缓存是否造成错误

缓存优化后至少检查:

  • 新发布文章是否在首页、专题、RSS 和 sitemap 出现;
  • 草稿、登录页和后台页面是否没有被公共缓存;
  • 不同分页、标签和筛选条件是否返回对应内容;
  • 更新文章后旧摘要、标题和 canonical 是否被清除;
  • 多个 worker 连续请求是否得到可接受的一致结果。

性能提升不能以内容错误作为代价。对于技术博客,发布后页面、站点地图和 RSS 的正确性应优先于几百毫秒的缓存收益。

十、常见错误做法

只把 SQLite timeout 调大

这只会让请求等待更久。先找出写入来源、事务范围和清理任务,再决定减少写入、批量处理或迁移数据库。

盲目增加 uWSGI worker

worker 增加可以提高部分并发能力,但也会增加内存、SQLite 写竞争和进程切换。先确认瓶颈是 CPU、I/O、上游等待还是数据库锁。

给每个字段都加索引

索引提高部分查询速度,也会增加写入和备份成本。用真实 SQL、数据量和 EXPLAIN 验证后再添加,并在迁移记录中说明原因。

把 LocMemCache 当成 Redis

多进程之间不会共享同一份内存缓存。需要跨 worker 一致性时,应使用共享缓存或把失效设计为可接受的不一致。

用 only() 隐藏查询问题

减少字段不等于减少总查询。模板访问未选择字段时可能重新查询,必须通过真实模板和查询统计验证。

只看平均响应时间

平均值会掩盖锁等待和偶发慢请求。至少同时看中位数、较慢分位数、错误率和请求数量,并标注测试条件。

十一、适合小型 Django 博客的落地顺序

第一步:建立基线

选首页、文章页、列表页和后台访问记录页,记录 5 次请求的 TTFB、总时间、响应大小、查询数量和数据库行数。固定代码版本和测试条件。

第二步:修复查询结构

先处理 N+1、无上限列表和不稳定排序,再用查询计划检查过滤和排序。每次修改只解决一个明确问题,并保留测试结果。

第三步:减少同步写入

检查访问记录、浏览量、GeoIP 补充和统计任务是否在公共请求中同步执行。能采样、延迟或批量处理的内容,不要阻塞页面响应。

第四步:明确缓存边界

区分静态资源、公共内容、查询结果和登录态页面。确认缓存键、失效时机和多进程行为,发布后检查首页、专题和 RSS。

第五步:评估数据库迁移

当锁等待、写入量和运维需求超过 SQLite 的适用范围时,再在测试环境进行 PostgreSQL 迁移演练。迁移前保留可验证的备份和回滚方案。

结语

Django 性能优化的起点是证据,而不是某个固定配置。先用请求时间、查询数量、执行计划和锁日志确认瓶颈,再分别处理 ORM、分页、索引、写入、缓存和数据库边界,才能知道每次改动带来了什么结果。

对于读多写少的小型博客,SQLite 仍然可以胜任,但要控制同步写入、访问记录留存和后台聚合;当并发事务和运维需求超过文件数据库的边界,再迁移到 PostgreSQL。无论使用哪种数据库,查询结构、缓存失效和发布后的内容正确性都需要持续验证。

分享这篇文章:

评论 (0)

请 登录 后发表评论, 还没有账户?立即注册

暂无评论,快来抢沙发吧!