高性能Web開發(fā):減少數(shù)據(jù)庫往返
背景
Web程序的后端主要有兩個東西:渲染(生成HTML,或數(shù)據(jù)序列化)和IO(數(shù)據(jù)庫操作,或內(nèi)部服務調(diào)用)。今天要講的是后面那個,關注一下如何減少數(shù)據(jù)庫往返這個問題。最快的查詢是不存在的,沒有最快,只有更快!
開始講之前我得提一下Schema的重要性,但不會在這花太多時間。單獨一個因素不會影響程序的整體響應速度,有調(diào)數(shù)據(jù)的能力,比有一個好的數(shù)據(jù)(庫)Schema要強得多。這些東西以后會細講,但Schema問題常會限制你的選擇,所以現(xiàn)在提一下。
我也會提一下緩存。在理想情況下,我要討論的東西能有效減少返回不能緩存或緩存丟失的數(shù)據(jù)的時間,但跟通過優(yōu)化查詢減少數(shù)據(jù)庫往返次數(shù)一樣,避免將全部東西扔進緩存里是個極大的進步。
最后得提一下的是,文中我用的是Python(Django),但原理在其他語言或ORM框架里也適用。我以前搞過Java(Hibernate),不太順手,后來搞Perl(DBIX::Class)、Ruby(Rails)以及其他幾種東西去了。
N+1 Selects問題
關于數(shù)據(jù)庫往返最常見又讓人吃驚的問題是n+1 selects問題。這個問題最簡單的形式包括一個有子對象的實體,和一對多的關系。下面是一個小例子。
- 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',)
上面定義了州跟市,一個州有0或多個市,這個例子程序用來打印一個州跟市的內(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
要完成這個功能的代碼如下:
- 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>
- ...
如果將上面的代碼跑起來,生成相應的HTML,通過django-debug-toolbar就會看到有一個用于列出全部的州查詢,然后對應每個州有一個查詢,用于列出這個州下面的市。如果只有3個州,這不是很多,但如果是50個,“+1”部分還是一個查詢,為了得到全部對應的市,“N"則變成了50。
2N+1 (不,這不算個事)
在開始搞這個N+1問題之前,我要給每個州加一個屬性,就是它所屬的國家。這就引入另一個一對多關系。每個州只能屬于一個國家。
- 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)在處理每個州時都得查詢一下它所屬的國家。注意,這里只能不停的檢索同一個州,因為這些州都是同一個國家的。
現(xiàn)在就有兩個有趣的問題了,這是每個Django ORM方案都要面對的問題。
#p#
select_related
- states = State.objects.select_related('country').all()
select_related通過在查詢主要對象(這里是州state)和其他對象(這里是國家country)之間的SQL做手腳起作用。這樣就可以省去為每個州都查一次國家。假如一次數(shù)據(jù)庫往返(網(wǎng)絡中轉(zhuǎn)->運行->返回)用時20ms,加起來的話共有N*20ms。如果N足夠大,這樣做挺費時的。
下面是新的檢索州的查詢:
- SELECT ... FROM "locations_state"
- INNER JOIN "locations_country" ON
- ("locations_state"."country_id" = "locations_country"."id")
- ORDER BY "locations_state"."name" ASC
- ...
用上面這個查詢?nèi)〈f的,能省去用來找國家的二級查詢。然而,這種解決有一個潛在的缺點,即反復的返回同一個國家對象,從而不得不一次又一次的將這一行傳給ORM代碼,生成大量重復的對象。等下我們還會再說說這個。
在繼續(xù)往下之前得說一下,在Django ORM中,如果關系中的一方有多個對象,select_related是沒用的。它能用來為一個州抓取對應的國家,但如果調(diào)用時添上“市”,它什么都不干。其他ORM框架(如Hibernate)沒有這種限制,但要用類似功能時得特別小心,這類框架會在join的時候為二級對象重復生成一級對象,然后很快就會失控,ORM滯在那里不停的處理大量的數(shù)據(jù)或結果行。
綜上所述,select_related的最好是在取單獨一個對象、同時又想抓取到關聯(lián)的(一個)對象時用。這樣只有一次數(shù)據(jù)庫往返,不會引入大量重復數(shù)據(jù),這在Django ORM只有一對一關系時都適用。
prefetch_related
- states = State.objects.prefetch_related('country', 'cities').all()
相反地, prefetch_related 的功能是收集關聯(lián)對象的全部id值,一次性批量獲取到它們,然后透明的附到相應的對象。這種方式最好的一個地方是能用在一對多關系中,比如本例中的州跟市。
下面是這種方式生成的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扔掉是個大進步。3 * 20ms總是會比(2 * 50 + 1) * 20ms 小,甚至比用select_related時的 (50 + 1) * 20ms也小。
上面這個例子對國家跟市都采用了prefetch。前面說過這里的州都屬同一國家,用select_related獲得州記錄時,這意味著要取到并處理這一國家記錄N次。相反,用prefetch_related只要取一次。而這樣會引入一次額外的數(shù)據(jù)庫往返,有沒有可能綜合兩種方式,你得在你的機器及數(shù)據(jù)上試試。然而,在本例中同時用select_related 和 prefetch_related可以將時間降到2 * 20ms,這可能會比分3次查詢要快,但也有很多潛在因素要考慮。
- states = State.objects.select_related('country') \
- .prefetch_related('cities').all()
能支持多深的關系?
要跨多個級別時怎么辦?select_related 和prefetch_related都可以通過雙下劃線遍歷關系對象。用這個功能時,中間對象也會包括在內(nèi)。這很有用,但在更復雜的對象模型中有點難用。
- # 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用在原生查詢
最后一點。上周的 efficiently querying for nearby things 一文中,為了實現(xiàn)查找最近的經(jīng)度/緯度點,我寫了一條復雜的SQL。其實最好的方法是寫一條原生的sql查詢 。而原生查詢不支持prefetch_related,挺可惜的。但有一個變通的方法,即可以直接用Django實現(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