Как оптимизировать производительность при сериализации множества полей геометрии GeoDjango?

Я разрабатываю приложение GeoDjango, в котором используется модель WorldBorder, предоставленная в руководстве. Я также создал свою собственную модель региона, привязанную к WorldBorder. Таким образом, WorldBorder/Country может иметь несколько регионов, в которых также есть границы (поле MultiPolygon).

Я сделал для него API с помощью DRF, но он настолько медленный, что для загрузки всех границ мира и регионов в формате GeoJSON требуется 16 секунд. Однако возвращаемый размер JSON составляет 10 МБ. Это разумно?

Я даже изменил сериализатор на serpy, что намного быстрее, чем DRF GIS, но предлагает повышение производительности только на 10%.

Оказывается, после профилирования большая часть времени тратится на функции ГИС для преобразования типа данных в базе данных в список координат вместо WKT. Если я использую WKT, сериализация намного быстрее (1,7 с по сравнению с 11,7 с, WKT только для WorldBorder MultiPolygon, все остальное по-прежнему в GeoJson)

Я также попытался сжать MultiPolygon с помощью ST_SimplifyVW с низким допуском (0,005), чтобы сохранить точность, что уменьшило размер JSON до 1,7 МБ. Это увеличивает общую нагрузку до 3,5 с. Конечно, я все еще могу найти лучший допуск, чтобы сбалансировать точность и скорость.

Ниже приведены данные профилирования (внезапное увеличение запросов в упрощенном MultiPolygon связано с неправильным использованием Django QS API для использования ST_SimplifyVW)

введите здесь описание изображения

РЕДАКТИРОВАТЬ: я исправил запрос БД, поэтому вызовы запросов остаются прежними при 75 запросах, и, как и ожидалось, это не значительно увеличивает производительность.

РЕДАКТИРОВАТЬ: я продолжал улучшать свои запросы к БД. Сейчас я сократил его до 8 запросов. Как и ожидалось, это не сильно улучшает производительность.

введите здесь описание изображения

Ниже приведено профилирование вызовов функций. Выделяю часть, на которую ушло больше всего времени. Здесь используется ванильная реализация ГИС DRF. imgur.com/AXWg3.png" alt="введите здесь описание изображения">

Ниже показано, как я использую WKT для одного из полей MultiPolygon без ST_SimplifyVW. введите здесь описание изображения

Вот модели по запросу @Udi

class WorldBorderQueryset(models.query.QuerySet):
    def simplified(self, tolerance):
        sql = "SELECT ST_SimplifyVW(mpoly, %s) AS mpoly"
        return self.extra(
            select={'mpoly': sql},
            select_params=(tolerance,)
        )


class WorldBorderManager(models.Manager):
    def get_by_natural_key(self, name, iso2):
        return self.get(name=name, iso2=iso2)

    def get_queryset(self, *args, **kwargs):
        qs = WorldBorderQueryset(self.model, using=self._db)
        qs = qs.prefetch_related('regions',)
        return qs

    def simplified(self, level):
        return self.get_queryset().simplified(level)


class WorldBorder(TimeStampedModel):
    name = models.CharField(max_length=50)
    area = models.IntegerField(null=True, blank=True)
    pop2005 = models.IntegerField('Population 2005', default=0)
    fips = models.CharField('FIPS Code', max_length=2, null=True, blank=True)
    iso2 = models.CharField('2 Digit ISO', max_length=2, null=True, blank=True)
    iso3 = models.CharField('3 Digit ISO', max_length=3, null=True, blank=True)
    un = models.IntegerField('United Nations Code', null=True, blank=True)
    region = models.IntegerField('Region Code', null=True, blank=True)
    subregion = models.IntegerField('Sub-Region Code', null=True, blank=True)
    lon = models.FloatField(null=True, blank=True)
    lat = models.FloatField(null=True, blank=True)

    # generated from lon lat to be one field so that it can be easily
    # edited in admin
    center_coordinates = models.PointField(blank=True, null=True)

    mpoly = models.MultiPolygonField(help_text='Borders')

    objects = WorldBorderManager()

    def save(self, *args, **kwargs):
        if not self.center_coordinates:
            self.center_coordinates = Point(x=self.lon, y=self.lat)
        super().save(*args, **kwargs)

    def natural_key(self):
        return self.name, self.iso2

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = 'Country'
        verbose_name_plural = 'Countries'
        ordering = ('name',)


