MidПрактика15 min

Yelp: geo discovery + reviews

Проектирование сервиса гео-поиска заведений: PostGIS/Elasticsearch geo, фильтры, рейтинги, отзывы, фото, ранжирование

Yelp / 2GIS / Google Maps для POI -- сервисы, где пользователь ищет "кофейни в радиусе 1 км с рейтингом > 4". Под капотом -- geo-index, full-text поиск, агрегации рейтингов, CDN для фото и модерация отзывов. Разберём архитектуру.

Функциональные требования

  1. Search: "near me", keyword + category filter, сортировка по distance/rating/relevance.
  2. Business detail: название, часы работы, фото, меню, отзывы, рейтинг.
  3. Reviews: 1-5 звёзд, текст, фото, лайки/дизлайки.
  4. Photos: загрузка, модерация, CDN.
  5. Check-ins, bookmarks, списки ("favorites").
  6. Business claim: владелец подтверждает права на свою точку.
  7. Moderation: удаление спама, fake reviews detection.
  8. Map view + list view.

Нефункциональные требования

  1. Latency: <200 ms p95 на search, <100 ms на business detail.
  2. Throughput: 50M MAU, 10M DAU, 50 searches/user/day = 500M searches/day, ~6K QPS avg, 50K peak.
  3. Freshness: новый отзыв виден за минуту, новый бизнес -- за сутки (после модерации).
  4. Consistency: rating average должен быть eventually consistent; точность -- не критична до сотых.
  5. Availability: 99.95%.
  6. Storage cost: фото -- 80% storage, оптимизируем через CDN и тонкие варианты.

Оценки нагрузки

  • Businesses: 50M глобально (Yelp-scale).
  • Reviews: средне 20 review/business = 1B reviews, average 200 байт = 200 GB (текстом).
  • Photos: средне 10 photos/business = 500M * 500 KB = 250 TB. На CDN храним ещё в 3-4 размерах -> x2 = 500 TB.
  • Search: 50K QPS peak, geo + text + filters.
  • Writes: 100K reviews/day, ~1 QPS avg, 10 peak. Spike после sports game / natural disaster.
  • Photo uploads: 1M/day.

High-Level Architecture

  ┌─────────┐        ┌─────────────────┐            ┌───────────────────┐
  │ Client  │  search│  API Gateway    │            │  Search Service   │
  │ (mobile)│ ──────►│  (auth, cache)  │ ─────────► │  (Elasticsearch)  │
  │         │ ◄──────│                 │ ◄───────── │  geo + text + agg │
  └────┬────┘        └────┬────────────┘            └─────────┬─────────┘
       │ detail           │                                   │
       │                  ▼                                   ▼
       │          ┌─────────────────┐                ┌───────────────────┐
       │          │ Business Service│───────────────►│  Primary DB       │
       │          │ (CRUD, claim)   │◄───────────────│  (PostgreSQL +    │
       │          └──────┬──────────┘                │   PostGIS)        │
       │                 │                           └───────────────────┘
       │                 │ CDC / Kafka
       │                 ▼
       │          ┌─────────────────┐
       │          │ Indexer         │ ─► ES bulk update
       │          └─────────────────┘
       │
       │ photo
       ▼
  ┌──────────────┐     ┌─────────────────┐
  │ CDN (images) │◄────│  Photo Service  │
  │ (Cloudflare, │     │  (upload,       │
  │  Fastly)     │     │   resize queue) │
  └──────────────┘     └─────────────────┘

  ┌─────────┐      ┌─────────────────┐         ┌─────────────────┐
  │ Client  │─────►│ Review Service  │────────►│ Moderation Q    │
  │         │      │ (create, flag)  │         │ (Kafka + worker)│
  └─────────┘      └─────────────────┘         └─────────────────┘

Два read-paths: (а) search -> Elasticsearch, (б) detail -> PostgreSQL (или cached Redis). Write -> PG, затем асинхронная индексация в ES.

API дизайн

GET  /api/v1/search?q=coffee&lat=55.75&lon=37.61&radius=1000&category=cafe&min_rating=4
GET  /api/v1/businesses/{id}
POST /api/v1/businesses                  # owner claim flow
GET  /api/v1/businesses/{id}/reviews?cursor=&sort=helpful
POST /api/v1/businesses/{id}/reviews     body: {rating, text, photos}
POST /api/v1/businesses/{id}/photos      multipart
<?php

declare(strict_types=1);

final readonly class SearchRequest
{
    /**
     * @param list<string> $categories
     */
    public function __construct(
        public ?string $query,
        public float $lat,
        public float $lon,
        public int $radiusM = 1000,
        public array $categories = [],
        public ?float $minRating = null,
        public ?string $priceLevel = null,  // $..$$$$
        public string $sort = 'best_match', // best_match|distance|rating|reviews
        public int $limit = 20,
        public ?string $cursor = null,
    ) {}
}

