2009年7月22日 星期三

避免資料寫入衝突

在使用 App Engine 時,常常會建立 model 來作 sequence(如:手動配置資料的 id)、counter(計數器)之類的工作,以前面文章提到的 counter 為例,如果 Counter 在 datastore 中的 data entity 只有一個,每次新增資料時,程式都會到同一個地方作資料寫入(update 也是一種 write)的動作,如果新增資料的頻率很高,那就很容易發生寫入衝突(write contention)而使得程式的效能低落。

試想你在作一個類似 Twitter 的網站,使用者增加訊息的速度是非常驚人的,如果只使用單一個 data entity 來儲存、更新計數器,那每則訊息的更新時間就會變長,因為都在等待更新計數器的動作。如果你曾經學習過用在磁碟機上的 RAID 技術,那便可以借鏡 RAID 5 規格中分散資料擺放的方式,使得各個讀寫資料的動作可以同時進行。

以計數器為例,可以試著把計數器拆開成好幾個碎片(shard),在更新計數器時,可以只要隨便選一個碎片來更新就可以了,如果要取得完整的數值,只要把各碎片的值加總就得到答案了。

這樣的概念,程式要怎麼寫呢?首先把 Counter 的定義修改成這樣:
from google.appengine.ext import db

class Counter(db.Model):
name = db.StringProperty(required=True)
count = db.IntegerProperty(required=True, default=0)

增加了一個 name 欄位,這是為了標記碎片是屬於哪一個計數器,也就是 name 欄位相同的碎片就是屬於同一個計數器。有了這樣的 model 之後,更新計數器的程式碼可以改寫成這樣:
...
import random

shard_name = 'product_counter_%d' % random.randint(0, 3) # 假設我分成4個碎片
counter = Counter.get_by_key_name(shard_name)
if counter is None:
counter = Counter(key_name=shard_name, name='product_counter')
counter.count += 1
counter.put()

上面的例子是我預計將計數器切成 4 個碎片,然後每個「碎片 entity」的 key_name 分別為 product_counter_0, ..., product_counter_3,於是便能夠直接根據 key_name 將碎片的 entity 取出,若是碎片不存在就立刻產生一個新的 entity,而新的 entity 除了要設定 key_name 之外,最重要的就是要記得標上 name 欄位的值,因為當我們需要加總計數器時,就是靠著這個欄位來取出各個碎片。最後則是將 count 欄位的值加 1 後儲存,完成更新計數器的動作。

接下來要處理的就是加總的動作囉,因為可以藉著 name 欄位來查詢,所以程式也變得很簡單:
...
counters = Counter.all().filter('name =', 'product_counter')
total = 0
for counter in counters:
total += counter.count

所以 total 的值就是計數器正確的數值了。

改善效能


上面的例子雖然改善了寫入衝突的問題,但光是這樣作,除了每次讀取計數器數值時都要做一次查詢(耗費 Datastore API 呼叫次數)以外,碎片如果太多也會增加計算時間,為了改善這個問題,可以為計數器作 cache。

Google App Engine 有提供 Memcache 的服務,我們可以直接拿它來作為計數器的 cache,首先從計數器的加總下手,一旦數值被計算出來後就放進 cache,如此一來,讀取計數器數值時就可以先從 cache 讀取,不在 cache 裡才去作資料查詢:
# 讀取計數器總數時,使用 cache
from google.appengine.api import memcache

counter_name = 'product_counter'
total = memcache.get(counter_name) # 這裡用 counter_name 作為 cache key
if total is None:
total = 0
counters = Counter.all().filter('name =', counter_name)
for counter in counters:
total += counter.count
memcache.set(counter_name, total)


那當計數器被更新的時候怎麼辦?除了更新 entity 之外,若是計數器的 cache 還存在,可以直接把 cache 裡的值加 1,這樣計數器的總數就不必重新計算了:
# 更新計數器時,別忘了更新 cache
from google.appengine.api import memcache
import random

counter_name = 'product_counter'
shard_name = 'product_counter_%d' % random.randint(0, 3) # 假設我分成4個碎片
counter = Counter.get_by_key_name(shard_name)
if counter is None:
counter = Counter(key_name=shard_name, name=counter_name)
counter.count += 1
counter.put()

# 更新 cache
total = memcache.get(counter_name)
if total:
memcache.incr(counter_name) # 使用 incr 函式直接對數值資料加 1

這樣就不必擔心更新了計數器,卻忘記更新快取了。

2 則留言:

  1. 第二段的程式碼, 這三行
    counter = Counter.get_by_key_name(shard_name)
    if counter is None:
    counter = Counter(key_name=shard_name, name='product_counter')

    如果改成
    counter = Counter.get_or_insert(shard_name, name='product_counter')

    會不會比較好呢? ;)

    回覆刪除
  2. @scw,
    你說的沒錯,感謝你的補充 :-)

    回覆刪除