【问题标题】:Improve the calculation of distances between objects in queryset改进查询集中对象之间距离的计算
【发布时间】:2019-12-07 13:33:18
【问题描述】:

我的 Django 项目中有下一个模型:

class Area(models.Model):
    name = models.CharField(_('name'), max_length=100, unique=True)
    ...

class Zone(models.Model):
    name = models.CharField(verbose_name=_('name'),
                            max_length=100,
                            unique=True)
    area = models.ForeignKey(Area,
                             verbose_name=_('area'),
                             db_index=True)
    polygon = PolygonField(srid=4326,
                           verbose_name=_('Polygon'),)
    ...

Area 就像一个城市,Zone 就像一个区。

所以,我想为每个区域缓存其区域内其他区域的顺序。像这样的:


def store_zones_by_distance():
    zones = {}
    zone_qs = Zone.objects.all()
    for zone in zone_qs:
        by_distance = Zone.objects.filter(area=zone.area_id).distance(zone.polygon.centroid).order_by('distance').values('id', 'name', ...)
        zones[zone.id] = [z for z in by_distance]
    cache.set("zones_by_distance", zones, timeout=None)

但问题是它效率不高且不可扩展。我们有 382 个区域,这个函数获取 383 个对 DB 的查询,而且速度很慢(SQL 时间为 3.80 秒,全局时间为 4.20 秒)。

是否有任何有效且可扩展的方法来获得它。我曾想过这样的事情:

def store_zones_by_distance():
    zones = {}
    zone_qs = Zone.objects.all()
    for zone in zone_qs.prefetch_related(Prefetch('area__zone_set', queryset=Zone.objects.all().distance(F('polygon__centroid')).order_by('distance'))):
        by_distance = zone.area.zone_set.all().values('id', 'name', ...)
        zones[zone.id] = [z for z in by_distance]

这显然不起作用,但是像这样,在 SQL 中缓存(与预取相关)排序的区域(area__zone_set)。

EDIT store_zones_by_distance 将返回(或设置在缓存中)如下内容:

{
    1: [{"id": 1, "name": "Zone 1"}, {"id": 2, "name": "Zone 2"}, {"id": 2, "name": "Zone 4"}, {"id": 2, "name": "Zone 3"}],
    2: [{"id": 2, "name": "Zone 2"}, {"id": 2, "name": "Zone 4"}, {"id": 2, "name": "Zone 1"}, {"id": 2, "name": "Zone 3"}],
    ...
}