class Region(TimeStampedModel):
    name = models.CharField(max_length=100, unique=True)
    country = models.ForeignKey(WorldBorder, related_name='regions')
    mpoly = models.MultiPolygonField(help_text='Areas')
    center_coordinates = models.PointField()

    moment_category = models.ForeignKey('moment.MomentCategory',
                                        blank=True, null=True)

    objects = RegionManager()
    no_joins = models.Manager()

    def natural_key(self):
        return (self.name,)

    def __str__(self):
        return self.name


# TODO might want to have separate table for ActiveCity for performance
# improvement since we have like 50k cities
class City(TimeStampedModel):
    country = models.ForeignKey(WorldBorder, on_delete=models.PROTECT,
                                related_name='cities')
    region = models.ForeignKey(Region, blank=True, null=True,
                               related_name='cities',
                               on_delete=models.SET_NULL)

    name = models.CharField(max_length=255)
    accent_city = models.CharField(max_length=255)
    population = models.IntegerField(blank=True, null=True)
    is_capital = models.BooleanField(default=False)

    center_coordinates = models.PointField()

    # is active marks that this city is a destination
    # only cities with is_active True will be put up to the frontend
    is_active = models.BooleanField(default=False)

    objects = DefaultSelectOrPrefetchManager(
        prefetch_related=(
            'yes_moment_beacons__activity__verb',
            'social_beacons',
            'video_beacons'
        ),
        select_related=('region', 'country')
    )
    no_joins = models.Manager()

    def natural_key(self):
        return (self.name,)

    def __str__(self):
        return self.name

    class Meta:
        verbose_name_plural = 'Cities'

class Beacon(TimeStampedModel):
    # if null defaults to city center coordinates
    coordinates = models.PointField(blank=True, null=True)
    is_fake = models.BooleanField(default=False)

    # can use city here, but the %(class)s gives no space between words
    # and it looks ugly

    def validate_activity(self):
        # activities in the region
        activities = self.city.region.moment_category.activities.all()
        if self.activity not in activities:
            raise ValidationError('Activity is not in the Region')

    def clean(self):
        self.validate_activity()

    def save(self, *args, **kwargs):
        # doing a full clean is needed here is to ensure code correctness
        # (not user),
        # because if someone use objects.create, clean() will never get called,
        # cons is validation will be done twice if the object is
        # created e.g. from admin
        self.full_clean()

        if not self.coordinates:
            self.coordinates = self.city.center_coordinates
        super().save(*args, **kwargs)

    class Meta:
        abstract = True


class YesMomentBeacon(Beacon):
    activity = models.ForeignKey('moment.Activity',
                                 on_delete=models.CASCADE,
                                 related_name='yes_moment_beacons')
    # ..........
    # other fields

    city = models.ForeignKey('world.City', related_name='yes_moment_beacons')

    objects = DefaultSelectOrPrefetchManager(
        select_related=('activity__verb',)
    )

    def __str__(self):
        return '{} - {}'.format(self.activity, self.coordinates)

# other beacon types.......

Вот мой сериализатор по запросу @Udi

class RegionInWorldSerializer(GeoFeatureModelSerializer):
    yes_moment_beacons = serializers.SerializerMethodField()
    social_beacons = serializers.SerializerMethodField()
    video_beacons = serializers.SerializerMethodField()

    center_coordinates = GeometrySerializerMethodField()

    def get_center_coordinates(self, obj):
        return obj.center_coordinates

    def get_yes_moment_beacons(self, obj):
        count = 0

        # don't worry, it's already prefetched in the manager
        # (including the below methods) so len is used instead of count
        cities = obj.cities.all()

        for city in cities:
            beacons = city.yes_moment_beacons.all()
            count += len(beacons)
        return count

    def get_social_beacons(self, obj):
        count = 0

        cities = obj.cities.all()

        for city in cities:
            beacons = city.social_beacons.all()
            count += len(beacons)
        return count

    def get_video_beacons(self, obj):
        count = 0

        cities = obj.cities.all()

        for city in cities:
            beacons = city.video_beacons.all()
            count += len(beacons)
        return count

    class Meta:
        model = Region
        geo_field = 'center_coordinates'
        fields = ('name', 'yes_moment_beacons', 'video_beacons',
                  'social_beacons')


class WorldSerializer(GeoFeatureModelSerializer):
    center_coordinates = GeometrySerializerMethodField()

    regions = RegionInWorldSerializer(many=True, read_only=True)

    def get_center_coordinates(self, obj):
        return obj.center_coordinates

    class Meta:
        model = WorldBorder
        geo_field = 'mpoly'

        fields = ('name', 'iso2', 'center_coordinates', 'regions')

Это основной запрос

def get_queryset(self):
    tolerance = self.request.GET.get('tolerance', None)
    if tolerance is not None:
        tolerance = float(tolerance)
        return WorldBorder.objects.simplified(tolerance)
    else:
        return WorldBorder.objects.all()

Вот фрагмент ответа API (1 из 236 объектов) с использованием ST_SimplifyVW с высоким допуском. Если я не использую его, Firefox зависает, потому что, я думаю, он не может обрабатывать 10 МБ JSON. Данные о границах этой конкретной страны малы по сравнению с другими странами. Возвращаемый здесь JSON сжат с 10 МБ до 750 КБ из-за ST_SimplifyVW. Даже имея всего 750 КБ JSON, на моем локальном компьютере это заняло 4,5 секунды.

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "coordinates": [
          [
            [
              [
                74.915741,
                37.237328
              ],
              [
                74.400543,
                37.138962
              ],
              [
                74.038315,
                36.814682
              ],
              [
                73.668304,
                36.909637
              ],
              [
                72.556641,
                36.821266
              ],
              [
                71.581131,
                36.346443
              ],
              [
                71.18779,
                36.039444
              ],
              [
                71.647766,
                35.419991
              ],
              [
                71.496094,
                34.959435
              ],
              [
                70.978592,
                34.504997
              ],
              [
                71.077209,
                34.052216
              ],
              [
                70.472214,
                33.944153
              ],
              [
                70.002777,
                34.052773
              ],
              [
                70.323318,
                33.327774
              ],
              [
                69.561096,
                33.08194
              ],
              [
                69.287491,
                32.526382
              ],
              [
                69.328247,
                31.940365
              ],
              [
                69.013885,
                31.648884
              ],
              [
                68.161102,
                31.830276
              ],
              [
                67.575546,
                31.53194
              ],
              [
                67.778046,
                31.332218
              ],
              [
                66.727768,
                31.214996
              ],
              [
                66.395538,
                30.94083
              ],
              [
                66.256653,
                29.85194
              ],
              [
                65.034149,
                29.541107
              ],
              [
                64.059143,
                29.41444
              ],
              [
                63.587212,
                29.503887
              ],
              [
                62.484436,
                29.406105
              ],
              [
                60.868599,
                29.863884
              ],
              [
                61.758331,
                30.790276
              ],
              [
                61.713608,
                31.383331
              ],
              [
                60.85305,
                31.494995
              ],
              [
                60.858887,
                32.217209
              ],
              [
                60.582497,
                33.066101
              ],
              [
                60.886383,
                33.557213
              ],
              [
                60.533882,
                33.635826
              ],
              [
                60.508331,
                34.140274
              ],
              [
                60.878876,
                34.319717
              ],
              [
                61.289162,
                35.626381
              ],
              [
                62.029716,
                35.448601
              ],
              [
                62.309158,
                35.141663
              ],
              [
                63.091934,
                35.432495
              ],
              [
                63.131378,
                35.865273
              ],
              [
                63.986107,
                36.038048
              ],
              [
                64.473877,
                36.255554
              ],
              [
                64.823044,
                37.138603
              ],
              [
                65.517487,
                37.247215
              ],
              [
                65.771927,
                37.537498
              ],
              [
                66.302765,
                37.323608
              ],
              [
                67.004166,
                37.38221
              ],
              [
                67.229431,
                37.191933
              ],
              [
                67.765823,
                37.215546
              ],
              [
                68.001389,
                36.936104
              ],
              [
                68.664154,
                37.274994
              ],
              [
                69.246643,
                37.094154
              ],
              [
                69.515823,
                37.580826
              ],
              [
                70.134995,
                37.529045
              ],
              [
                70.165543,
                37.871719
              ],
              [
                70.71138,
                38.409866
              ],
              [
                70.97998,
                38.470459
              ],
              [
                71.591934,
                37.902618
              ],
              [
                71.429428,
                37.075829
              ],
              [
                71.842758,
                36.692101
              ],
              [
                72.658508,
                37.021202
              ],
              [
                73.307205,
                37.462753
              ],
              [
                73.819717,
                37.228058
              ],
              [
                74.247208,
                37.409546
              ],
              [
                74.915741,
                37.237328
              ]
            ]
          ]
        ],
        "type": "MultiPolygon"
      },
      "properties": {
        "name": "Afghanistan",
        "iso2": "AF",
        "center_coordinates": {
          "coordinates": [
            65.216,
            33.677
          ],
          "type": "Point"
        },
        "regions": {
          "type": "FeatureCollection",
          "features": [
            {
              "type": "Feature",
              "geometry": {
                "coordinates": [
                  66.75292967820785,
                  34.52466146754814
                ],
                "type": "Point"
              },
              "properties": {
                "name": "Central Afghanistan",
                "yes_moment_beacons": 0,
                "video_beacons": 0,
                "social_beacons": 0
              }
            },
            {
              "type": "Feature",
              "geometry": {
                "coordinates": [
                  69.69726561529792,
                  35.96022296494905
                ],
                "type": "Point"
              },
              "properties": {
                "name": "Northern Highlands",
                "yes_moment_beacons": 0,
                "video_beacons": 0,
                "social_beacons": 0
              }
            },
            {
              "type": "Feature",
              "geometry": {
                "coordinates": [
                  63.89541422401191,
                  32.27442932956255
                ],
                "type": "Point"
              },
              "properties": {
                "name": "Southwestern Afghanistan",
                "yes_moment_beacons": 0,
                "video_beacons": 0,
                "social_beacons": 0
              }
            }
          ]
        }
      }
    },
    ........
}