final readonly class BusinessDTO
{
    public function __construct(
        public string $id,
        public string $name,
        public string $categorySlug,
        public float $lat,
        public float $lon,
        public float $rating,
        public int $reviewsCount,
        public string $priceLevel,
        public string $photoUrl,
        public float $distanceM,
    ) {}
}
## Схема данных
CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE businesses (
    id            UUID PRIMARY KEY,
    name          TEXT NOT NULL,
    slug          TEXT UNIQUE NOT NULL,
    category_id   UUID NOT NULL,
    price_level   SMALLINT,                -- 1..4 ($..$$$$)
    location      GEOGRAPHY(POINT, 4326) NOT NULL,  -- lat/lon
    address       TEXT,
    phone         TEXT,
    hours         JSONB,                   -- {"mon":[{"o":"09:00","c":"22:00"}], ...}
    rating_avg    NUMERIC(2,1) DEFAULT 0,  -- maintained by trigger/job
    reviews_count INT DEFAULT 0,
    owner_id      UUID,                    -- claimed by business
    status        SMALLINT NOT NULL DEFAULT 1,  -- 1=active, 2=closed, 3=pending_review
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- GIST index for spatial queries.
CREATE INDEX idx_businesses_location ON businesses USING GIST (location);
CREATE INDEX idx_businesses_cat ON businesses (category_id);
CREATE INDEX idx_businesses_rating ON businesses (rating_avg DESC);

CREATE TABLE categories (
    id     UUID PRIMARY KEY,
    parent UUID,
    slug   TEXT UNIQUE NOT NULL,
    name   TEXT NOT NULL
);

CREATE TABLE reviews (
    id           UUID PRIMARY KEY,
    business_id  UUID NOT NULL REFERENCES businesses(id),
    user_id      UUID NOT NULL,
    rating       SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
    body         TEXT NOT NULL,
    helpful_count INT NOT NULL DEFAULT 0,
    status       SMALLINT NOT NULL DEFAULT 1,  -- 1=active, 2=hidden, 3=pending_mod
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_reviews_biz_time ON reviews (business_id, created_at DESC) WHERE status = 1;

CREATE TABLE review_photos (
    id         UUID PRIMARY KEY,
    review_id  UUID NOT NULL REFERENCES reviews(id),
    url        TEXT NOT NULL,
    width      INT,
    height     INT,
    status     SMALLINT NOT NULL DEFAULT 1
);

CREATE TABLE business_photos (
    id          UUID PRIMARY KEY,
    business_id UUID NOT NULL REFERENCES businesses(id),
    url         TEXT NOT NULL,
    type        SMALLINT NOT NULL, -- 1=outside, 2=interior, 3=food, 4=menu
    uploaded_by UUID,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Materialized aggregate (обновляется workers из reviews).
CREATE MATERIALIZED VIEW business_rating_stats AS
SELECT
    business_id,
    COUNT(*) AS reviews_count,
    ROUND(AVG(rating)::numeric, 1) AS rating_avg,
    COUNT(*) FILTER (WHERE rating = 5) AS five_stars,
    COUNT(*) FILTER (WHERE rating = 4) AS four_stars
FROM reviews
WHERE status = 1
GROUP BY business_id;

Elasticsearch-индекс businesses:

{
  "mappings": {
    "properties": {
      "id": {"type": "keyword"},
      "name": {"type": "text", "analyzer": "standard"},
      "name_suggest": {"type": "completion"},
      "categories": {"type": "keyword"},
      "location": {"type": "geo_point"},
      "rating": {"type": "float"},
      "reviews_count": {"type": "integer"},
      "price_level": {"type": "integer"},
      "is_open_now": {"type": "boolean"}
    }
  }
}

Ключевые компоненты

1. Search: Elasticsearch vs PostGIS

У нас два варианта для geo-запроса:

PostGIS:

SELECT id, name, ST_Distance(location, ST_MakePoint(:lon, :lat)::geography) AS dist
FROM businesses
WHERE ST_DWithin(location, ST_MakePoint(:lon, :lat)::geography, :radius)
  AND category_id = ANY(:cats)
  AND rating_avg >= :min_rating
ORDER BY dist
LIMIT 20;

GIST index на location делает запрос быстрым в пределах локального города, но full-text по name через to_tsvector уступает ES. Подходит для простых сценариев.

Elasticsearch:

{
  "size": 20,
  "query": {
    "bool": {
      "must": [
        {"match": {"name": "coffee"}}
      ],
      "filter": [
        {"geo_distance": {"distance": "1km", "location": {"lat": 55.75, "lon": 37.61}}},
        {"terms": {"categories": ["cafe"]}},
        {"range": {"rating": {"gte": 4}}}
      ]
    }
  },
  "sort": [
    {"_geo_distance": {"location": {"lat": 55.75, "lon": 37.61}, "order": "asc", "unit": "m"}}
  ]
}

ES даёт full-text + geo + facets в одном запросе, и масштабируется горизонтально. Стоит дороже в инфраструктуре.

Гибрид: ES для search (чтение), Postgres для write-operations (reviews, business updates). CDC (Debezium / самописный consumer) реплицирует изменения из PG в ES с задержкой ~1 минута.

2. Search service (PostGIS backend)

package search

import (
    "context"
    "database/sql"
)

type BusinessRow struct {
    ID           string
    Name         string
    CategorySlug string
    Lat          float64
    Lon          float64
    Rating       float64
    Reviews      int
    DistanceM    float64
}

type Repo struct {
    DB *sql.DB
}

// Nearby searches using PostGIS ST_DWithin + ST_Distance.
func (r *Repo) Nearby(ctx context.Context, lat, lon float64, radiusM int, cats []string, minRating float64, limit int) ([]BusinessRow, error) {
    rows, err := r.DB.QueryContext(ctx, `
        SELECT
            b.id, b.name, c.slug,
            ST_Y(b.location::geometry), ST_X(b.location::geometry),
            b.rating_avg, b.reviews_count,
            ST_Distance(b.location, ST_MakePoint($1, $2)::geography) AS dist
        FROM businesses b
        JOIN categories c ON c.id = b.category_id
        WHERE ST_DWithin(b.location, ST_MakePoint($1, $2)::geography, $3)
          AND b.status = 1
          AND ($4::text[] IS NULL OR c.slug = ANY($4))
          AND ($5::numeric IS NULL OR b.rating_avg >= $5)
        ORDER BY dist
        LIMIT $6
    `, lon, lat, radiusM, cats, minRating, limit)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    out := make([]BusinessRow, 0, limit)
    for rows.Next() {
        var b BusinessRow
        if err := rows.Scan(&b.ID, &b.Name, &b.CategorySlug,
            &b.Lat, &b.Lon, &b.Rating, &b.Reviews, &b.DistanceM); err != nil {
            return nil, err
        }
        out = append(out, b)
    }
    return out, nil
}
### 3. Search endpoint -- ES backend
<?php

declare(strict_types=1);

final class SearchController
{
    public function __construct(
        private readonly \Elastic\Elasticsearch\Client $es,
        private readonly CityCache $cityCache,   // top categories per city
    ) {}

    public function search(SearchRequest $req): array
    {
        $filter = [
            ['geo_distance' => [
                'distance' => $req->radiusM . 'm',
                'location' => ['lat' => $req->lat, 'lon' => $req->lon],
            ]],
        ];
        if (!empty($req->categories)) {
            $filter[] = ['terms' => ['categories' => $req->categories]];
        }
        if ($req->minRating !== null) {
            $filter[] = ['range' => ['rating' => ['gte' => $req->minRating]]];
        }

        $must = [];
        if ($req->query !== null && $req->query !== '') {
            $must[] = ['multi_match' => [
                'query' => $req->query,
                'fields' => ['name^3', 'description', 'categories'],
                'fuzziness' => 'AUTO',
            ]];
        }

        $sort = match ($req->sort) {
            'distance' => [['_geo_distance' => ['location' => ['lat' => $req->lat, 'lon' => $req->lon], 'order' => 'asc', 'unit' => 'm']]],
            'rating'   => [['rating' => 'desc'], ['reviews_count' => 'desc']],
            'reviews'  => [['reviews_count' => 'desc']],
            default    => [
                // best_match: combination of _score + rating + distance penalty
                '_score',
                ['rating' => 'desc'],
                ['_geo_distance' => ['location' => ['lat' => $req->lat, 'lon' => $req->lon], 'order' => 'asc', 'unit' => 'm']],
            ],
        };

        $resp = $this->es->search([
            'index' => 'businesses',
            'body'  => [
                'size'  => $req->limit,
                'query' => ['bool' => ['must' => $must, 'filter' => $filter]],
                'sort'  => $sort,
            ],
        ]);

        return array_map(
            static fn(array $hit) => [
                'id'           => $hit['_source']['id'],
                'name'         => $hit['_source']['name'],
                'rating'       => $hit['_source']['rating'],
                'reviews_count'=> $hit['_source']['reviews_count'],
                'photo_url'    => $hit['_source']['photo_url'] ?? null,
                'distance_m'   => $hit['sort'][1] ?? null,
            ],
            $resp['hits']['hits'] ?? [],
        );
    }
}
### 4. Ranking (best_match)

Смесь сигналов:

score = relevance_score (BM25 of name/desc)
      * rating_boost (rating / 5 + 0.5)
      * popularity (log(reviews_count + 1))
      * proximity (1 / (1 + distance_km))
      * freshness (recent reviews get more weight)

Реализуется через function_score в ES. Для A/B тестов -- разные function_score варианты.

5. Reviews и rating aggregation

Когда пользователь пишет review:

  1. INSERT INTO reviews (..., status=3) -- pending moderation.
  2. Kafka event review.created -> moderation worker (ML + rules).
  3. Если approved -> UPDATE reviews SET status=1, публикуется review.approved.
  4. Rating aggregator consumer слушает review.approved и инкрементально обновляет businesses.rating_avg, reviews_count.

Раз в сутки full recompute через materialized view -- на случай сдвига из-за удалённых reviews.

6. Photo pipeline

  1. Upload (multipart) -> получаем original в S3.
  2. Kafka event photo.uploaded -> worker:
    • проверяет EXIF, moderation (NSFW, violence);
    • генерирует варианты (thumbnail 150x150, medium 800x600, large 1600x1200);
    • сохраняет в S3 с deterministic ключами;
  3. Путь через CDN: https://cdn.yelp.com/photo/{id}/medium.webp.
  4. CDN кэширует immutable (URL содержит version).

7. City-level cache

Топ-20 кафе в Москве меняется медленно. Кэшируем (city, category) -> [business_ids] в Redis на 10 минут. Снимает с ES до 50% QPS (повторные запросы пользователей в одном районе).

8. Business claim

Владелец хочет управлять карточкой:

  1. Заходит на свою страницу, нажимает "Claim".
  2. Yelp звонит или шлёт почтой на адрес бизнеса код.
  3. Владелец вводит код -> businesses.owner_id = user_id.
  4. Получает доступ к аналитике, ответу на отзывы.

Масштабирование и bottlenecks

Bottleneck 1: hot geo query

Москва, центр, вечер, все ищут "кафе" -- огромный QPS. Митигации:

  • city cache;
  • CDN edge caching для unpersonalized search (с Vary: lat/lon округлёнными до 0.01);
  • ES clusters на регион.

Bottleneck 2: review write spike

После крупного события (открытие ресторана, скандал) -- тысячи reviews за час. Модерация не успевает. Решение: queue + приоритизация + rate limit на пользователя (не больше 1 review/business/day).

Bottleneck 3: photo storage growth

500 TB и растёт. Решения:

  • lifecycle policy: old low-rated photos -> Glacier.
  • image optimization: WebP / AVIF, 30-50% экономии.
  • deduplication по хэшу (похоже на Dropbox).

Bottleneck 4: ES re-index

При изменении mapping-а -- полный re-index. 50M docs * 2 KB = 100 GB, несколько часов. Решения: blue-green index (alias переключает между старым и новым).

Bottleneck 5: rating eventual consistency

review -> aggregate обновляется через 1-5 секунд. Если пользователь видит старый рейтинг -- не страшно. UI может показать "{rating} (updating...)".

Trade-offs и альтернативы

Решение Плюс Минус
PostGIS ACID, сложные geo операции Медленнее full-text
Elasticsearch Geo+text+agg в одном запросе Eventually consistent, дороже
S2/H3 cells Простые распределённые индексы Меньше гибкости
CDC PG -> ES Гибкая схема Лаг 1-5 min
Dual write PG+ES Мгновенно Риск расхождения
Materialized rating Быстро читать Задержка обновления
Trigger-based rating Мгновенно Нагрузка на PG
CDN для фото Дёшево egress Invalidation сложнее
Direct S3 для фото Просто Без edge caching дорого

Альтернатива ES -- Algolia (SaaS). Меньше operational burden, но дороже на scale и vendor lock-in.

Альтернатива полному ES -- OpenSearch (AWS fork). Функционально близко, некоторые фичи позади.

Для очень маленького проекта можно обойтись только PostGIS + pg_trgm для fuzzy name match. Но при 50M businesses масштабирование PG становится сложным.

Выводы

Geo discovery = правильный индекс (geo_point в ES или GIST в PostGIS) + хорошее ранжирование (BM25 + rating + distance + popularity). PG -- источник правды, ES -- быстрый read-path, CDC между ними. Reviews и rating -- пример eventual consistency: пишем в PG, асинхронно пересчитываем aggregate, инвалидируем кэш. Photos -- самая тяжёлая по storage часть, решается через CDN + thumbnail pipeline + жёсткие лимиты на бизнес. City-level cache и edge caching сильнее всего снимают нагрузку с search -- повторные запросы в одном районе очень частые.