【问题讨论】:

    标签: python django gis postgis geodjango


    【解决方案1】:

    您可以进行嵌套预取,从而产生 3 个查询。

    def store_zones_by_distance():
        area_qs = Area.objects.prefetch_related(Prefetch(
            'zone_set',
            queryset=Zone.objects.annotate(
                distance=F('polygon__centroid')
            ).order_by('distance')
        ))
        zones = Zone.objects.all().prefetch_related(Prefetch(
            'area',
            queryset=area_qs,
            to_attr='prefetched_area'
        ))
    
        zones_dict = {}
        for zone in zones:
            zones_dict[zone.id] = zone.prefetched_area.zone_set
    

    更新使用来自 @JohnMoutafis 的函数与 django.forms.model_to_dict 相结合,在 2 个查询中完成您的预期输出。

    from django.db.models import F, Prefetch
    from django.forms import model_to_dict
    
    def store_zones_by_distance():
        zones = {}
        areas = Area.objects.prefetch_related(Prefetch(
            'zone_set',
            queryset=Zone.objects.annotate(
                distance=Centroid('polygon')
            ).order_by('distance')
        ))
    
        for area in areas:
            for zone in area.zone_set.all():
                zones[zone.id] = [
                    model_to_dict(zone, fields=['id', 'name'])
                    for zone in area.zone_set.all()
                ]
    

    【讨论】:

    • 感谢这个星期一将测试您的解决方案,如果可行,我将关闭赏金!
    • 请看 JohnMoutafis 的回答中的两个 cmets。您的解决方案与另一个解决方案存在相同的问题。
    • 我已经更新了我的解决方案。可以将相同的逻辑应用于原始嵌套预取,但我认为您希望使用最少的查询来做到这一点。不确定循环的可扩展性如何。
    • 很抱歉,我收到下一个错误:“ValueError: Cannot use object with type F for a spatial lookup parameter.”
    • @Goin 这很奇怪,因为它的注释与您的 OP 中的注释相同,我的印象是它正在工作。我已经根据您的其他评论更新了我的代码答案。如果更新的注释不起作用,您必须自己解决。要测试代码是否提供了预期的输出(尽管未排序),您可以删除注释并运行该函数。
    【解决方案2】:

    更新:经过我们的反复讨论,我相信我们可以找到解决这个问题的可行方案。

    您需要按区域之间的距离对区域进行大小写。据我了解,这不需要发生多次(因此您使用的是缓存)。
    本质上,您需要在服务器启动时以及每次在数据库上更新(添加、删除、修补等)新区域时设置一次此缓存。

    我们可以使用AppConfig.ready() 函数在服务器启动时设置缓存,然后我们可以为区域更新案例创建post_savepost_delete 信号。

    让我们编写我们将在这两种情况下使用的实用方法:

    from django.db.models import Q
    from django.forms import model_to_dict
    
    def store_zones_by_distance():
        zones = {}
        areas = Area.objects.prefetch_related(`zone_set`).all()
    
        for area in areas:
            for zone in area.zone_set.all():
                ordered_zones = area.zone_set.filter(~Q(id=zone.id)).distance(
                    zone.polygon.centroid
                ).order_by('distance')
    
                zones[zone.id] = [
                   model_to_dict(ordered_zone, fields=['id', 'name'])
                   for ordered_zone in ordered_zones
                ]
        cache.set("zones_by_distance", zones, timeout=None)
    

    方法说明:

    • ordered_zones 将返回除我们当前正在检查的区域之外的所有区域(因此 filter(~Q(id=zone.id)) 转换为“过滤 id 为 NOT 当前区域 id 的区域” ) 按其质心到当前区域质心的距离排序。
    • 利用@bdoubleu model_to_dict 的建议,我们正在以字典表示形式创建模型实例列表。
    • 每个区域的最终结果如下所示:[{"id": 1, "name": "Zone 1"}, {"id": 2, "name": "Zone 2"}, ...]

    现在我们需要创建post_savepost_delete 信号并将所有内容连接到AppConfig.ready() 函数(基本上我们将按照此处描述的步骤进行操作:Django Create and Save Many instances of model when another object are created 稍加改动)。

    我假设store_zones_by_distance 是在your_app/utils.py 中创建的(不过你可以在任何地方创建它)

    1. your_app/signals.py 中创建post_savepost_delete 信号:

      from django.db.models.signals import post_save, post_delete
      from django.dispatch import receiver
      
      from your_app.models import Zone
      from your_app.utils import store_zones_by_distance
      
      
      @receiver(post_save, sender=Zone)
      def update_added_zone_cache(sender, instance, created, **kwargs):
          store_zones_by_distance()
      
      @receiver(post_delete, sender=Zone)
      def update_removed_zone_cache(sender, instance, *args, **kwargs):
          store_zones_by_distance()
      
    2. 在服务器启动时运行store_zones_by_distance 并连接your_app/app.py 中的信号:

      class YourAppConfig(AppConfig):
          name = 'your_project.your_app'
      
          def ready(self):
              import your_project.your_app.signals
              # Run it once at server start
              store_zones_by_distance()
      

    您不会在查询上节省太多,但您将准备好缓存,而不会阻塞任何端点,直到它更新。


    出于评论遗留原因,我将把它留在这里,但这不是@Goin 想要的解决方案。

    我相信你已经非常接近一个好的解决方案了。
    正如您在尝试更优化的解决方案时已经尝试过的那样,you can access the foreign key related objects with the _set notation。在您的情况下,您可以使用zones_setArea 访问Zones
    _set 允许您照常对其应用任何查询集方法。

    现在,为了避免多次 DB 命中,我们需要构造 a custom Prefetch,我们将添加 polygon__centroid 距离作为注释。
    说了这么多,让我们实现它:

    def store_zones_by_distance():
        zones = {}
        areas = Area.objects.prefetch_related(
            Prefetch(
                `zone_set`,
                queryset=Zone.object.all().annotate(
                    centroid_distance=Centroid('polygon')
                ).order_by('centroid_distance')
            )
        ).all()
    
        for area in areas:
            for zone in area.zone_set.all():
                zones[zone.id] = area.zone_set.all().values_list('id', 'name', ...)
    

    这将导致对数据库的单个查询将获取您的方法所需的所有内容。
    编辑: 正如@bdoubleu 提到的,values_list 将导致每个区域的额外查询,因此您可能希望放弃它并将查询集保留在字典 zones[zone.id] = area.zone_set.all() 中。
    请记住,使用 2 for 可能仍然很耗时。

    【讨论】:

    • 调用.values_list() 将导致对每个区域进行额外查询,因此希望他能找到解决方案。否则,这个答案似乎是最好的 2 个查询 vs 我的答案的 3 个查询。 zone_set 也是一个查询集而不是一个列表。
    • @bdoubleu 好点!他可能应该一起放弃values_list
    • 谢谢,我将在下周一测试您的解决方案,如果可行,我将关闭赏金!
    • 嗨@JohnMoutafis。由于三个原因,您的解决方案不起作用。首先我得到下一个错误:“FieldError:无法将关键字'centroid'解析为字段。不允许加入'polygon'。”但我可以修复它:“annotate(centroid_distance=Centroid('polygon'))”。第二个错误,你是多边形的中心(polygon__centroid),我想按区域之间的 distance 排序,但我也可以修复它:“area.zone_set.all().distance(zone.polygon .centroid).order_by('distance').values_list('id', 'name', ...)"....
    • @Goin 你能解释一下你将如何使用zones 的列表以便我理解并可能提供解决方案吗?
    【解决方案3】:

    对不起,我不能评论,因为我很新,所以我必须在这里写下建议。 在您的第一个示例中:

    def store_zones_by_distance():
        zones = {}
        zone_qs = Zone.objects.all()
        for zone in zone_qs:
            by_distance = Zone.objects.filter(area=zone.area_id).distance(zone.polygon.centroid).order_by('distance').values('id', 'name', ...)
            zones[zone.id] = [z for z in by_distance]
        cache.set("zones_by_distance", zones, timeout=None)
    

    有趣的是,当你改变时需要多长时间:

    zone_qs = Zone.objects.all()
    

    to:

    zone_qs = Zone.objects.all().prefetch_related("area")
    

    by_distance = Zone.objects.filter(area=zone.area_id).distance...
    

    to:

    by_distance = zone_qs.objects.filter(area=zone.area_id).distance...
    

    希望我能为这个话题提供一些有用的东西。

    【讨论】:

    • 感谢@swydngr,但这不是解决方案。我得到的 SQL 查询比我的解决方案多,而且时间几乎没有增加。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-07-04
    • 2014-11-29
    • 1970-01-01
    相关资源
    最近更新 更多