Django 优化数据库查询的一些经验

标签: Python cache Database django django-rest-framework | 发表时间:2021-08-20 20:06 | 作者:laixintao
出处:https://www.kawabangga.com

ORM 帮我们节省了很多工作,基本上不用写 SQL,就可以完成很多 CRUD 操作,而且外键的关联也会自动被 ORM 处理好,使得开发的效率非常高。我觉得 Django 的 ORM 在 ORM 里面算是非常好用的了,尤其是自带的 Django-admin,可以节省很多工作,甚至比很多公司内部开发的后台界面都要优秀。

但是 ORM 也带来了一些问题,最严重的就是,这些外键关联会去自动 Fetch,导致非常容易写出来 N + 1 查询,加上如果使用 django-rest-framework, Serializer 可以帮助你很方便地去渲染关联的外键,就更容易写出 N + 1 查询了。也是因为这个原因,之前在蚂蚁工作的时候,公司基本上是禁止(有一些小范围的内部项目还是可以使用)使用类似于 Hibernate 这种自动关联外键 ORM 的,都需要手写 SQL map,自己去 Join。但其实,我觉得这是一种非常粗暴的做法,用大量的人力换取降低错误出现的几率。这个问题是非常好解决的,我们只要对接口 Profile,看一下完成一次请求到底进行了哪些 SQL 查询即可,如果发现了 N + 1 就去解决。

这篇文章介绍 Django 中去 debug 和优化数据库查询的一些方法,其实对于其他的语言和框架,也差不多类似,也有同类的工具。

让我们从头开始思考,如何提升网站的性能。首先,最接近于用户的就是前端的代码,我们可以从前端开始 perf,看哪一个页面渲染用的时间最长,是请求 Block 住了,还是前端的组件写的性能差。如果是非常重要的面向用户的页面,可以系统性地使用一些监控工具发现性能问题。如果是对内的,其实只要自己去每一个页面点几下试试就可以发现性能问题了。如果自己没觉得卡顿,基本上就足够了。

对于内部的系统来说,如果前端不是写的出奇的差的话,性能问题一般都是由 API 慢引起的。所以本文就不过多介绍前端性能的优化了。

Slow API

发现耗时长的 API

Django 有一个 Prometheus 的 exporter 库, django-prometheus,可以使用这个库将 metrics 暴露出来,然后在 Grafana 上绘制每一个 view 的 P99/P95 等,发现耗时长的 API,然后针对性地进行优化,去 Debug 到底是慢在了哪里。

Debug

Django 的  django-debug-toolbar 是一个非常好用的工具。安装之后,设置好 debug 用的 IP,使用这个 IP 去访问的时候,它会自动对整个请求做 Profile,包括使用的 Cache,Template,Signal 等等。最有用的就是 SQL Profile 了。

它会把一个请求涉及的所有 SQL 都列出来,包括:

  • 这个 SQL 花了多少时间
  • 这个 SQL 是由哪一行代码触发的,类似于一个 traceback
  • 有多少个类似的 SQL,有多少个重复的 SQL (如果有的话,一般就是有问题了,意味着同样的 SQL 查询了多次)
  • 点击 Expl 可以很方便地看到这个 SQL 的 Explain
  • 点击 Sel 可以看到 SQL 的详情

如下所示:

然后就可以针对慢的 API 进行优化了。

解决

N + 1 Query

N + 1 查询是造成 API 慢的最常见的原因。比如这样一个需求:我们有一个列表页,对于列表中的每一个 Item,都要展示它相关的 Tag。如果使用 djagno-rest-framework 的 Nested relationships 的话,实际的查询会:

  1. 先查询出来当前列表页要展示的 item list
  2. 对于每一个 item,都去查询它的 tag

这就是 N + 1 查询。

解决的方法很简单,就是使用 Django 的 prefetch_related(),它的原理是使用 in 一次性将所有的外键关联的数据查询出来,然后在内存中使用 Python 做 “join”,这样就只会产生两个查询:

  1. 先查询出来列表页要展示的 item list
  2. 一次查询出来所有的 tag,使用 tag_id in (item_id, item2_id…)

