筆者最近幫朋友處理一些股票類的數據,採用了 Django 作為 Web 框架。
Django 的 Admin 模塊是我喜歡 Django 勝於 Flask 的重要原因之一。
小項目,如果是給自己用的,不那麼講究的 Admin 界面,善用 Django Admin 可以在人手不足的下少寫相當多的代碼。
我先同步了一些股票的五分鐘數據用於並且寫了簡單的 Django Admin 頁面, 解決了兩個小問題, 發現自己有段時間沒更新專欄了, 趕緊水一篇, 希望可以給後來人一些優化思路上的參考
當數據量上升到五千萬到一億的時候,我打開了 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
好,開始定位問題
從 SQL 的 Panel 可以看出 基本上應該卡在 SQL 上面 進入頁面查看詳細
有兩個問題:
問題 1: count 海量數據
紅藍兩條 sql 語句是兩塊特別明顯的硬骨頭,並且展開之後發現,執行的是 count
count 每次需要掃全表,那當然慢咯,慢是一個問題,更加尷尬的事情是執行了兩次
問題 2: n+1 問題
query 數量 105 個,不是 duplicated 就是 similar, 這是標準 ORM n+1 的表現。
好,開始
從 django-debug-tools 裏展開相關的代碼堆棧信息
依據路徑查找代碼, 發現 count 有兩個地方需要優化
# odin-py3.7/lib/python3.7/site-packages/django/contrib/admin/views/main.py class 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 的問題 稍後回來。
充斥著重複和類似的 queries, 這八成時 N+1 問題,即
instance.stock.name 的時候每次都會取 stock 一下資料庫,這就造成了多次 hit 資料庫, 每次hit的查詢資料庫雖然時間不多, 但頻繁的會話本身就是一種浪費
N+1 問題無非就種解決方案
翻了翻官方文檔,發現 admin.ModelAdmin 裏支持了第一種方案, 於是
list_select_related = ["stock"]
於是乎,本來需要 6 s 的頁面,現在打開頁面只要 1s 不到,資料庫的時間只用了不到 200ms
好,那麼我們開始解決之前的那個懸而未決的問題
來思考一下,count 的數量真的是特別重要的麼?
其實,並不是需要特別精確的數量,換而言之,假如現在的數量是 一億條,我三分鐘後即
便這張表的數量是一億零 300 條,依舊當它一億條有木有問題。
顯然,在這個場景下,一點問題都沒有。
於是方案 1, 就是之前的那個方案其實也是不錯的。
def _get_count(self): return 100000000
如果你說,我要稍微真實一點的數據,可以麼
那麼方案 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 之類的緩存,但是我也要相對來說比較接近真實數量的代碼
有的,方案 3
以 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
還有什麼其他的方案麼?當然有,方案四。
假如 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左右, 優化完成, 完工
推薦閱讀: