海量数据的 Django Admin 优化技巧。

转载自品略图书馆 http://www.pinlue.com/article/2020/04/0211/3710102817445.html

前言

笔者最近帮朋友处理一些股票类的数据,采用了 Django 作为 Web 框架。

Django 的 Admin 模块是我喜欢 Django 胜于 Flask 的重要原因之一。

小项目,如果是给自己用的,不那么讲究的 Admin 界面,善用 Django Admin 可以在人手不足的下少写相当多的代码。

我先同步了一些股票的五分钟数据用于并且写了简单的 Django Admin 页面, 解决了两个小问题, 发现自己有段时间没更新专栏了, 赶紧水一篇, 希望可以给后来人一些优化思路上的参考

0x01 事情开始起变化

当数据量上升到五千万到一亿的时候,我打开了 Django Admin 对应的页面想查看一些数据。打开页面大约花了半分钟,这页面速度可以说是相当慢了。

需要交代一下,我的 ModelAdmin 是这么写的

@admin.register(Stock5Min)class Stock5MinAdmin(ReadOnlyAdminMixin, admin.ModelAdmin): list_display = ( "stock_name", "code", "datetime", "date", "open", "high", "low", "close", ) def stock_name(self, instance): return instance.stock.name

好,开始定位问题

打开开发者工具从返回响应内容上判断,应该不是 html 太大或是 JS 死循环 / 内存泄漏。排除掉是前端的问题。

安装 django-debug-tools 定位问题监控。

从 SQL 的 Panel 可以看出 基本上应该卡在 SQL 上面 进入页面查看详细

有两个问题:

问题 1: count 海量数据

红蓝两条 sql 语句是两块特别明显的硬骨头,并且展开之后发现,执行的是 count

count 每次需要扫全表,那当然慢咯,慢是一个问题,更加尴尬的事情是执行了两次

问题 2: n+1 问题

query 数量 105 个,不是 duplicated 就是 similar, 这是标准 ORM n+1 的表现。

0x02 解决问题

好,开始

count 海量数据

从 django-debug-tools 里展开相关的代码堆栈信息

依据路径查找代码, 发现 count 有两个地方需要优化

# odin-py3.7/lib/python3.7/site-packages/django/contrib/admin/views/main.pyclass ChangeList: def get_results(self, request): paginator = self.model_admin.get_paginator(request, self.queryset, self.list_per_page) result_count = paginator.count # 这里是 count1 优化点 # Get the total number of objects, with no admin filters applied. if self.model_admin.show_full_result_count: full_result_count = self.root_queryset.count() # 这里是 count2 优化点 else: full_result_count = None

看起来 count2 比较容易一些,在 ModelAdmin 添加如下配置,跳过 count2

show_full_result_count = False

再优化 count1, 只要能让 paginator 的数量变为理想的数量就足够了,因为数量已经接近 1 亿,所以在 ModelAdmin 里指定 如下的 paginator 就好了

class LargeTablePaginator(Paginator): def _get_count(self): return 100000000 count = property(_get_count)

于是乎,本来需要 40s+ 的页面,现在只需 6s

这个时候聪明的你跳出来了

这个优化很智障,哪有这么指定 count 数量的。这不是逃避问题么...

但逃避可耻,但是有用。

开个玩笑

机智的笔者其实也早就知道了你的想法,

我先按下不表,先去解决剩下来的 6s 的问题 稍后回来。

解决 N+1 的问题

充斥着重复和类似的 queries, 这八成时 N+1 问题,即

instance.stock.name 的时候每次都会取 stock 一下数据库,这就造成了多次 hit 数据库, 每次hit的查询数据库虽然时间不多, 但频繁的会话本身就是一种浪费

N+1 问题无非就种解决方案

django 内置的 selectrelated 实现 leftjoin

django 内置的 prefetchrelated 来预先取stock从而实现减少hit数据库的次数的目的

翻了翻官方文档,发现 admin.ModelAdmin 里支持了第一种方案, 于是

list_select_related = ["stock"]

于是乎,本来需要 6 s 的页面,现在打开页面只要 1s 不到,数据库的时间只用了不到 200ms

0x03 四种快速 count 方案

好,那么我们开始解决之前的那个悬而未决的问题

来思考一下,count 的数量真的是特别重要的么?

其实,并不是需要特别精确的数量,换而言之,假如现在的数量是 一亿条,我三分钟后即

便这张表的数量是一亿零 300 条,依旧当它一亿条有木有问题。

显然,在这个场景下,一点问题都没有。

于是方案 1, 就是之前的那个方案其实也是不错的。

方案 1: 就是最简单直接的方式,人肉估一个数量

def _get_count(self): return 100000000

如果你说,我要稍微真实一点的数据,可以么

方案 2: 用定时缓存 count 值

那么方案 2 就更加合适一些了

def _get_count(self): key = "stock5min" count = cache.get(key) if not count: count = do_count() cache.set(key, count, 30 * 60) # 每三小时刷一次 return count

如果你说,我不想使用 redis 之类的缓存,但是我也要相对来说比较接近真实数量的代码

,或者说,像 mysql 或者是 postges 的表就没有什么元数据可以给我读一读,获得一个大致的

数量的方案嘛?

有的,方案 3

方案 3: 读取 Meta 表的值

以 PG 为例,就可以提供方案三的做法

pgclass

def _get_count(self): if getattr(self, "_count", None) is not None: return self._count query = self.object_list.query if not query.where: # 如果走全表 count try: cursor = connection.cursor() cursor.execute("SELECT reltuples FROM pg_class WHERE relname = %s", [query.model._meta.db_table]) self._count = int(cursor.fetchone()[0]) except: self._count = super()._get_count() else: self._count = super()._get_count() return self._count

还有什么其他的方案么?当然有,方案四。

方案 4: count 指定超时时间

假如 count 的执行时间超过了 200ms, 默认给一个数量。

def _get_count(self): with transaction.atomic(), connection.cursor() as cursor: cursor.execute("SET LOCAL statement_timeout TO 200;") try: return super().count except OperationalError: return 100000000

现在页面打开时间稳定在1s左右, 优化完成, 完工

发布了60 篇原创文章 · 获赞 58 · 访问量 14万+

猜你喜欢

转载自blog.csdn.net/yihuliunian/article/details/105340917