Итак, дело в том, что GeoDjango не так быстр, как я ожидал, или ожидаются цифры производительности? Что я могу сделать, чтобы повысить производительность, продолжая выводить GeoJSON, то есть не WKT. Является ли толерантность тонкой настройки единственным способом? Я мог бы также отделить конечную точку для получения регионов.


person Abirafdi Raditya Putra    schedule 31.12.2017    source источник
comment
хорошо известно, что сериализация в транспортный формат является одним из узких мест в производительности. Сколько записей моделей вы сериализуете и насколько глубоко?   -  person Jason    schedule 01.01.2018
comment
@ Джейсон, на самом деле объектов не так много. Это несколько глубоко, но не слишком глубоко (например, 5-6), но каждый объект имеет большие данные MultiPolygon. У меня всего 236 объектов, но он создал 10 МБ JSON (несжатого) из-за упомянутого MultiPolygon. Наконец я столкнулся с проблемой, которая заставила меня задуматься, а неужели Python такой медленный? (или сериализация на других языках просто относительно медленная?)   -  person Abirafdi Raditya Putra    schedule 01.01.2018
comment
Зависит от нескольких вещей. Но я подозреваю, что вы запускаете это с сервером разработки django на своем собственном компьютере, который представляет собой единый процесс и определенно не оптимизирован для скорости. Бьюсь об заклад, если вы поместите это в среду развертывания nginx + gunicorn, ваши результаты будут значительно отличаться.   -  person Jason    schedule 01.01.2018
comment
@Jason Я настроил сервер разработки с производственной конфигурацией (приложение докеризовано, uwsgi и nginx), хотя это не окончательно, например. некоторая конфигурация отсутствует и не оптимизирована. К сожалению, время загрузки немного медленнее. В целом сервер разработки работает низко, я могу исследовать это позже, но посмотрим, смогу ли я добиться значительного улучшения производительности.   -  person Abirafdi Raditya Putra    schedule 01.01.2018
comment
Интересно. Возможно, вы захотите довести это до сведения списка рассылки разработчиков geodjango.   -  person Jason    schedule 01.01.2018
comment
Вы сохранили упрощенные полигоны в новом поле для кэширования? Вы пытались сохранить геоджсон в новом поле для кэширования банок? Вы служите для использования на карте js?   -  person Udi    schedule 01.01.2018
comment
Можете ли вы опубликовать свои модели и основной запрос?   -  person Udi    schedule 01.01.2018
comment
Привет @Уди. Нет, я вообще не использую кеширование. Но я не думаю, что это улучшит производительность, не так ли? Потому что у профилировщика запрос занял всего 188 мс из 14 с. Большую часть времени используется для сериализации объектов базы данных в формат GeoJSON, как показано в профилировщике CPython. По сравнению с WKT сериализация в GeoJSON слишком медленная. Нет, я не работаю с js map. Это API, возвращающий простой JSON в формате GeoJSON. Я предоставил модели и т. д.   -  person Abirafdi Raditya Putra    schedule 01.01.2018
comment
Я оптимизировал запросы как можно быстрее, используя упреждающую выборку и везде связанную выборку. Доказано количеством запросов, которые возвращают много вложенных данных всего 8.   -  person Abirafdi Raditya Putra    schedule 01.01.2018
comment
Я бы сказал, что вы должны возвращать только числовые данные без географических полигонов и кэшировать все полигоны региона/страны в предварительно рассчитанных геоджсонах (поскольку они не меняются), оставляя задачу объединения клиенту. Т.е. создать вызов API /country/123 или статический файл с геоданными для всех регионов этой страны, возможно заранее упрощенный.   -  person Udi    schedule 01.01.2018
comment
Еще одна проблема: используйте агрегацию вместо того, чтобы искать, например, на get_yes_moment_beacons   -  person Udi    schedule 01.01.2018
comment
Глупо, я только что понял, вы имели в виду, что нужно кэшировать предварительно рассчитанный GeoJSON/пропустить весь процесс сериализации. Хорошо, думаю, это сработает. Спасибо   -  person Abirafdi Raditya Putra    schedule 01.01.2018
comment
В get_yes_moment_beacons и других методах маяки уже предварительно выбраны в диспетчере по умолчанию, поэтому операция len не будет снова запрашивать БД.   -  person Abirafdi Raditya Putra    schedule 01.01.2018
comment
@Udi, ты мог бы сделать свой комментарий в качестве ответа. Я отмечу его как правильный, так как я только что реализовал его, и он прекрасно работает. Потребовалось всего чуть более 1 с (упрощенные полигоны), чтобы запросить все данные, которые мне нужны.   -  person Abirafdi Raditya Putra    schedule 01.01.2018


