0x00 前言

筆者最近幫朋友處理一些股票類的數據,採用了 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

好,開始定位問題

  1. 打開開發者工具從返迴響應內容上判斷,應該不是 html 太大或是 JS 死循環 / 內存泄漏。排除掉是前端的問題。
  2. 安裝 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.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 的問題 稍後回來。

解決 N+1 的問題

充斥著重複和類似的 queries, 這八成時 N+1 問題,即

instance.stock.name 的時候每次都會取 stock 一下資料庫,這就造成了多次 hit 資料庫, 每次hit的查詢資料庫雖然時間不多, 但頻繁的會話本身就是一種浪費

N+1 問題無非就種解決方案

  1. django 內置的 selectrelated 實現 leftjoin
  2. 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左右, 優化完成, 完工

0xEE 參考鏈接

  • Photo by Kyle Broad on Unsplash

推薦閱讀:

相關文章