请不要以python思维对待django ORM

2,211 阅读4分钟

如果一个web请求需要花费几秒,99%是因为数据库没用好。 当使用ORM的时候,很自然地会想要用python的思维方式来处理数据查询,但是这种思维方式会杀死你的性能。改用子查询(subqueries)和annotations,以sql的思维思考,可以大幅度提高你的web性能。

有一天你打开Datadog,看到一张这样的图:

红色的区域表示进行了数据库请求。这一次web请求进行了644次数据库请求!只有18.6%的时间在做真正有用的事。单次的数据库请求是很快的,但是这么多请求加起来就会严重拖慢web请求速度。 在django这个上下文下,每一次数据库请求,都需要分配内存,model和数据库映射时,还需要序列化和反序列化,然后还要通过网络传输数据。

对于一次web请求,数据库分配到的工作越多,数据库请求次数越少,效率越高。

如果将这644次数据库请求转换成一次,响应速度可以提高将近40倍。

数据库查询性能清单

  • 无论数据大小,请求次数是不是都是常数?
  • 你是否只从数据库取真正需要的数据?
  • 这个问题只能使用Python循环解决吗?

打破Python思维模式

有一个City model,其中有一个计算城市人口密度的方法density。

class City(models.Model):
    state = models.ForeignKey(State, related_name='cities')
    name = models.TextField()
    population = models.DecimalField()
    land_area_km = models.DecimalField()
    def density(self):
       return self.population / self.land_area_km

想要计算一个城市的人口密度,下面这种方式是很自然就能想到的:

>>> illinois = State.objects.get(name='Illinois')
>>> chicago = City.objects.create(
    name="Chicago",
    state=illinois,
    population=2695598,
    land_area_km=588.81
)
>>> chicago.density()
4578.04...

问题出在当我们想要查询出所有拥挤(密度大于4000)的城市时:

class City(models.Model):
    ...
    @classmethod
    def dense_cities(cls):
        return [
            city for city in City.objects.all()
            if city.density() > 4000
        ]

如果只有5%的城市是拥挤的,那么将会有95%的数据最终会被丢弃。**在数据中过滤,一定是比将数据导入内存,然后让Python过滤效率要高的!**对于不需要的数据,django都需要花时间完成额外、无意义的操作:将数据转换成model实例。对于数据量小的应用到没什么,但是一旦数据库一大,对性能照成的影响是巨大的。

使用annotate

objects = CitySet.as_manager()这一行表示对City这一model使用自定义的ModelManager,这里不展开讲了,有兴趣可以自己搜索一下。 关于annotate的使用,请参考今天一起发的另一篇文章:Django annotation,减少IO次数利器。

class CitySet(models.QuerySet):
    def add_density(self):
        return self.annotate(
            density=F('population') / F('land_area_km')
        )
    def dense_cities(self):
        self.add_density().filter(density__gt=4000)

class City(models.Model):
    ...
    objects = CitySet.as_manager()

annotate(density=F('population') / F('land_area_km'))中的F aggregate函数表示获取population和land_area_km的值。

self.annotate(
  density=F('population') / F('land_area_km')
)

表示对于一个queryset,给他其中的每一项object,加上一个density字段,值为population /land_area_km。

>>> City.objects.dense_cities().values_list('name', 'density')
<QuerySet [("New York City", Decimal('10890.23')), ...]>

# Reverse descriptor
>>> illinois.city.dense_cities().values_list('name', 'density')
<QuerySet [("Chicago", Decimal('4578.04')), ...]>

解释一下:

City.objects.dense_cities().values_list('name', 'density')

这个查询语句的queryset是所有的city object,应该是直接用City这个model调用objects。先调用annotate(density=F('population') / F('land_area_km')),给每个object加上density这个字段,最后筛选出density大于4000的。

illinois.city.dense_cities().values_list('name', 'density')

这个查询语句的queryset是illinois州的所有城市。

这种方法比前面循环的方法效率高多了,因为IO只有一次。

使用subquery

一次查询效率比多次查询高。 杀死django性能最简单的方式就是在for循环中使用query。

要筛选出所有存在dense城市的州:

[
    state for state in State.objects.all()
    if state.cities.dense_cities().exists()
]

类似这种,exists()会进行一次额外的查询,这会累计很多次毫秒级的查询。加起来的时间也是很可观的。可以用subquery解决这个问题。

最基本的使用方法:

state_ids = City.objects.dense_cities().values('state_id') 
State.objects.filter(id__in=Subquery(state_ids))
// 或者也可以把Subquery省略掉
State.objects.filter(id__in=state_ids)

这样就把很多次的exists查询降低到了一次。

更进一步,和前面说过的annotate结合起来:

class StateSet(models.QuerySet):
    def add_dense_cities(self):
        return self.annotate(
            has_dense_cities=Exists(
               City
               .objects
               .filter(state=OuterRef('id'))
               .dense_cities()
            )
        )

class State(models.Model):
    ...
    objects = StateSet.as_manager()

filter(state=OuterRef('id'))就是筛选出 state object的所有city,然后调用dense_cities筛选dense城市,然后调用Exists聚合函数,返回True或False。add_dense_cities就给state queryset里的每一个object加上了一个has_dense_cities字段。

最后使用这个查询:

State.objects.add_dense_cities().filter(has_dense_cities=True)

总结

提高数据库查询效率的一个重要原则就是降低IO查询次数,尽量避免使用for循环,试试annotate和subquery吧!

关注我的微信公众号