Ответы (3)


Поскольку ваши географические данные меняются нечасто, попробуйте кэшировать все полигоны региона/страны в предварительно рассчитанных geojsons. Т.е. создать вызов API /country/123.geojson или статический файл с геоданными для всех регионов этой страны, возможно заранее упрощенный.

Другие ваши вызовы API должны возвращать только числовые данные без географических полигонов, оставляя задачу объединения клиенту.

person Udi    schedule 01.01.2018

На случай, если кто-то еще столкнется с подобными проблемами при попытке оптимизировать PostGIS. Одно из возможных решений для ускорения процесса сериализации — сделать это непосредственно на стороне базы данных и не полагаться на geodjango. Эта функция примет набор запросов и вернет файл geojson. Это полезно, когда вам нужно выполнить большую сериализацию geojson на лету.

def queryset_geojson_serializer(queryset: QuerySet,
                            fields: List[str],
                            spatial_field: str,
                            bbox: Tuple[float, float, float, float] = None,
                            limit: int = 1000,
                            decimal_places: int = 4,
                            ) -> str:

"""
Serialize queryset into geojson. This function cannot handle related fields.

:param queryset: Queryset with Spataial Field. All fields must be part of queryset and not related.
:param fields: List of Fields to include as parameters.
:param spatial_field: Name of spatial field.
:param bbox: Tuple of bounding box. (xmin, ymin, xmax, ymax)
:param limit: Limit number of returned values.
:param decimal_places: Number of decimal places in geojson geometry.
:return: Geojson of dataset
"""

