Yelp / 2GIS / Google Maps для POI -- сервисы, где пользователь ищет "кофейни в радиусе 1 км с рейтингом > 4". Под капотом -- geo-index, full-text поиск, агрегации рейтингов, CDN для фото и модерация отзывов. Разберём архитектуру.
Функциональные требования
- Search: "near me", keyword + category filter, сортировка по distance/rating/relevance.
- Business detail: название, часы работы, фото, меню, отзывы, рейтинг.
- Reviews: 1-5 звёзд, текст, фото, лайки/дизлайки.
- Photos: загрузка, модерация, CDN.
- Check-ins, bookmarks, списки ("favorites").
- Business claim: владелец подтверждает права на свою точку.
- Moderation: удаление спама, fake reviews detection.
- Map view + list view.
Нефункциональные требования
- Latency: <200 ms p95 на search, <100 ms на business detail.
- Throughput: 50M MAU, 10M DAU, 50 searches/user/day = 500M searches/day, ~6K QPS avg, 50K peak.
- Freshness: новый отзыв виден за минуту, новый бизнес -- за сутки (после модерации).
- Consistency: rating average должен быть eventually consistent; точность -- не критична до сотых.
- Availability: 99.95%.
- 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
}
<?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'] ?? [],
);
}
}
Смесь сигналов:
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:
INSERT INTO reviews (..., status=3)-- pending moderation.- Kafka event
review.created-> moderation worker (ML + rules). - Если approved ->
UPDATE reviews SET status=1, публикуетсяreview.approved. - Rating aggregator consumer слушает
review.approvedи инкрементально обновляетbusinesses.rating_avg, reviews_count.
Раз в сутки full recompute через materialized view -- на случай сдвига из-за удалённых reviews.
6. Photo pipeline
- Upload (multipart) -> получаем original в S3.
- Kafka event
photo.uploaded-> worker:- проверяет EXIF, moderation (NSFW, violence);
- генерирует варианты (thumbnail 150x150, medium 800x600, large 1600x1200);
- сохраняет в S3 с deterministic ключами;
- Путь через CDN:
https://cdn.yelp.com/photo/{id}/medium.webp. - CDN кэширует immutable (URL содержит version).
7. City-level cache
Топ-20 кафе в Москве меняется медленно. Кэшируем (city, category) -> [business_ids] в Redis на 10 минут. Снимает с ES до 50% QPS (повторные запросы пользователей в одном районе).
8. Business claim
Владелец хочет управлять карточкой:
- Заходит на свою страницу, нажимает "Claim".
- Yelp звонит или шлёт почтой на адрес бизнеса код.
- Владелец вводит код ->
businesses.owner_id = user_id. - Получает доступ к аналитике, ответу на отзывы.
Масштабирование и 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 -- повторные запросы в одном районе очень частые.