Django Performance Optimization[2]: Memory Inflation Due to Object Reference Kept Unexpectedly

Holis
iCHEF
Published in
9 min readDec 5, 2023

這一篇要來聊一個在實作 API 過程中,GC 沒有如預期做事
導致 API 在經手大量資料時,使用 Memory 過度膨脹的案例

Unexpected SIGKILL

先想像一個情景

有一個 API 需要回傳大量資料,比如 10 萬筆交易的內容。
在應用程式裡,先用 ORM 先到 DB Query 資料,得到 ORM object,然後放到 Serizlizer (序列化器) 序列化成可以回傳的內容。

Serialization (序列化): 將程式物件轉化成可以在網路傳送或被儲存的格式 (Ex: JSON)
Deserialization (反序列化): 將可以於網路傳送或被儲存的資料轉化為程式物件的過程

一開始 local 跑起來很順利,部署上 Production 後也順利運轉。某一天,系統監控開始提示 5xx 太多。你看了看你的錯誤監控 (Ex: Sentry),發現他收到 SIGKILL,你疑惑程式怎麼會被系統殺了,於是去查了那段時間的 mechaine metric,發現 memory 異常地高,可能還翻了機器上的 log,好吧他可能被 OOM killer 宰了。

Linux OOM Doc

你開始仔細地算一下 (其實在開發前就要算好,擬定 SLA),或是去看了實際的 body size 多大,你看到每一筆透過網路傳出來的資料不過 1KB,那 10 萬筆交易資料也就是 100MB,雖然不算小了,但和你看到的 metric 天差地遠,在應用程式裡究竟發生了什麼事?

模擬這個情景

假設公司用這樣的表來存客戶的資料
相對應的 API 的序列化器如下

from rest_framework import serializers


class Customer(models.Model):
uuid = models.UUIDField(default=uuid4)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

name = models.CharField(max_length=255)
phone = models.CharField(max_length=255)
email = models.CharField(max_length=255, null=True)
address = models.CharField(max_length=255, null=True)

latest_login_at = models.DateTimeField(null=True)


class CustomerSerializer(serializers.ModelSerializer):
class Meta:
model = Customer
fields = (
'id',
'name', 'address',
'phone', 'email',
'created_at', 'updated_at',
'latest_login_at',
)

你今天負責維護一隻報表 API,他可以回傳至多 10 萬筆的客戶資料。
為了簡化問題,1 萬就好,然後 code 也簡單地長這樣。


class CustomerListView(APIView):
authentication_classes = ()
permission_classes = (permissions.AllowAny,)

def get(self, request, format=None):
max = 10000

customers = Customer.objects.all()[:max]

resp = []

for customer in customers: # 這樣寫只是後續比較好 profile
serializer = CustomerSerializer(customer)
resp.append(serializer.data)

return Response(resp)

首先把 server 跑起來,看看 output 有多少 MB

同時我們需要 memory profiler,看看應用程式在跑的時候,花了多少記憶體

from memory_profiler import profile as memory_profil

def do_memory_profiler(func):
"""
Print memory profile info everytime you envoke decorated func
"""
@wraps(func)
def wrapped(*args, **kwargs):
profiled_func = memory_profil(func=func, stream=sys.stdout)
return profiled_func(*args, **kwargs)

return wrapped
3MB 的內容,在迭代的過程卻佔用 240 MB 的空間

哇 ! 整個執行過程佔用 300MB,整整 100 倍,究竟發生了什麼事

等於是一個每個 `serializer.data` 在應用程裡用掉了 300KB,而實際資料輸出時只佔有 3KB。Python 的 dictionary 實作泛型就算沒有效率,也不應該到這個幅度。

那問題就變成了

GC 有準時把記憶體收掉嗎?

他什麼時候會收掉?

第一個機會,是 Reference Count

一個物件被參考時,就會被計數,當計數器歸零時回收這個物件

>> import sys
>> a = '__'
>> b = [a]
>> c = {'key': a}
>> sys.getrefcount(a)
4 # 傳到 getrefcount 也算一次

第二個機會,是 Generation Garbage Collection

用來處理 circular reference 導致 reference count 永遠不會歸零的情境
Cpython 會在達到某個 threshold 時回收他們

class MyClass:
pass

obj = MyClass()
obj.foo = obj

del obj # 雖然 obj 被刪除了,但 obj.foo 對 obj 的 reference 還存在

上面的情境,在沒有 Generation Garbage Collection 的前提下,便會產生 Memory Leak

因此,我們做個簡單的實驗,來確認 GC 在執行上述程式碼的影響

主動去觸發 Generation Garbage Collection

對照組:不主動觸發 GC
比較組:主動觸發 GC

從比較組確定了,每次迭代都觸發 GC 後,Memory 的使用並沒有減少

因為 Generation Garbage Collection 有一定被充分執行了,所以可以確保沒有發生 Circular reference 沒有被回收的狀況

因此原因剩下一個

還活在 Scope 的物件 Keep 了 Serializer 的 Reference Count

真相在點進 serializer.data 後明瞭了

django rest framework 的 Serializer.data會保留對 seriliazer 本身的 reference

在整個 API 運行的期間,沒有一個 serializer 被 GC 回收

我們能怎麼做?

其實在一開始,我們都假設序列化器回傳的資料 Serializer.data應該要是 dict type,在經過 rest_framework.response.Response 變成為 Json Response

就連官方文件也這樣和我們說

Serializer.data 回傳的是 primitive type,所以我們預期他是 dict

因此要做的事情也很簡單,讓它回歸純粹的 primitive dict 就行了

一發 deepcopy 結束

原來的資料 type 是 django rest 的 ReturnDict
再經過 deepcopy 後變成 primitive type: dict,不會再 reference 到 serializer

最後看看對照組和比較組

對照組:因為 GC 沒有回收 Serializer,迭代過程佔用 240MB
比較組:GC 回收 Serializer,迭代過程只佔用 20MB

優化後迭代的過程佔用的記憶體 240MB -> 20MB
剩下不到 1/10

結語

因為通常不會預期 GC 和第三方沒有如預期的運作,範例的情境可能難以避免

若從自己是設計者的角度,便盡量不要意外的保留物件參考。以免 caller 以為已經離開 scope 了,實際上卻仍被 keep reference

這個案例裡,ReturnDict(OrderedDict)的 doc string 很明確的揭示了 keep reference,但官方文件卻沒有提到,因此產生了誤會的風險

永遠讓你的程式只做它說它要做的事

--

--