高性能Web開發(fā):減少數(shù)據(jù)庫(kù)往返
背景
Web程序的后端主要有兩個(gè)東西:渲染(生成HTML,或數(shù)據(jù)序列化)和IO(數(shù)據(jù)庫(kù)操作,或內(nèi)部服務(wù)調(diào)用)。今天要講的是后面那個(gè),關(guān)注一下如何減少數(shù)據(jù)庫(kù)往返這個(gè)問(wèn)題。最快的查詢是不存在的,沒有最快,只有更快!
開始講之前我得提一下Schema的重要性,但不會(huì)在這花太多時(shí)間。單獨(dú)一個(gè)因素不會(huì)影響程序的整體響應(yīng)速度,有調(diào)數(shù)據(jù)的能力,比有一個(gè)好的數(shù)據(jù)(庫(kù))Schema要強(qiáng)得多。這些東西以后會(huì)細(xì)講,但Schema問(wèn)題常會(huì)限制你的選擇,所以現(xiàn)在提一下。
我也會(huì)提一下緩存。在理想情況下,我要討論的東西能有效減少返回不能緩存或緩存丟失的數(shù)據(jù)的時(shí)間,但跟通過(guò)優(yōu)化查詢減少數(shù)據(jù)庫(kù)往返次數(shù)一樣,避免將全部東西扔進(jìn)緩存里是個(gè)極大的進(jìn)步。
最后得提一下的是,文中我用的是Python(Django),但原理在其他語(yǔ)言或ORM框架里也適用。我以前搞過(guò)Java(Hibernate),不太順手,后來(lái)搞Perl(DBIX::Class)、Ruby(Rails)以及其他幾種東西去了。
N+1 Selects問(wèn)題
關(guān)于數(shù)據(jù)庫(kù)往返最常見又讓人吃驚的問(wèn)題是n+1 selects問(wèn)題。這個(gè)問(wèn)題最簡(jiǎn)單的形式包括一個(gè)有子對(duì)象的實(shí)體,和一對(duì)多的關(guān)系。下面是一個(gè)小例子。
- from django.db import models
- class State(models.Model):
- name = models.CharField(max_length=64)
- country = models.ForeignKey(Country, related_name='states')
- class Meta:
- ordering = ('name',)
- class City(models.Model):
- name = models.CharField(max_length=64)
- state = models.ForeignKey(State, related_name='cities')
- class Meta:
- ordering = ('name',)
上面定義了州跟市,一個(gè)州有0或多個(gè)市,這個(gè)例子程序用來(lái)打印一個(gè)州跟市的內(nèi)聯(lián)列表。
- Alaska
- Anchorage
- Fairbanks
- Willow
- California
- Berkeley
- Monterey
- Palo Alto
- San Diego
- San Francisco
- Santa Cruz
- Kentucky
- Albany
- Monticello
- Lexington
- Louisville
- Somerset
- Stamping Ground
要完成這個(gè)功能的代碼如下:
- from django.shortcuts import render_to_response
- from django.template.context import RequestContext
- from locations.models import State
- def list_locations(request):
- data = {'states': State.objects.all()}
- return render_to_response('list_locations.html', data,
- RequestContext(request))
- ...
- <ul>
- {% for state in states %}
- <li>{{ state.name }}
- <ul>
- {% for city in state.cities.all %}
- <li>{{ city.name }}</li>
- {% endfor %}
- </ul>
- </li>
- {% endfor %}
- </ul>
- ...
如果將上面的代碼跑起來(lái),生成相應(yīng)的HTML,通過(guò)django-debug-toolbar就會(huì)看到有一個(gè)用于列出全部的州查詢,然后對(duì)應(yīng)每個(gè)州有一個(gè)查詢,用于列出這個(gè)州下面的市。如果只有3個(gè)州,這不是很多,但如果是50個(gè),“+1”部分還是一個(gè)查詢,為了得到全部對(duì)應(yīng)的市,“N"則變成了50。
2N+1 (不,這不算個(gè)事)
在開始搞這個(gè)N+1問(wèn)題之前,我要給每個(gè)州加一個(gè)屬性,就是它所屬的國(guó)家。這就引入另一個(gè)一對(duì)多關(guān)系。每個(gè)州只能屬于一個(gè)國(guó)家。
- Alaska (United States)
- ...
- ...
- class Country(models.Model):
- name = models.CharField(max_length=64)
- class State(models.Model):
- name = models.CharField(max_length=64)
- country = models.ForeignKey(Country, related_name='states')
- ...
- ...
- <li>{{ state.name }} ({{ state.country.name }})
- ...
在django-debug-toolbar的SQL窗口里,能看到現(xiàn)在處理每個(gè)州時(shí)都得查詢一下它所屬的國(guó)家。注意,這里只能不停的檢索同一個(gè)州,因?yàn)檫@些州都是同一個(gè)國(guó)家的。
現(xiàn)在就有兩個(gè)有趣的問(wèn)題了,這是每個(gè)Django ORM方案都要面對(duì)的問(wèn)題。
#p#
select_related
- states = State.objects.select_related('country').all()
select_related通過(guò)在查詢主要對(duì)象(這里是州state)和其他對(duì)象(這里是國(guó)家country)之間的SQL做手腳起作用。這樣就可以省去為每個(gè)州都查一次國(guó)家。假如一次數(shù)據(jù)庫(kù)往返(網(wǎng)絡(luò)中轉(zhuǎn)->運(yùn)行->返回)用時(shí)20ms,加起來(lái)的話共有N*20ms。如果N足夠大,這樣做挺費(fèi)時(shí)的。
下面是新的檢索州的查詢:
- SELECT ... FROM "locations_state"
- INNER JOIN "locations_country" ON
- ("locations_state"."country_id" = "locations_country"."id")
- ORDER BY "locations_state"."name" ASC
- ...
用上面這個(gè)查詢?nèi)〈f的,能省去用來(lái)找國(guó)家的二級(jí)查詢。然而,這種解決有一個(gè)潛在的缺點(diǎn),即反復(fù)的返回同一個(gè)國(guó)家對(duì)象,從而不得不一次又一次的將這一行傳給ORM代碼,生成大量重復(fù)的對(duì)象。等下我們還會(huì)再說(shuō)說(shuō)這個(gè)。
在繼續(xù)往下之前得說(shuō)一下,在Django ORM中,如果關(guān)系中的一方有多個(gè)對(duì)象,select_related是沒用的。它能用來(lái)為一個(gè)州抓取對(duì)應(yīng)的國(guó)家,但如果調(diào)用時(shí)添上“市”,它什么都不干。其他ORM框架(如Hibernate)沒有這種限制,但要用類似功能時(shí)得特別小心,這類框架會(huì)在join的時(shí)候?yàn)槎?jí)對(duì)象重復(fù)生成一級(jí)對(duì)象,然后很快就會(huì)失控,ORM滯在那里不停的處理大量的數(shù)據(jù)或結(jié)果行。
綜上所述,select_related的最好是在取單獨(dú)一個(gè)對(duì)象、同時(shí)又想抓取到關(guān)聯(lián)的(一個(gè))對(duì)象時(shí)用。這樣只有一次數(shù)據(jù)庫(kù)往返,不會(huì)引入大量重復(fù)數(shù)據(jù),這在Django ORM只有一對(duì)一關(guān)系時(shí)都適用。
prefetch_related
- states = State.objects.prefetch_related('country', 'cities').all()
相反地, prefetch_related 的功能是收集關(guān)聯(lián)對(duì)象的全部id值,一次性批量獲取到它們,然后透明的附到相應(yīng)的對(duì)象。這種方式最好的一個(gè)地方是能用在一對(duì)多關(guān)系中,比如本例中的州跟市。
下面是這種方式生成的SQL:
- SELECT ... FROM "locations_state" ORDER BY "locations_state"."name" ASC
- SELECT ... FROM "locations_country" WHERE "locations_country"."id" IN (1)
- SELECT ... FROM "locations_city"
- WHERE "locations_city"."state_id" IN (1, 2, 3)
- ORDER BY "locations_city"."name" ASC
這樣2N+1就變成3了。把N扔掉是個(gè)大進(jìn)步。3 * 20ms總是會(huì)比(2 * 50 + 1) * 20ms 小,甚至比用select_related時(shí)的 (50 + 1) * 20ms也小。
上面這個(gè)例子對(duì)國(guó)家跟市都采用了prefetch。前面說(shuō)過(guò)這里的州都屬同一國(guó)家,用select_related獲得州記錄時(shí),這意味著要取到并處理這一國(guó)家記錄N次。相反,用prefetch_related只要取一次。而這樣會(huì)引入一次額外的數(shù)據(jù)庫(kù)往返,有沒有可能綜合兩種方式,你得在你的機(jī)器及數(shù)據(jù)上試試。然而,在本例中同時(shí)用select_related 和 prefetch_related可以將時(shí)間降到2 * 20ms,這可能會(huì)比分3次查詢要快,但也有很多潛在因素要考慮。
- states = State.objects.select_related('country') \
- .prefetch_related('cities').all()
能支持多深的關(guān)系?
要跨多個(gè)級(jí)別時(shí)怎么辦?select_related 和prefetch_related都可以通過(guò)雙下劃線遍歷關(guān)系對(duì)象。用這個(gè)功能時(shí),中間對(duì)象也會(huì)包括在內(nèi)。這很有用,但在更復(fù)雜的對(duì)象模型中有點(diǎn)難用。
- # only works when there's a single object at each step
- city = City.objects.select_related('state__country').all()[0]
- # 1 query, no further db queries
- print('{0} - {1} - {2}'.format(city.name, city.state.name,
- city.state.country.name)
- # works for both single and multiple object relationships
- countries = Country.objects.prefetch_related('states__cities')
- # 3 queries, no further db queries
- for country in countries:
- for state in country.states:
- for city in state.cities:
- print('{0} - {1} - {2}'.format(city.name, city.state.name,
- city.state.country.name)
prefetch_related用在原生查詢
最后一點(diǎn)。上周的 efficiently querying for nearby things 一文中,為了實(shí)現(xiàn)查找最近的經(jīng)度/緯度點(diǎn),我寫了一條復(fù)雜的SQL。其實(shí)最好的方法是寫一條原生的sql查詢 。而原生查詢不支持prefetch_related,挺可惜的。但有一個(gè)變通的方法,即可以直接用Django實(shí)現(xiàn)prefetch_related功能的prefetch_related_objects。
- from django.db.models.query import prefetch_related_objects
- # prefetch_related_objects requires a list, it won't work on a QuerySet so
- # we need to convert with list()
- cities = list(City.objects.raw('<sql-query-for-nearby-cities>'))
- prefetch_related_objects(cities, ('state__country',))
- # 3 queries, no further db queries
- for city in cities:
- print('{0} - {1} - {2}'.format(city.name, city.state.name,
- city.state.country.name)
這多牛呀!
英文原文:High Performance Web: Reducing Database Round Trips
譯文鏈接:http://www.oschina.net/translate/high-performance-web-reducing-database-round-trips