大檔案的兩階段上傳:為什麼不經過應用伺服器

Mork 的單支影片上限是 500 MB,這個數字一開始就決定了上傳不能照一般的方式做。

一般的作法是檔案先送到應用伺服器,伺服器收完再轉存到儲存空間,那樣每一個位元組都會經過應用伺服器,而應用伺服器通常是按執行時間計費、有請求逾時上限,同一個執行個體還要同時服務別人的請求,一支 500 MB 的影片傳半小時,就等於把一個工作單元佔住半小時。

所以我們把它拆開了,檔案直接進儲存空間,位元組完全不經過應用伺服器。

拆成兩次對話

流程變成跟伺服器講兩次話,中間夾著一次不經過伺服器的傳輸。

第一次是 prepare,瀏覽器報上檔名、大小、型別,伺服器回一組有簽章的上傳網址,那個簽章有時效,而且綁定了檔案位置跟 Content-Type,拿到它的人只能往那一個位置放那一種型別的東西,不能拿去傳別的。

第二次是 PUT,瀏覽器把檔案直接送到儲存空間,伺服器不在這條路上。

第三次是 commit,瀏覽器說「傳完了」,伺服器這時候才去確認、才建立短連結。

好處是 AP 的營運成本降低了,架構也變簡單了。

伺服器沒看過那個檔案

這是整件事的核心取捨,傳統作法裡檔案經過伺服器,所以伺服器知道它多大、是什麼、內容長什麼樣;改成直傳之後,這些全部不知道了。

伺服器唯一知道的,是使用者在 prepare 階段宣稱的那些。而使用者可以說謊。

所以 commit 不能只是寫一筆資料庫,它得回頭去問儲存空間:

head = head_object(key=file_key)

if head['ContentLength'] > max_mb * 1048576:
    delete_object(key=file_key)
    raise UploadError('檔案大小超過上限 %d MB。' % max_mb)

這個查詢只要中繼資料,不下載內容,所以確認一支 500 MB 的影片跟確認一張 20 KB 的圖一樣快。

有三件事只有在這裡才驗得到。

一是檔案真的存在,不存在就代表還沒傳完,或是根本沒傳,回 409 請對方重試。

二是真正的大小,位元組是直接進儲存空間的,這是唯一量得到的地方;超標的直接刪掉,不刪的話儲存空間會被拿來當免費空間用。

三是型別跟宣告的一致,簽章已經綁了 Content-Type,理論上到不了這裡,但這是最後一道,寧可多擋,空的型別也算不符——沒有型別的物件,瀏覽器嗅探出什麼都有可能。

短網址代碼要在最後才產生

一個容易寫錯的細節,是短網址代碼要在哪一步產生。

放在 prepare 最直覺,先給網址,使用者可以先複製起來,但那樣中途放棄的上傳會把代碼佔掉,而那些代碼永遠不會有人用,短網址代碼是 4 到 7 碼,空間有限,沒有理由拿去養一堆空號。

所以短網址代碼是在 commit、驗證通過之後才產生的,使用者得等檔案傳完才拿得到網址,這樣是對的,在檔案還沒到位之前給出的網址是壞的。

有效期限也一樣

「30 分鐘後到期」要從哪一刻起算?如果從 prepare 起算,一支傳了半小時的大檔案,使用者拿到網址的時候它已經快過期了,等於白白吃掉他半小時。

# 到期時間從 commit 當下起算,而不是 prepare。
expire_at = datetime.now() + timedelta(minutes=int(post_data['expire_minutes']))

待確認的狀態放在快取

prepare 到 commit 之間,伺服器要記住這次上傳有哪些檔案,那份資料我們是寫在快取裡,帶著存活時間,不是寫進資料庫。

理由是大部分的上傳其實會失敗或被放棄,網路斷了、使用者關掉分頁、檔案太大按了取消,如果每一次 prepare 都在資料庫留一筆,就得另外寫一支清掃程式去收拾那些永遠不會 commit 的紀錄;放在快取的話,時間到就自己不見了。

順便把 commit 綁在同一個工作階段上:

if pending['identifier'] != request.session.get('identifier'):
    raise UploadError('上傳工作階段不符,請重新上傳。', status=403)

沒有這一行,別人猜到 upload id 就能替你收單、替你建立連結。

值不值得

如果上限是 5 MB,這個複雜度不值得;是 500 MB 就值得。

← 回到技術筆記