参考一个官方文档中的例子:每一个 Pizze 都有不同的 Topping(浇头?)。

from django.db import models

class Topping(models.Model):
    name = models.CharField(max_length=30)

class Pizza(models.Model):
    name = models.CharField(max_length=50)
    toppings = models.ManyToManyField(Topping)

    def __str__(self):
        return "%s (%s)" % (
            self.name,
            ", ".join(topping.name for topping in self.toppings.all()),
        )

下面这个查询就是一个 N + 1,要先查出来所有的 Pizze,然后对于每一个 Pizza 去查询它的 Toppings:

>>> Pizza.objects.all()
["Hawaiian (ham, pineapple)", "Seafood (prawns, smoked salmon)"...

如果使用 prefetch 的话,就会只有 3 次查询(因为是一个 many-to-many 关系,所以要有一次是查询中间表的):

>>> Pizza.objects.all().prefetch_related('toppings')

 

注意只能使用 .all()

prefetch 其实是缓存了一个 queryset(), 如果查询条件改变了,Django 就必须重新发起查询。以下这个用法,就不会用到 prefetch 的 cache:

>>> pizzas = Pizza.objects.prefetch_related('toppings')
>>> [list(pizza.toppings.filter(spicy=True)) for pizza in pizzas]

因为 prefetch 已经把所有的数据查询到内存里面了,所以我们应该这么用,就不会触发新的查询了:

>>> pizzas = Pizza.objects.prefetch_related('toppings') 
>>> [pizza for pizza in pizzas 
         if any(topping.spicy==True for topping in pizza.toppings)
    ]

 

Prefetch()

prefetch_related() 所接收的参数,除了可以是一个 string 外,也可以是一个 Prefetch() 对象,可以用来更精确地控制 cache 的 queryset. 比如排序:

>>> Restaurant.objects.prefetch_related(
...     Prefetch('pizzas__toppings', queryset=Toppings.objects.order_by('name')))

也可以一次性 prefetch 多个外键(顺序很重要, 参考文档), Prefetch()string 可以混用:

>>> Restaurant.objects.prefetch_related( 
... Prefetch('pizzas__toppings', queryset=Toppings.objects.order_by('name')),
... "address")

 

many-to-many 和嵌套外键

对于嵌套的外键,可以用 __ 将 Model 的属性名字联合起来,比如这样:

>>> Restaurant.objects.prefetch_related('pizzas__toppings')

这样 pizzastoppings 都会被 prefetch.

 

select_related()

select_related() 也是有类似作用的一个功能,只不过他和 prefetch 的区别是:

  • prefetch_related() 是用 in 然后用代码 join
  • select_related() 是用 SQL 直接 join

显然, select_related() 触发的查询更少,一次查询就可以解决问题。但是它的功能也有限,不能支持嵌套的外键查询。

 

prefetch_related_objects()

以上的两个方法是用于 queryset 的,如果是对 object 的话,可以使用这个函数。

比如,我们要查询最近的一个订单关联的数据的话,可以这么使用:

latest_order = self.orders.order_by("-id").first()
models.prefetch_related_objects(
    [last_group],
    "item",
    "user_address",
)

Cached Property

Django 提供了一很实用的装饰器 @cached_property ,用这个替换 @property 的话,一个对象在读取这个 property 的时候只会计算一次,同一个对象在第一次之后来读取这个 property 都会使用缓存。

有点类似于 Python 中的 @lru_cache

 

减少不必要的展示字段

无论是 Cache 还是 prefetch 的方法,都是有一些复杂的。如果前端用户到一些字段,就没有必要一次性返回。

刚开始写 DRF 中的 Serializer 的时候,倾向于每一个 Model 都有一个 Serializer,然后这些 Serializer 都互相关联。最终,导致查询一个列表页的时候,每一个 item 相关的数据,以及这些数据相关的数据,都被一次性展示出来了。即使优化过后也难以维护。

后来总结出来一个比较好的实践,是每一个 Model 都有两个 Serializer:

  • ListSerialzer:对于所有的外键只展开一层,不展开外键的外键
    • 用于列表页 API 的显示
    • 这样查询的时候,只需要对于每一个外键查询一次 in 就可以了
  • DetailSerializer:按需求展示所有的外键
    • 用于详情页的渲染
    • 对于每一个外键关联的 row,可能都要再进行一次查询,把所有关联的外键都展开,方便展示。但是因为只有一个对象,所以也不会特别慢。但是依然要注意 N + 1,如果嵌套的太深,考虑不一次展示那么多,新提供一个 API 进行查询

这样的好处是我们可以按需进行 prefetch,List 页面的 API 只需要 prefetch 直接关联的外键就可以了,Detail 的 API 可以按需进行级联 prefetch. 总体的原则就是尽量避免多重外键的 prefetch.

值得一提的是在 django-rest-framework 中,是可以在同一个 ModelViewSet 里面,针对不同的 API,使用不同的 Serializer 的:

def get_serializer_class(self):
    if self.action == "list":
        return ExperimentListSerializer
    return super().get_serializer_class()

使用冗余字段

现在存储已经很便宜了,在合适的场景下,可以考虑直接将 fields 多存几份,节省查询。

比如我的一个场景是:一个 group 里面有个并行执行的 Execution,如果所有的 Execution 都执行完了,这个 group 就可以被认为是执行完了。

之前的实现是在 group 上定义一个 is_running 的字段,返回 group.execution_set.filter(is_running=True).exists()。这样每次都需要查询外键。

其实可以在 group 上保存一个 is_running 的字段,然后当 Execution 结束的时候顺便更新 group.is_running. ( Signal 其实不太好维护,我比较喜欢显式调用)。

这样的好处是:

  1. 方便查询,业务逻辑变得简单

缺点是:

  1. 另外肯定有某个地方的逻辑变得复杂了,因为要同步更新
  2. 可能又潜在的数据不一致

Slow SQL

随着数据越来越多,即使开发的环境中发现 API 造成的请求都很少,也很快,但是线上环境跑着跑着可能就有问题了。

所以最好对线上的 SQL 也进行观测。方法很简单,只要将查询时间 >1秒(或者其他时间)的 SQL log 出来就可以了。可以通过针对 Django ORM 设置 logging 配置来完成这件事:

添加一个新的 logger,然后 filter 类似于一下设置:

"filters": {
    "slow_sql_above_50ms": {
        "()": "django.utils.log.CallbackFilter",
        "callback": lambda record: not hasattr(record, "duration")
        or record.duration > 0.05,  # output slow queries only
    },
},

就可以将 SQL 日志过滤出来,然后只 log 请求时间 >50ms 的。

 

最后,以 Django 中的一句话作为结尾:

Always profile for your use case!

The post Django 优化数据库查询的一些经验 first appeared on 卡瓦邦噶!.

相关 [django 优化 数据库] 推荐:

Django 优化数据库查询的一些经验

- - 卡瓦邦噶!
ORM 帮我们节省了很多工作,基本上不用写 SQL,就可以完成很多 CRUD 操作,而且外键的关联也会自动被 ORM 处理好,使得开发的效率非常高. 我觉得 Django 的 ORM 在 ORM 里面算是非常好用的了,尤其是自带的 Django-admin,可以节省很多工作,甚至比很多公司内部开发的后台界面都要优秀.

数据库优化

- - 数据库 - ITeye博客
程序运行效率,优化应用程序,在SP编写过程中应该注意以下几点: . a) SQL的使用规范: .   i.尽量避免大事务操作,慎用holdlock子句,提高系统并发能力.   ii.尽量避免反复访问同一张或几张表,尤其是数据量较大的表,可以考虑先根据条件提取数据到临时表中,然后再做连接.   iii.尽量避免使用游标,因为游标的效率较差,如果游标操作的数据超过1万行,那么就应该改写;如果使用了游标,就要尽量避免在游标循环中再进行表连接的操作.

