關(guān)鍵詞:熱更新 | 熱重載 | 定時更新 | 即時更新 | 緩存 | functools | cachetools | LRU | TTL
假設(shè)應(yīng)用需要加載一個配置文件config.txt
细卧,一般的做法類似于:
with open('config.txt') as f:
parameters = f.read()
接下來parameters
中存儲的數(shù)據(jù)就可以被其他代碼使用够挂,但是這樣寫的話程序每次啟動后,數(shù)據(jù)是固定死的,無法動態(tài)地自我更新棺聊,每次要修改配置/模型只能重啟整個應(yīng)用。
本文中熱更新的意思是在應(yīng)用運(yùn)行時內(nèi),從外部(如文件剩檀、數(shù)據(jù)庫、REST API)中獲得數(shù)據(jù)并更新應(yīng)用內(nèi)的Python對象旺芽。應(yīng)用場景一般是應(yīng)用作為服務(wù)(有對外的API)沪猴,需要在不重啟的前提下更新自己的配置參數(shù)或者算法模型。
熱更新可以分為兩種:定時更新(periodic update)和即時更新(on-call update)采章,前者周期性地运嗜、主動地執(zhí)行更新,后者則是被動地等待悯舟,直到接收到某種來自應(yīng)用外部的信號才會執(zhí)行更新担租。本文將通過內(nèi)存緩存(Memory Cache)和裝飾器(Decorator)技術(shù)來實(shí)現(xiàn)兩種熱更新。
內(nèi)存緩存
先來說明一下內(nèi)存緩存抵怎。緩存中的數(shù)據(jù)一般以鍵值對(key-value pair)的形式存在奋救,value中放數(shù)據(jù)本身,key中放數(shù)據(jù)的某種描述名便贵。緩存的容量決定了其最大可容納的數(shù)據(jù)條數(shù)菠镇,當(dāng)容量已滿時再向緩存中存入新的數(shù)據(jù),緩存就會采取開始清理行為:清除掉已存的部分?jǐn)?shù)據(jù)承璃,從而為新數(shù)據(jù)騰地方利耍。清理的策略(判定何時需要清理、具體如何清理數(shù)據(jù))有很多種盔粹,決定了緩存的不同類型隘梨。
Python中緩存常被寫成裝飾器的形式,緩存數(shù)據(jù)的key
里放的是被裝飾原函數(shù)的參數(shù)值組合(key
的生成方法可以不同舷嗡,后面還會提到)轴猎,value
里放的則是原函數(shù)的返回值。這樣當(dāng)函數(shù)被調(diào)用時进萄,程序會先去緩存數(shù)據(jù)里找是不是已經(jīng)有相同的參數(shù)值捻脖,如果有就直接返回已緩存的返回值,不重復(fù)進(jìn)行原函數(shù)內(nèi)的計(jì)算中鼠。
注意緩存的使用有一個隱含前提:函數(shù)本身是無狀態(tài)的可婶。假如函數(shù)內(nèi)引用了全局變量,或者存在閉包援雇,那同樣的參數(shù)值不一定必然計(jì)算出相同的返回值矛渴。這樣緩存的返回值和實(shí)際期望的返回值就不一定一致了。
定時熱更新
定時更新的實(shí)現(xiàn)使用了來源于第三方庫cachetools的TTLCache
惫搏,TTL(Time-to-Live)指存在時長策略具温。這種緩存為每一條存入的數(shù)據(jù)記錄其存在的時長蚕涤。每次調(diào)用都會檢查是否存在超過某個設(shè)定時長閾值的數(shù)據(jù),如果有就會開始清理行為:所有超時數(shù)據(jù)都會被清除掉铣猩;如果沒有超時數(shù)據(jù)揖铜,緩存將會換用LRU策略,使緩存不超出容量大屑料啊(下一部分會提到LRU的具體策略)蛮位。
當(dāng)我們將TTL緩存的容量設(shè)為1時较沪、且用于加載數(shù)據(jù)的原函數(shù)參數(shù)不變的情況下鳞绕,邏輯就變成了定時更新:
- 未超過時長:緩存保留,每次調(diào)用都使用緩存數(shù)據(jù)
- 超過時長尸曼,緩存清空(只有一條數(shù)據(jù))们何,程序重新計(jì)算(在這里即重新加載數(shù)據(jù))
示例代碼如下(運(yùn)行需要安裝cachetools
,并在文首鏈接里下載完整的項(xiàng)目):
import time
import cachetools
from utils import change_conf_file
ROTATE = 5
@cachetools.cached(cachetools.TTLCache(1, ROTATE))
def reload():
print('Cache cleared, reloading config...')
with open('config.txt') as f:
parameters = f.read()
return parameters
class Model():
def log(self):
self.model = reload()
print(self.model)
if __name__ == '__main__':
# Reload automatically every [ROTATE] seconds
model = Model()
while True:
time.sleep(2)
change_conf_file() # change data
model.log()
即時熱更新
即時更新的實(shí)現(xiàn)使用了來源于Python內(nèi)置庫functools的lru_cache
控轿,LRU(Least Recently Used)指最少使用策略冤竹。這種緩存為每一條存入的數(shù)據(jù)記錄其被使用的次數(shù),每次調(diào)用都會檢查緩存大小是否超出容量茬射,如超出就會開始清理行為:會從使用次數(shù)最少的數(shù)據(jù)開始清理鹦蠕,直到緩存大小處于容量以內(nèi)。
當(dāng)我們將LRU緩存的容量設(shè)為1在抛、且用于加載數(shù)據(jù)的原函數(shù)參數(shù)不變的情況下钟病,原函數(shù)只有在第一次被調(diào)用時才會發(fā)生計(jì)算,之后調(diào)用都會直接返回緩存中的數(shù)據(jù)刚梭,到這里與一般的讀取效果上并無區(qū)別肠阱。當(dāng)我們需要熱更新數(shù)據(jù)的時候,只需要主動清空緩存朴读。如下例中Getter.getModel.cache_clear()
屹徘。其中Getter.getModel
是裝飾后的函數(shù),其中帶有用于清理緩存的函數(shù)cache_clear()
衅金。有了這個扳機(jī)噪伊,我們只需要額外開發(fā)一個API(比如REST下的GET)來觸發(fā)它,這樣通過外部即時call API就可以進(jìn)行熱更新了氮唯。
示例代碼如下(運(yùn)行需要在文首鏈接里下載完整的項(xiàng)目):
import time
from functools import lru_cache
from utils import change_conf_file
class Getter:
@staticmethod
@lru_cache(1)
def getModel():
with open('config.txt') as f:
model = f.read()
return model
class Model():
def log(self):
self.model = Getter.getModel()
print(self.model)
if __name__ == '__main__':
# Reload only when cache_clear() is called
model = Model()
while True:
model.log()
time.sleep(2)
change_conf_file() # change data
Getter.getModel.cache_clear()
print('Cache cleared, reloading config...')
model.log()
這里補(bǔ)充一個細(xì)節(jié)鉴吹,上面的示例中被緩存裝飾器裝飾的原函數(shù)getModel
是一個無參數(shù)的函數(shù),這種情況下lru_cache
是如何運(yùn)作的呢您觉?lru_cache
的實(shí)現(xiàn)中使用函數(shù)functools._make_key
來生成緩存的key
拙寡。在Python中,當(dāng)原函數(shù)無參數(shù)時琳水,默認(rèn)參數(shù)args
可認(rèn)為是空元組()
肆糕,可選參數(shù)可認(rèn)為是空字典{}
般堆,在這種情況下生成的key
將會是空列表[]
(注意列表是不可hash的,不可直接作為字典的key诚啃,functools里的實(shí)際數(shù)據(jù)結(jié)構(gòu)較為復(fù)雜淮摔,這里沒有深入)。上一部分提到的第三方庫cachetools
實(shí)現(xiàn)了類似的方法keys.typedkey
始赎,兩者生成的key
存在區(qū)別和橙,但是結(jié)合其他方法,行為在大部分情況是一樣的造垛,包括本文中的無參數(shù)函數(shù)情況魔招。
import time
from functools import _make_key
from cachetools.keys import typedkey
if __name__ == '__main__':
print(_make_key((), {}, False)) # []
print(typedkey((), {}, False)) # ((), {}, <class 'tuple'>, <class 'dict'>)
擴(kuò)展問題
-
多線程情況:本文的熱更新方法均基于緩存,而由于緩存涉及到讀寫操作五辽,在多線程環(huán)境下我們需要考慮其正確性办斑。
更正:functools.lru_cache
和cachetools.TTLCache
里均有使用到鎖的機(jī)制,再考慮到Python的GIL鎖杆逗,本文所述的熱更新在線程安全上應(yīng)該算是有保障的乡翅,但目前未經(jīng)試驗(yàn)無法完全下斷言。functools.lru_cache
的文檔中提到多線程環(huán)境下hit和miss的計(jì)數(shù)只是近似值罪郊,而Cachetool的文檔中則明確提到cachetools.TTLCache
這樣的類是非線程安全的蠕蚜,需要額外提供鎖以實(shí)現(xiàn)同步。最開始寫這篇文章的時候我對GIL的理解有誤悔橄,按我現(xiàn)在的理解靶累,GIL寬泛地講只會阻止多線程調(diào)用多個CPU,但同一個CPU下的多線程仍然是有效的(不然還要多線程干嘛)橄维,所以同步的問題還是要考慮清除尺铣。 -
import:假如我們在一個模塊(module)里更新model,而另一個模塊
import
這個model争舞,那么當(dāng)原模塊的model熱更新后凛忿,import
得到的model并不會更新,這種行為可能與Python自身的module cache有關(guān)竞川。要實(shí)現(xiàn)所有module的熱更新店溢,能考慮的一個辦法是讓數(shù)據(jù)自己成為一個模塊,使其變得可以在模塊之間共享委乌。
吐槽:這篇寫得我渾身無力啊床牧,本來覺得很簡單,就平時用的小東西拿來拎拎清遭贸,沒想到越拎越深...很多時候我們很happy是因?yàn)槲覀冋驹诒降淖钌厦娓昕龋挥妹鎸λ碌哪Ч砑?xì)節(jié)...作為搞技術(shù)的,我們還是不能光看臉,也要多盯襠(?_?)?