最近帮朋友处理一些股票类的数据,采用了 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 定位问题监控。
来百度APP畅享高清图片
从 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左右, 优化完成, 完工返回搜狐,查看更多