数据库优化小计

- - CSDN博客数据库推荐文章
周一夜间进行了一次XX业务相关的数据库表优化. 一共4张表,数据量不大,最小的40万记录,最大的300万,大小不超过300MB. 但由于历史原因,表没有建立索引,对应的服务使用的SQL千姿百态,修改起来难度有点大,容易改错,涉及的全国客户较多,大部分都是全表扫描,在秒级的响应时间,但大多客户还能忍着.

数据库的优化tips

- - CSDN博客数据库推荐文章
数据库   TIPS::. 1、用于记录或者是数据分析的表创建时::使用Id作为主键,1,2,3...表示消息条数,用户账号id用于做外键,一个用户对应唯一个accountId.                                     一个accountId可以对应多条数据;. 2、创建索引::    例如需要根据注册版本号和注册游戏ID来查询另外一些字段的时候,就可以根据版本号和游戏ID来创建索引::相当于就是根据查询条件来建索引;.

数据库查询优化

- - SQL - 编程语言 - ITeye博客
1 使用SET NOCOUNT ON 选项:.     缺省地,每次执行SQL语句时,一个消息会从服务端发给客户端以显示SQL语句影响的行数. 通过关闭这个缺省值,你能减少在服务端和客户端的网络流量,帮助全面提升服务器和应用程序的性能. 为了关闭存储过程级的这个特点,在每个存储过程的开头包含“SET NOCOUNT ON”语句.

