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

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

2009年7月6日 星期一

支援交易運算的計數器

支援交易運算


在許多資料庫運算中,為了保持相關的資料庫運算結果能夠不被分割地將同時送進資料庫(也就是一旦有一個操作發生失敗,則整串操作都會同時取消),所以提供了「交易」(transaction)的運算。

Google App Engine 上的 datastore 當然也支援「交易」運算,在上述的例子中,共有兩個資料庫運算--Product entity 的寫入以及 Counter entity 的更新,試著想像這兩個操作若各自(或同時)發生錯誤時會有什麼問題,若是 Product entity 在寫入時發生錯誤,程式就中斷了,那就沒什麼太大的問題,若是 Product entity 成功寫入 datastore,但 Counter entity 在更新時發生錯誤,這時就會造成計數器的統計數量不一致,這時候就適合使用交易運算了。

交易運算的限制


在 Google App Engine 中,交易運算只能操作相同 entity group 的資料,如果我們要將 ProductCounter 的新增動作在同一個交易運算中完成的話,就必須讓它們成為同一個 entity group。

要成為同一個 entity group,不管是 Product 還是 Counter 都需要一個共同的 parent,因此需要一個額外的 data model 來作為這個 entity group 的 root,這裡我提供一個 Index 的 model,順便用來作為每一筆 Product 資料的「序號產生器」 :p
class Index(db.Model):
max_index = db.IntegerProperty(required=True, default=0)

有了這個 model 後,新增 Product 及更新 Counter 的動作就可以改寫為:
# 新增 product 的程式片段...
...
# 從 Index 中取出 Product 的 index
ind = Index.get_by_key_name('product')
if ind is None:
ind = Index()
ind.max_index += 1
ind.put()

# 新增 product entity 並設定 parent 為 ind
p = Product(parent=ind, key_name="product_%d" % ind.max_index,........)
p.put()

# 根據 key_name 取得 counter,並且指定 parent
counter = Counter.get_by_key_name('product_counter', parent=ind.key())
if counter is None:
# 如果 counter 不存在,則建立一個新的,別忘了指定 key_name 及 parent
counter = Counter(parent=ind, key_name='product_counter')

counter.count += 1
counter.put()

在新增 ProductCounter 時,都加上指定 parent 的參數,以此將這些資料建構成一個 entity group,然而,一旦資料在 entity group 中,在取出時也要加上 parent 參數。

作成交易運算的函式


因此,我們可以把上面的程式碼包成一個函式,比方說是 create_product,這樣就可以利用 Datastore API 中的 run_in_transaction 函式來作成交易運算了。

....
def create_product():
# 放入上述的程式碼

....
# 新增資料時...
from google.appengine.ext import db
db.run_in_transaction(create_product)


如此一來,只有當 Index, ProductCounter 的資料操作都成功時,更新的資料才會進入 datastore 中。