通常情況下,Web應用的效能瓶頸都會出現在關係型資料庫上,當併發訪問量較大時,如果所有的請求都需要透過關係型資料庫完成資料持久化操作,那麼資料庫一定會不堪重負。最佳化Web應用效能最為重要的一點就是使用快取,把那些資料體量不大但訪問頻率非常高的資料提前載入到快取伺服器中,這又是典型的空間換時間的方法。通常快取伺服器都是直接將資料置於記憶體中而且使用了非常高效的資料存取策略(雜湊儲存、鍵值對方式等),在讀寫效能上遠遠優於關係型資料庫的,因此我們可以讓Web應用接入快取伺服器來最佳化其效能,其中一個非常好的選擇就是使用Redis。
Web應用的快取架構大致如下圖所示。
在此前的課程中,我們介紹過Redis的安裝和使用,此處不再進行贅述。如果需要在Django專案中接入Redis,可以使用三方庫django-redis
,這個三方庫又依賴了一個名為redis
的三方庫,它封裝了對Redis的各種操作。
安裝django-redis
。
pip install django-redis
修改Django配置檔案中關於快取的配置。
CACHES = {
'default': {
# 指定透過django-redis接入Redis服務
'BACKEND': 'django_redis.cache.RedisCache',
# Redis伺服器的URL
'LOCATION': ['redis://1.2.3.4:6379/0', ],
# Redis中鍵的字首(解決命名衝突)
'KEY_PREFIX': 'vote',
# 其他的配置選項
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
# 連線池(預置若干備用的Redis連線)引數
'CONNECTION_POOL_KWARGS': {
# 最大連線數
'max_connections': 512,
},
# 連線Redis的使用者口令
'PASSWORD': 'foobared',
}
},
}
至此,我們的Django專案已經可以接入Redis,接下來我們修改專案程式碼,用Redis為之寫的獲取學科資料的介面提供快取服務。
所謂宣告式快取是指不修改原來的程式碼,透過Python中的裝飾器(代理)為原有的程式碼增加快取功能。對於FBV,程式碼如下所示。
from django.views.decorators.cache import cache_page
@api_view(('GET', ))
@cache_page(timeout=86400, cache='default')
def show_subjects(request):
"""獲取學科資料"""
queryset = Subject.objects.all()
data = SubjectSerializer(queryset, many=True).data
return Response({'code': 20000, 'subjects': data})
上面的程式碼透過Django封裝的cache_page
裝飾器快取了檢視函式的返回值(響應物件),cache_page
的本意是快取檢視函式渲染的頁面,對於返回JSON資料的檢視函式,相當於是快取了JSON資料。在使用cache_page
裝飾器時,可以傳入timeout
引數來指定快取過期時間,還可以使用cache
引數來指定需要使用哪一組快取服務來快取資料。Django專案允許在配置檔案中配置多組快取服務,上面的cache='default'
指定了使用預設的快取服務(因為之前的配置檔案中我們也只配置了名為default
的快取服務)。檢視函式的返回值會被序列化成位元組串放到Redis中(Redis中的str型別可以接收位元組串),快取資料的序列化和反序列化也不需要我們自己處理,因為cache_page
裝飾器會呼叫django-redis
庫中的RedisCache
來對接Redis,該類使用了DefaultClient
來連線Redis並使用了pickle序列化,django_redis.serializers.pickle.PickleSerializer
是預設的序列化類。
如果快取中沒有學科的資料,那麼透過介面訪問學科資料時,我們的檢視函式會透過執行Subject.objects.all()
向資料庫發出SQL語句來獲得資料,檢視函式的返回值會被快取,因此下次請求該檢視函式如果快取沒有過期,可以直接從快取中獲取檢視函式的返回值,無需再次查詢資料庫。如果想了解快取的使用情況,可以配置資料庫日誌或者使用Django-Debug-Toolbar來檢視,第一次訪問學科資料介面時會看到查詢學科資料的SQL語句,再次獲取學科資料時,不會再向資料庫發出SQL語句,因為可以直接從快取中獲取資料。
對於CBV,可以利用Django中名為method_decorator
的裝飾器將cache_page
這個裝飾函式的裝飾器放到類中的方法上,效果跟上面的程式碼是一樣的。需要提醒大家注意的是,cache_page
裝飾器不能直接放在類上,因為它是裝飾函式的裝飾器,所以Django框架才提供了method_decorator
來解決這個問題,很顯然,method_decorator
是一個裝飾類的裝飾器。
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
@method_decorator(decorator=cache_page(timeout=86400, cache='default'), name='get')
class SubjectView(ListAPIView):
"""獲取學科資料的檢視類"""
queryset = Subject.objects.all()
serializer_class = SubjectSerializer
所謂程式設計式快取是指透過自己編寫的程式碼來使用快取服務,這種方式雖然程式碼量會稍微大一些,但是相較於宣告式快取,它對快取的操作和使用更加靈活,在實際開發中使用得更多。下面的程式碼去掉了之前使用的cache_page
裝飾器,透過django-redis
提供的get_redis_connection
函式直接獲取Redis連線來操作Redis。
def show_subjects(request):
"""獲取學科資料"""
redis_cli = get_redis_connection()
# 先嚐試從快取中獲取學科資料
data = redis_cli.get('vote:polls:subjects')
if data:
# 如果獲取到學科資料就進行反序列化操作
data = json.loads(data)
else:
# 如果快取中沒有獲取到學科資料就查詢資料庫
queryset = Subject.objects.all()
data = SubjectSerializer(queryset, many=True).data
# 將查到的學科資料序列化後放到快取中
redis_cli.set('vote:polls:subjects', json.dumps(data), ex=86400)
return Response({'code': 20000, 'subjects': data})
需要說明的是,Django框架提供了cache
和caches
兩個現成的變數來支援快取操作,前者訪問的是預設的快取(名為default
的快取),後者可以透過索引運算獲取指定的快取服務(例如:caches['default']
)。向cache
物件傳送get
和set
訊息就可以實現對快取的讀和寫操作,但是這種方式能做的操作有限,不如上面程式碼中使用的方式靈活。還有一個值得注意的地方,由於可以透過get_redis_connection
函式獲得的Redis連線物件向Redis發起各種操作,包括FLUSHDB
、SHUTDOWN
等危險的操作,所以在實際商業專案開發中,一般都會對django-redis
再做一次封裝,例如封裝一個工具類,其中只提供了專案需要用到的快取操作的方法,從而避免了直接使用get_redis_connection
的潛在風險。當然,自己封裝對快取的操作還可以使用“Read Through”和“Write Through”的方式實現對快取的更新,這個在下面會介紹到。
在使用快取時,一個必須搞清楚的問題就是,當資料改變時,如何更新快取中的資料。通常更新快取有如下幾種套路,分別是:
- Cache Aside Pattern
- Read/Write Through Pattern
- Write Behind Caching Pattern
第1種方式的具體做法就是,當資料更新時,先更新資料庫,再刪除快取。注意,不能夠使用先更新資料庫再更新快取的方式,也不能夠使用先刪除快取再更新資料庫的方式,大家可以自己想一想為什麼(考慮一下有併發的讀操作和寫操作的場景)。當然,先更新資料庫再刪除快取的做法在理論上也存在風險,但是發生問題的機率是極低的,所以不少的專案都使用了這種方式。
第1種方式相當於編寫業務程式碼的開發者要自己負責對兩套儲存系統(快取和關係型資料庫)的操作,程式碼寫起來非常的繁瑣。第2種方式的主旨是將後端的儲存系統變成一套程式碼,對快取的維護封裝在這套程式碼中。其中,Read Through指在查詢操作中更新快取,也就是說,當快取失效的時候,由快取服務自己負責對資料的載入,從而對應用方是透明的;而Write Through是指在更新資料時,如果沒有命中快取,直接更新資料庫,然後返回。如果命中了快取,則更新快取,然後再由快取服務自己更新資料庫(同步更新)。剛才我們說過,如果自己對專案中的Redis操作再做一次封裝,就可以實現“Read Through”和“Write Through”模式,這樣做雖然會增加工作量,但無疑是一件“一勞永逸”且“功在千秋”的事情。
第3種方式是在更新資料的時候,只更新快取,不更新資料庫,而快取服務這邊會非同步的批次更新資料庫。這種做法會大幅度提升效能,但代價是犧牲資料的強一致性。第3種方式的實現邏輯比較複雜,因為他需要追蹤有哪資料是被更新了的,然後再批次的重新整理到持久層上。
快取是為了緩解資料庫壓力而新增的一箇中間層,如果惡意的訪問者頻繁的訪問快取中沒有的資料,那麼快取就失去了存在的意義,瞬間所有請求的壓力都落在了資料庫上,這樣會導致資料庫承載著巨大的壓力甚至連線異常,類似於分散式拒絕服務攻擊(DDoS)的做法。解決快取穿透的一個辦法是約定如果查詢返回為空值,把這個空值也快取起來,但是需要為這個空值的快取設定一個較短的超時時間,畢竟快取這樣的值就是對快取空間的浪費。另一個解決快取穿透的辦法是使用布隆過濾器,具體的做法大家可以自行了解。
在實際的專案中,可能存在某個快取的key某個時間點過期,但恰好在這個時間點對有對該key的大量的併發請求過來,這些請求沒有從快取中找到key對應的資料,就會直接從資料庫中獲取資料並寫回到快取,這個時候大併發的請求可能會瞬間把資料庫壓垮,這種現象稱為快取擊穿。比較常見的解決快取擊穿的辦法是使用互斥鎖,簡單的說就是在快取失效的時候,不是立即去資料庫載入資料,而是先設定互斥鎖(例如:Redis中的setnx),只有設定互斥鎖的操作成功的請求,才能執行查詢從資料庫中載入資料並寫入快取,其他設定互斥鎖失敗的請求,可以先執行一個短暫的休眠,然後嘗試重新從快取中獲取資料,如果快取還沒有資料,則重複剛才的設定互斥鎖的操作,大致的參考程式碼如下所示。
data = redis_cli.get(key)
while not data:
if redis_cli.setnx('mutex', 'x'):
redis.expire('mutex', timeout)
data = db.query(...)
redis.set(key, data)
redis.delete('mutex')
else:
time.sleep(0.1)
data = redis_cli.get(key)
快取雪崩是指在將資料放入快取時採用了相同的過期時間,這樣就導致快取在某一時刻同時失效,請求全部轉發到資料庫,導致資料庫瞬時壓力過大而崩潰。解決快取雪崩問題的方法也比較簡單,可以在既定的快取過期時間上加一個隨機時間,這樣可以從一定程度上避免不同的key在同一時間集體失效。還有一種辦法就是使用多級快取,每一級快取的過期時間都不一樣,這樣的話即便某個級別的快取集體失效,但是其他級別的快取還能夠提供資料,避免所有的請求都落到資料庫上。