MySQL数据库优化总结

- - CSDN博客推荐文章
        对于一个以数据为中心的应用,数据库的好坏直接影响到程序的性能,因此数据库性能至关重要. 一般来说,要保证数据库的效率,要做好以下四个方面的工作:数据库设计、sql语句优化、数据库参数配置、恰当的硬件资源和操作系统,这个顺序也表现了这四个工作对性能影响的大小.        一、数据库设计   适度的反范式,注意是适度的.

ORACLE数据库优化设计方案

- - CSDN博客推荐文章
本文主要从大型数据库ORACLE环境四个不同级别的调整分析入手,分析ORACLE的系统结构和工作机理,从九个不同方面较全面地总结了ORACLE数据库的优化调整方案. 关键词 ORACLE数据库 环境调整 优化设计 方案. 对于ORACLE数据库的数据存取,主要有四个不同的调整级别,第一级调整是操作系统级包括硬件平台, 第二级调整是ORACLE RDBMS级的调整,.

DB2数据库性能优化介绍

- - CSDN博客数据库推荐文章
作者:chszs,转载需注明. 博客主页: http://blog.csdn.net/chszs. 前段时间,我从CSDN得到了这本书《DB2数据库性能调整和优化(第2版)》,这是一本介绍DB2数据库性能调优的书籍,此书覆盖了DB2数据库性能调优所需的全部知识和工具,而且还提供了大量的性能调优的实际案例,颇有一种“一书在手,DB2尽在掌握”的豪情.

浅谈MySQL 数据库性能优化

- - BlogJava-qileilove
数据库是 IO 密集型的程序,和其他数据库一样,主要功能就是数据的持久化以及数据的管理. 本文侧重通过优化MySQL 数据库缓存参数如查询缓存,表缓存,. 日志缓存,索引缓存,innodb缓存,插入缓存,以及连接参数等方式来对MySQL数据库进行优化.   这里先引用一句话,从内存中读取一个数据的时间消耗是微秒级别,而从普通硬盘上读取一个数据是在毫秒级别,二者相差3个数量级.

数据库优化的最佳实践

- - ITeye博客
  选择合理的索引(前缀性及可选性)、删除没有用的索引.    2)使用规范化,但不要使用过头.   规范化(至少是第三范式)是一个易于理解且标准的方法. 然而,在有些情况下,你可能希望违反这些规则. 查询表通常是规范化的产物,也就是说,你创建了一个特殊的表,这个表包含了在其他表中被频繁使用的相关信息的列表.