# test to make sure that all fields are in the model
# this returns a better error when the field isn't present.
model = queryset.model
fields_for_test = fields + [spatial_field]
for f in fields_for_test:
    model._meta.get_field(f)

if not isinstance(limit, int):
    raise ValueError(f'limit must be an integer, not {type(limit)}')

# try to get srid from the spatial field
if model._meta.get_field(spatial_field).srid:
    srid = model._meta.get_field(spatial_field).srid
else:
    srid = 4326

# filter queryset to ensure that spatial field not Null
queryset = queryset.filter(**{f"{spatial_field}__isnull": False})

# get unique pk list
pk = model._meta.pk.name
qs_id_list = tuple(queryset.values_list(pk, flat=True)[0:limit])

# if values
if len(qs_id_list) > 0:
    # generate initial SQL
    query_raw = f'SELECT {", ".join(fields)}, st_AsGeoJSON({spatial_field}, {decimal_places}) AS geojson FROM {model._meta.db_table}'

    # select only values in queryset
    where = f' WHERE {pk} IN' + str(qs_id_list)

    # filter results based on bounding box in the query
    if bbox:
        where += f' AND loc && ST_MakeEnvelope({bbox[0]}, {bbox[1]}, {bbox[2]}, {bbox[3]}, {srid})'

    query_raw += where

    result = queryset.raw(query_raw)

    # generate features
    features = []
    for v in result:
        properties = {field: str(getattr(v, field)) for i, field in enumerate(fields)}
        feature = {'type': 'Feature',
                   'properties': properties,
                   'geometry': json.loads(v.geojson)
                   }
        features.append(feature)
else:
    features = []

# convert python dictionary into json
geojson = json.dumps({
    "type": "FeatureCollection",
    "crs": {"type": "name", "properties": {"name": f"EPSG:{srid}"}},
    'features': features
})

return geojson

Пара заметок:

Требуется ПостГИС. Использует метод st_asGeoJSON для создания объектов geojson.

Не работает со связанными полями.

person Ben    schedule 17.09.2020

Рассматривали ли вы возможность использования формата Topojson? Это значительно уменьшает размер файла. Затем Topojson можно преобразовать обратно в geojson с помощью листовок, openlayers...

person geomajor56    schedule 03.01.2018
comment
Привет, спасибо за ответ. Это означает, что мне нужно создать свой собственный сериализатор TopoJSON, так как я думаю, что он недоступен в Django. Но если вы внимательно посмотрите на мой вопрос, проблема не в том, как сжимать данные (поскольку я сделал это с помощью ST_SimplifyVW в PostGIS), а в том, что Python очень медленно сериализует данные. Так что я думаю, что даже я использую TopoJSON, он по-прежнему сильно влияет на производительность. Таким образом, предварительно вычислить сериализованный результат и кэшировать/сохранить его - это способ выбрать формат GeoJSON или TopoJSON. - person Abirafdi Raditya Putra; 05.01.2018
comment
это ST_SimplifyVW замедляет его? Я заметил, что большинство функций PostGIS, которые я использовал, работали очень медленно. Я использовал django-geojson на сервере и локально без заметная задержка. - person geomajor56; 06.01.2018
comment
Нет, это не так. Как я сказал и в вопросе. Это процесс сериализации (из WKT в БД в тип данных Python в транспортный формат GeoJSON/TopoJSON и т. д.), который берет на себя большую часть удара по производительности (см. дамп профилировщика cPython). Может быть, это не так уж и медленно на вашей машине, потому что данных не так много. В моем проекте у него есть 10 мегабайт возвращенного JSON из API. Даже FireFox зависает при предварительном просмотре. - person Abirafdi Raditya Putra; 08.01.2018