高性能 Web 開發:減少數據庫往返

jopen 11年前發布 | 11K 次閱讀 性能

背景

Web程序的后端主要有兩個東西:渲染(生成HTML,或數據序列化)和IO(數據庫操作,或內部服務調用)。今天要講的是后面那個,關注一下如何減少數據庫往返這個問題。最快的查詢是不存在的,沒有最快,只有更快!

開始講之前我得提一下Schema的重要性,但不會在這花太多時間。單獨一個因素不會影響程序的整體響應速度,有調數據的能力,比有一個好的數據(庫)Schema要強得多。這些東西以后會細講,但Schema問題常會限制你的選擇,所以現在提一下。

我也會提一下緩存。在理想情況下,我要討論的東西能有效減少返回不能緩存或緩存丟失的數據的時間,但跟通過優化查詢減少數據庫往返次數一樣,避免將全部東西扔進緩存里是個極大的進步。

最后得提一下的是,文中我用的是Python(Django),但原理在其他語言或ORM框架里也適用。我以前搞過Java(Hibernate),不太順手,后來搞Perl(DBIX::Class)、Ruby(Rails)以及其他幾種東西去了。

N+1 Selects問題

關于數據庫往返最常見又讓人吃驚的問題是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',)</pre> <p>上面定義了州跟市,一個州有0或多個市,這個例子程序用來打印一個州跟市的內聯列表。 </p>

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))</pre>

...
<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')

...</pre>

...
<li>{{ state.name }} ({{ state.country.name }})
...

在django-debug-toolbar的SQL窗口里,能看到現在處理每個州時都得查詢一下它所屬的國家。注意,這里只能不停的檢索同一個州,因為這些州都是同一個國家的。

2N+1 queries, not good

現在就有兩個有趣的問題了,這是每個Django ORM方案都要面對的問題。

select_related

states = State.objects.select_related('country').all()

select_related通過在查詢主要對象(這里是州state)和其他對象(這里是國家country)之間的SQL做手腳起作用。這樣就可以省去為每個州都查一次國家。假如一次數據庫往返(網絡中轉->運行->返回)用時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
...

用上面這個查詢取代舊的,能省去用來找國家的二級查詢。然而,這種解決有一個潛在的缺點,即反復的返回同一個國家對象,從而不得不一次又一次的將這一行傳給ORM代碼,生成大量重復的對象。等下我們還會再說說這個。

在繼續往下之前得說一下,在Django ORM中,如果關系中的一方有多個對象,select_related是沒用的。它能用來為一個州抓取對應的國家,但如果調用時添上“市”,它什么都不干。其他ORM框架(如Hibernate)沒有這種限制,但要用類似功能時得特別小心,這類框架會在join的時候為二級對象重復生成一級對象,然后很快就會失控,ORM滯在那里不停的處理大量的數據或結果行。

綜上所述,select_related的最好是在取單獨一個對象、同時又想抓取到關聯的(一個)對象時用。這樣只有一次數據庫往返,不會引入大量重復數據,這在Django ORM只有一對一關系時都適用。

prefetch_related

states = State.objects.prefetch_related('country', 'cities').all()

相反地, prefetch_related 的功能是收集關聯對象的全部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只要取一次。而這樣會引入一次額外的數據庫往返,有沒有可能綜合兩種方式,你得在你的機器及數據上試試。然而,在本例中同時用select_related 和 prefetch_related可以將時間降到2 * 20ms,這可能會比分3次查詢要快,但也有很多潛在因素要考慮。

states = State.objects.select_related('country') \
    .prefetch_related('cities').all()

2 queries, pretty good

能支持多深的關系?

要跨多個級別時怎么辦?select_related 和prefetch_related都可以通過雙下劃線遍歷關系對象。用這個功能時,中間對象也會包括在內。這很有用,但在更復雜的對象模型中有點難用。

# 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)</pre>

prefetch_related用在原生查詢

最后一點。上周的 efficiently querying for nearby things 一文中,為了實現查找最近的經度/緯度點,我寫了一條復雜的SQL。其實最好的方法是寫一條原生的sql查詢 。而原生查詢不支持prefetch_related,挺可惜的。但有一個變通的方法,即可以直接用Django實現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)</pre>

這多牛呀!

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!