Проектирование системы отслеживания посылок
Введение
Каждый день по миру перемещаются десятки миллионов посылок. UPS доставляет около 25 миллионов отправлений ежедневно, FedEx — порядка 15 миллионов. В период праздников эти цифры вырастают в 3–5 раз. За каждой посылкой — цепочка событий: сканирование на складе отправителя, погрузка в грузовик, перелёт между хабами, сортировка, «последняя миля» до двери получателя.
Система отслеживания посылок на первый взгляд похожа на Uber (Глава 41) или Uber Eats (Глава 42): есть отправитель, получатель, курьер и некий «заказ», который нужно доставить. Но различия принципиальны:
| Характеристика | Uber / Uber Eats | FedEx / UPS |
|---|---|---|
| Время доставки | Минуты–часы | Часы–дни |
| Количество «точек» на пути | 1–2 | 5–15 (хабы, сортировки) |
| GPS-трекинг в реальном времени | Обязателен | Опционален, статусы важнее |
| Источник событий | GPS-координаты | Сканирования штрихкодов |
| Масштаб хранения истории | Недели | Годы (таможня, споры) |
| Количество участников | 3 (пассажир, водитель, ресторан) | 4+ (отправитель, получатель, курьер, оператор сортировки, таможня) |
В этой главе мы спроектируем систему, которая справляется с миллиардами событий в год, хранит полную историю каждой посылки, предсказывает время доставки и уведомляет всех участников цепочки в реальном времени.
Предварительные знания: рекомендуется прочитать Главу 7 (очереди сообщений), Главу 12 (согласованность данных) и Главу 13 (шардирование).
Функциональные и нефункциональные требования
Четыре стороны системы
Система обслуживает четыре типа пользователей одновременно:
1. Отправитель (Shipper)
- Создать отправление, получить трекинг-номер
- Распечатать этикетку с штрихкодом
- Отслеживать статус посылки
- Получить подтверждение доставки (proof of delivery)
2. Получатель (Recipient)
- Отслеживать посылку по трекинг-номеру
- Получать push/SMS/email уведомления
- Изменить адрес доставки или выбрать временной слот
- Подписать получение электронной подписью
3. Курьер / водитель (Driver)
- Получить маршрут на день
- Сканировать посылки при приёмке и сдаче
- Зафиксировать попытку доставки (attempt)
- Сфотографировать посылку у двери
4. Оператор сортировочного центра (Hub Operator)
- Сканировать посылки на конвейере
- Видеть загруженность линий и направлений
- Получать алерты при аномалиях (потеря посылки, неверный хаб)
- Управлять исключениями (exceptions) — повреждения, задержки таможни
Нефункциональные требования
- Доступность трекинга: 99.99% (посылка должна быть отслеживаемой всегда)
- Запись событий сканирования: допустима задержка до 30 секунд (eventual consistency)
- Уведомления: доставляются в течение 2 минут после события
- ETA: точность ±4 часа для standard delivery, ±1 час для express
- Хранение: история событий хранится 7 лет (таможенные требования)
- Пиковая нагрузка: Чёрная Пятница — 5x от среднего
Оценка масштаба
Проведём расчёт для системы уровня UPS:
Посылок в день: 25 000 000
Событий сканирования: ~10 на посылку = 250 000 000 событий/день
Событий в секунду (avg): 250M / 86400 ≈ 2 900 событий/с
Пиковая нагрузка: 2 900 × 5 = 14 500 событий/с
Размер одного события: ~500 байт (JSON с метаданными)
Запись в день: 250M × 500 байт = 125 ГБ/день
Хранение за 7 лет: 125 ГБ × 365 × 7 ≈ 320 ТБ
Запросы трекинга (read): ~100M запросов/день = 1 200 rps (avg)
Пиковый read: ~6 000 rps
Уведомлений в день: ~50M (2 уведомления на посылку)
Эти числа подсказывают архитектурные решения:
- Event log должен поддерживать высокий write throughput — подходит Cassandra или Kafka
- Read-трафик в 40 раз меньше write — можно кэшировать текущий статус в Redis
- 320 ТБ за 7 лет — нужна tiered storage стратегия (горячие/тёплые/холодные данные)
Подробнее о горизонтальном масштабировании хранилищ — в Главе 5: Масштабирование баз данных и Главе 13: Шардирование и партиционирование.
Высокоуровневая архитектура
┌─────────────────────────────────┐
│ API Gateway │
│ (Auth, Rate Limit, Routing) │
└──────────────┬──────────────────┘
│
┌──────────────┬───────────────┼───────────────┬───────────────┐
│ │ │ │ │
┌──────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│ Shipment │ │ Tracking │ │ Scanning │ │ Routing │ │ ETA │
│ Service │ │ Service │ │ Service │ │ Service │ │ Service │
└──────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └────┬───────┘
│ │ │ │ │
└──────────────┴───────────────┴───────────────┴──────────────┘
│
┌──────────▼───────────┐
│ Kafka Event Bus │
│ (shipment-events) │
└──────────┬───────────┘
│
┌──────────────┬───────────────┼───────────────┬──────────────┐
│ │ │ │ │
┌──────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│Notification │ │ Billing │ │ Analytics │ │ Search │ │ Audit / │
│ Service │ │ Service │ │ Service │ │ Service │ │ Compliance│
└─────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
Хранилища:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐
│ PostgreSQL │ │ Cassandra │ │ Redis │ │Elasticsearch│
│ (shipments, │ │ (event log, │ │ (current status,│ │(search by │
│ addresses, │ │ scan history) │ │ ETA cache, │ │ address, │
│ billing) │ │ │ │ rate limits) │ │ recipient) │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────┘
IoT Layer:
┌─────────────────────────────────────────────────────────────┐
│ Scanners → Edge Gateway → Kafka (per hub) → Central Kafka │
└─────────────────────────────────────────────────────────────┘
Подробнее о роли API Gateway — в Главе 10: API Design. Паттерны Event-Driven архитектуры разобраны в Главе 7: Очереди сообщений.
Жизненный цикл посылки (Shipment Lifecycle)
Конечный автомат
Посылка в системе — это объект с конечным набором состояний. Переходы между ними инициируются событиями сканирования или действиями операторов.
┌─────────────┐
│ CREATED │ ← отправитель создал отправление
└──────┬──────┘
│ picked_up (курьер забрал)
┌──────▼──────┐
│ PICKED_UP │
└──────┬──────┘
│ arrived_at_facility
┌──────▼──────┐
│ AT_ORIGIN │ ← сортировочный центр отправителя
│ FACILITY │
└──────┬──────┘
│ departed_facility
┌──────▼──────┐
┌────────►│ IN_TRANSIT │◄────────┐
│ └──────┬──────┘ │
│ │ arrived │ (мультихоп: между хабами)
│ ┌──────▼──────┐ │
│ │AT_SORTING │─────────┘
│ │ HUB │ departed_hub
│ └──────┬──────┘
│ │ out_for_delivery
│ ┌──────▼──────┐
│ │OUT_FOR_DELIV│
│ └──────┬──────┘
│ ┌──────┴────────────────┐
│ │ │
│ ┌──────▼──────┐ ┌───────▼──────┐
│ │ DELIVERED │ │ EXCEPTION │
│ └─────────────┘ └───────┬──────┘
│ │
│ ┌──────────────────────────────▼──────┐
│ │ FAILED_ATTEMPT → retry → reschedule │
└──┤ LOST / DAMAGED / CUSTOMS_HOLD │
└────────────────────────────────────-┘
Каждый переход — это событие сканирования с метаданными: время, местоположение (хаб или GPS), оператор, устройство.
from enum import Enum from dataclasses import dataclass from datetime import datetime from typing import Optional class ShipmentStatus(Enum): CREATED = "created" PICKED_UP = "picked_up" AT_ORIGIN_FACILITY = "at_origin_facility" IN_TRANSIT = "in_transit" AT_SORTING_HUB = "at_sorting_hub" OUT_FOR_DELIVERY = "out_for_delivery" DELIVERED = "delivered" FAILED_ATTEMPT = "failed_attempt" EXCEPTION = "exception" CUSTOMS_HOLD = "customs_hold" # Допустимые переходы: from_status -> [to_status, ...] VALID_TRANSITIONS = { ShipmentStatus.CREATED: [ShipmentStatus.PICKED_UP], ShipmentStatus.PICKED_UP: [ShipmentStatus.AT_ORIGIN_FACILITY], ShipmentStatus.AT_ORIGIN_FACILITY:[ShipmentStatus.IN_TRANSIT, ShipmentStatus.EXCEPTION], ShipmentStatus.IN_TRANSIT: [ShipmentStatus.AT_SORTING_HUB, ShipmentStatus.EXCEPTION, ShipmentStatus.CUSTOMS_HOLD], ShipmentStatus.AT_SORTING_HUB: [ShipmentStatus.IN_TRANSIT, ShipmentStatus.OUT_FOR_DELIVERY, ShipmentStatus.EXCEPTION], ShipmentStatus.OUT_FOR_DELIVERY: [ShipmentStatus.DELIVERED, ShipmentStatus.FAILED_ATTEMPT, ShipmentStatus.EXCEPTION], ShipmentStatus.FAILED_ATTEMPT: [ShipmentStatus.OUT_FOR_DELIVERY, ShipmentStatus.AT_SORTING_HUB], ShipmentStatus.CUSTOMS_HOLD: [ShipmentStatus.IN_TRANSIT, ShipmentStatus.EXCEPTION], } @dataclass class ScanEvent: tracking_number: str event_type: str # "arrived", "departed", "delivered", etc. new_status: ShipmentStatus location_code: str # "HUB-JFK-01" или GPS координаты operator_id: str device_id: str timestamp: datetime metadata: dict # подпись, фото, причина исключения def validate_transition(current: ShipmentStatus, new: ShipmentStatus) -> bool: allowed = VALID_TRANSITIONS.get(current, []) return new in allowed
Конечные автоматы для заказов еды мы рассматривали в Главе 42: Проектирование Uber Eats / DoorDash — там Order State Machine имеет аналогичную структуру, но более короткий жизненный цикл.
Event Sourcing и CQRS
Почему Event Sourcing идеален для трекинга
Традиционная CRUD-система хранит текущее состояние: посылка либо «в пути», либо «доставлена». Но для логистики нам нужно знать всю историю: когда была забрана, сколько времени провела на каждом хабе, где произошла задержка.
Event Sourcing (событийный источник) — паттерн, при котором состояние системы восстанавливается из последовательности иммутабельных событий. Каждое сканирование — это факт, который нельзя изменить, только дополнить.
Традиционный подход (CRUD):
┌──────────────────────────┐
│ shipment_id: 123 │
│ status: AT_SORTING_HUB │ ← только текущее состояние
│ updated_at: 2024-12-20 │
└──────────────────────────┘
Event Sourcing:
┌─────┬──────────────────────────┬─────────────────────────┐
│ seq │ event_type │ timestamp │
├─────┼──────────────────────────┼─────────────────────────┤
│ 1 │ shipment_created │ 2024-12-18 09:00:00 UTC │
│ 2 │ picked_up │ 2024-12-18 14:32:00 UTC │
│ 3 │ arrived_origin_facility │ 2024-12-18 18:15:00 UTC │
│ 4 │ departed_origin_facility │ 2024-12-18 23:40:00 UTC │
│ 5 │ arrived_sorting_hub_JFK │ 2024-12-19 06:20:00 UTC │
│ 6 │ departed_hub_JFK │ 2024-12-19 09:05:00 UTC │
│ 7 │ arrived_sorting_hub_SVO │ 2024-12-20 04:10:00 UTC │ ← текущее
└─────┴──────────────────────────┴─────────────────────────┘
CQRS: разделение записи и чтения
CQRS (Command Query Responsibility Segregation) — разделение модели записи и модели чтения. Для трекинга это означает:
- Write side: события сканирования пишутся в Cassandra (append-only) и публикуются в Kafka
- Read side: Kafka Consumer обновляет Redis (текущий статус) и Elasticsearch (полная история для UI)
WRITE PATH READ PATH
────────────────────── ──────────────────────────
Scanner → Scanning Service GET /track/{id}
↓ ↓
Validate & Enrich Redis (current status)
↓ если нет в кэше:
Cassandra (event_log) ↓
↓ Cassandra (full history)
Kafka: shipment-events ──────────────► Consumer → Redis (update)
Consumer → Elasticsearch (index)
Consumer → Notification Service
# Write side: запись события def record_scan_event(event: ScanEvent, cassandra_session, kafka_producer): # 1. Валидация перехода состояния current_status = get_current_status(event.tracking_number) if not validate_transition(current_status, event.new_status): raise InvalidTransitionError( f"Cannot transition from {current_status} to {event.new_status}" ) # 2. Запись в Cassandra (append-only, иммутабельно) cassandra_session.execute( """INSERT INTO shipment_events (tracking_number, event_id, event_type, status, location_code, operator_id, device_id, occurred_at, metadata) VALUES (%s, uuid(), %s, %s, %s, %s, %s, toTimestamp(now()), %s)""", (event.tracking_number, event.event_type, event.new_status.value, event.location_code, event.operator_id, event.device_id, str(event.metadata)) ) # 3. Публикация в Kafka для downstream consumers kafka_producer.send( topic='shipment-events', key=event.tracking_number.encode(), # партиция по трекинг-номеру value={ 'tracking_number': event.tracking_number, 'event_type': event.event_type, 'new_status': event.new_status.value, 'location_code': event.location_code, 'occurred_at': event.timestamp.isoformat(), 'metadata': event.metadata } )
Подробнее о Kafka как event log и паттерне Event Sourcing — в Главе 7: Очереди сообщений и Event-Driven архитектура. Согласованность между write и read моделями — это тема Главы 12: Согласованность в распределённых системах.
Сравнение: Event Sourcing vs традиционный CRUD
| Критерий | Event Sourcing | Традиционный CRUD |
|---|---|---|
| Полная история | Бесплатно | Нужен отдельный audit log |
| Восстановление состояния | Replay событий | Из текущей записи |
| Исправление ошибок | Компенсирующее событие | UPDATE запрос |
| Производительность чтения | Нужен read model | Прямой SELECT |
| Сложность | Выше | Ниже |
| Соответствие требованиям (таможня, суды) | Идеально | Сложнее доказать |
Сканирование и IoT-интеграция
Устройства сканирования
На сортировочных центрах используется несколько технологий:
- Штрихкод (1D/2D, QR) — основной способ: надёжно, дёшево, работает без электричества на этикетке
- RFID (Radio Frequency Identification) — бесконтактное считывание на скорости конвейера, но дороже
- Мобильные сканеры курьеров — Android/iOS устройства с камерой и встроенным сканером
Типичный сортировочный центр FedEx обрабатывает 200 000 посылок в смену через конвейерные линии с автоматическими сканерами. Это дают 50–100 сканирований в секунду на один хаб.
Edge Computing на хабах
Прямая отправка каждого сканирования в центральный облачный дата-центр создаёт проблемы:
- Интернет-канал хаба может быть перегружен
- Задержка сети добавляет latency к конвейеру
- При обрыве связи конвейер не должен останавливаться
Решение — Edge Gateway на каждом хабе:
Сортировочный центр (Hub):
┌──────────────────────────────────────────────────┐
│ Конвейерная линия A: Scanner1 → Scanner2 → ... │
│ Конвейерная линия B: Scanner3 → Scanner4 → ... │
│ ↓ │
│ ┌──────────────────┐ │
│ │ Edge Gateway │ ← Local Kafka (буфер) │
│ │ (local broker) │ ← SQLite (offline cache) │
│ └──────────────────┘ │
│ ↓ (sync каждые 30 сек или при восст.) │
└─────────┼────────────────────────────────────────┘
↓
Central Kafka Cluster
Offline-first подход: если связь с центром потеряна, Edge Gateway продолжает принимать события локально. При восстановлении — синхронизирует буфер с сортировкой по timestamp. Cassandra идеально подходит для этого: при конфликтах побеждает запись с более поздним occurred_at.
# Курьерское приложение: работа офлайн class OfflineFirstScanQueue: def __init__(self, local_db_path: str, sync_service): self.local_db = sqlite3.connect(local_db_path) self.sync_service = sync_service self._ensure_table() def record_scan(self, event: ScanEvent): # Всегда пишем локально self.local_db.execute( "INSERT INTO pending_scans VALUES (?, ?, ?, ?, ?)", (event.tracking_number, event.event_type, event.new_status.value, event.location_code, event.timestamp.isoformat()) ) self.local_db.commit() # Пытаемся отправить немедленно, если есть сеть self._try_sync() def _try_sync(self): pending = self.local_db.execute( "SELECT * FROM pending_scans ORDER BY timestamp LIMIT 100" ).fetchall() if not pending: return try: self.sync_service.bulk_send(pending) self.local_db.execute("DELETE FROM pending_scans WHERE synced = 0") self.local_db.commit() except NetworkError: pass # Попробуем при следующем сканировании или по таймеру
Паттерны работы с очередями и гарантии доставки сообщений — в Главе 7: Очереди сообщений и Event-Driven архитектура.
Маршрутизация посылок (Routing)
Line-haul vs Last-mile
Логистика делится на два принципиально разных типа перемещений:
Line-haul (магистральные маршруты): перемещение между крупными хабами — грузовики, самолёты, поезда. Расписание фиксировано, маршруты предзаданы. Задача системы — определить оптимальную цепочку хабов для посылки.
Last-mile (последняя миля): доставка от местного хаба до двери получателя. Один курьер везёт 50–200 посылок. Задача — оптимальный порядок объезда адресов. Это Vehicle Routing Problem (VRP).
Line-haul граф (упрощённо):
[Москва-Hub] ──(ТУ-214, 3h)──► [Франкфурт-Hub] ──(747, 11h)──► [Нью-Йорк-JFK]
│ │
(фура, 8h) (фура, 4h)
▼ ▼
[Санкт-Петербург-Hub] [Лондон-Heathrow]
Алгоритм выбора маршрута для посылки
При создании отправления Routing Service находит оптимальный путь через хабы:
import heapq from dataclasses import dataclass, field from typing import Dict, List, Tuple from datetime import datetime, timedelta @dataclass(order=True) class RouteNode: estimated_arrival: datetime hub_code: str = field(compare=False) path: List[str] = field(default_factory=list, compare=False) def find_optimal_route( origin_hub: str, dest_hub: str, departure_time: datetime, service_type: str, # "express" | "standard" hub_graph: Dict[str, List[Tuple[str, timedelta, str]]] # hub -> [(next_hub, transit_time, transport)] ) -> List[str]: """ Модифицированный алгоритм Дейкстры для маршрутизации посылок. hub_graph[hub] = список (следующий_хаб, время_в_пути, тип_транспорта) """ # (время_прибытия, хаб, путь) priority_queue = [RouteNode(departure_time, origin_hub, [origin_hub])] visited = set() while priority_queue: current = heapq.heappop(priority_queue) if current.hub_code in visited: continue visited.add(current.hub_code) if current.hub_code == dest_hub: return current.path for next_hub, transit_time, transport in hub_graph.get(current.hub_code, []): if next_hub not in visited: # Учитываем расписание: следующий рейс/рейд после arrival next_departure = get_next_departure( current.hub_code, next_hub, current.estimated_arrival ) arrival = next_departure + transit_time heapq.heappush(priority_queue, RouteNode( arrival, next_hub, current.path + [next_hub] )) return [] # Маршрут не найден
Алгоритмы кратчайшего пути (Дейкстра, A*) и Contraction Hierarchies подробно разобраны в Главе 43: Проектирование Lyft / Bolt в контексте картографии.
Vehicle Routing Problem (VRP) для last-mile
VRP — NP-сложная задача, поэтому на практике используют эвристики:
- Nearest Neighbor — курьер всегда едет к ближайшей необслуженной точке
- Savings Algorithm (Clarke-Wright) — объединение маршрутов от склада
- Google OR-Tools — библиотека с эффективными солверами для VRP
Для 200 адресов Nearest Neighbor даёт маршрут в пределах 15–20% от оптимального за миллисекунды — приемлемый trade-off для production.
Расчёт ETA
ETA (Estimated Time of Arrival) для посылки — многоуровневая задача. В отличие от Uber Eats (Глава 42), где ETA считается в минутах, здесь горизонт прогноза — часы и дни.
Компоненты ETA
ETA_total = T_line_haul + T_hub_processing + T_last_mile
T_line_haul: из расписания транспорта (детерминировано)
T_hub_processing: ML-прогноз (зависит от загруженности хаба)
T_last_mile: ML-прогноз (зависит от количества остановок, пробок)
ML-признаки для предсказания ETA
def build_eta_features( shipment, current_hub: str, destination: str, current_time: datetime ) -> dict: return { # Маршрут 'remaining_hubs': len(shipment.remaining_route), 'total_distance_km': shipment.total_distance_km, 'service_type': shipment.service_type, # express=0, standard=1 # Временные признаки 'day_of_week': current_time.weekday(), 'is_holiday_season': is_holiday_season(current_time), # Ноябрь-Декабрь 'hour_of_day': current_time.hour, # Операционные признаки 'current_hub_utilization': get_hub_utilization(current_hub), # 0.0 - 1.0 'weather_delay_index': get_weather_delay(current_hub, destination), 'customs_required': shipment.is_international, # Исторические признаки 'avg_hub_delay_last_7d': get_avg_hub_delay(current_hub, days=7), 'on_time_rate_this_route': get_route_on_time_rate( current_hub, destination, shipment.service_type ), }
Модель обновляется при каждом новом событии сканирования: если посылка задержалась на хабе дольше ожидаемого — ETA пересчитывается и отправляется уведомление получателю.
Подходы к ML-предсказанию ETA с учётом исторических данных мы рассматривали в Главе 42: Uber Eats (двухкомпонентная модель kitchen time + courier time).
Уведомления в реальном времени
Типы уведомлений и их приоритет
| Событие | Канал | Приоритет | SLA |
|---|---|---|---|
| Посылка забрана (picked up) | Low | 10 мин | |
| Прибыла в ваш город | Push + Email | Medium | 5 мин |
| Сегодня доставят | Push + SMS | High | 2 мин |
| Курьер выехал | Push | High | 1 мин |
| Доставлено / попытка | Push + SMS + Email | Critical | 30 сек |
| Исключение (потеря, повреждение) | Push + SMS + Email | Critical | 30 сек |
Архитектура Notification Service
Notification Service подписан на Kafka топик shipment-events и определяет, нужно ли уведомление:
from enum import Enum import httpx from typing import Optional class NotificationChannel(Enum): PUSH = "push" SMS = "sms" EMAIL = "email" WEBHOOK = "webhook" # для B2B интеграций # Правила: какие события вызывают какие уведомления NOTIFICATION_RULES = { "picked_up": [NotificationChannel.EMAIL], "at_sorting_hub": [], # промежуточные хабы — не беспокоим "out_for_delivery": [NotificationChannel.PUSH, NotificationChannel.SMS], "delivered": [NotificationChannel.PUSH, NotificationChannel.SMS, NotificationChannel.EMAIL, NotificationChannel.WEBHOOK], "failed_attempt": [NotificationChannel.PUSH, NotificationChannel.SMS, NotificationChannel.EMAIL], "exception": [NotificationChannel.PUSH, NotificationChannel.SMS, NotificationChannel.EMAIL, NotificationChannel.WEBHOOK], } async def process_shipment_event(event: dict, notification_service): event_type = event['event_type'] channels = NOTIFICATION_RULES.get(event_type, []) if not channels: return shipment = await get_shipment(event['tracking_number']) recipient_prefs = await get_notification_prefs(shipment.recipient_id) for channel in channels: if channel in recipient_prefs.enabled_channels: await notification_service.send( channel=channel, recipient=shipment.recipient, template=f"shipment_{event_type}", context={ 'tracking_number': event['tracking_number'], 'location': event['location_code'], 'eta': shipment.current_eta, } )
B2B Webhooks
Крупные бизнес-клиенты (Amazon, интернет-магазины) интегрируются не через Push, а через webhooks — HTTP-колбэки, которые система вызывает при каждом событии.
Проблема fan-out:
1 событие → 1 000 B2B клиентов → 1 000 HTTP запросов одновременно
Решение: Webhook Worker Pool с очередью
Kafka event → Webhook Queue (Redis или SQS)
↓
Worker Pool (50 воркеров)
↓
HTTP POST → клиент
Retry с exponential backoff при ошибке
Dead Letter Queue после 3 неудачных попыток
import asyncio import aiohttp from datetime import datetime, timedelta async def deliver_webhook( webhook_url: str, payload: dict, secret: str, max_retries: int = 3 ) -> bool: signature = compute_hmac_signature(payload, secret) headers = { 'Content-Type': 'application/json', 'X-Webhook-Signature': signature, 'X-Timestamp': str(int(datetime.utcnow().timestamp())), } for attempt in range(max_retries): try: async with aiohttp.ClientSession() as session: async with session.post( webhook_url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=10) ) as response: if response.status in (200, 201, 202): return True if response.status in (400, 401, 403, 404): return False # не повторяем — клиентская ошибка except (aiohttp.ClientError, asyncio.TimeoutError): pass # Exponential backoff: 1s, 2s, 4s await asyncio.sleep(2 ** attempt) # После всех попыток — в Dead Letter Queue await send_to_dlq(webhook_url, payload) return False
Паттерны доставки уведомлений (push, webhooks, fan-out) рассматривались в Главе 27: Проектирование Telegram и Главе 26: Проектирование WhatsApp.
Публичный API для бизнес-клиентов
REST API: основные эндпоинты
# Создание отправления
POST /v2/shipments
Authorization: Bearer <api_key>
{
"sender": { "name": "...", "address": {...} },
"recipient": { "name": "...", "address": {...} },
"package": { "weight_kg": 1.5, "dimensions_cm": [30, 20, 10] },
"service_type": "express",
"declared_value_usd": 150.00
}
→ 201 Created
{
"tracking_number": "1Z999AA10123456784",
"label_url": "https://api.fedex.com/labels/abc123.pdf",
"estimated_delivery": "2024-12-22",
"rate_usd": 24.99
}
# Трекинг
GET /v2/shipments/{tracking_number}
→ 200 OK
{
"tracking_number": "1Z999AA10123456784",
"current_status": "at_sorting_hub",
"current_location": "FedEx Hub Frankfurt",
"estimated_delivery": "2024-12-22",
"events": [
{ "timestamp": "...", "status": "picked_up", "location": "Moscow" },
{ "timestamp": "...", "status": "at_origin_facility", "location": "SVO Hub" }
]
}
# Batch трекинг (до 100 номеров)
POST /v2/shipments/track/batch
{ "tracking_numbers": ["1Z...", "1Z...", ...] }
# Подписка на вебхуки
POST /v2/webhooks
{
"url": "https://myshop.com/webhooks/fedex",
"events": ["delivered", "failed_attempt", "exception"],
"secret": "my_webhook_secret"
}
Rate Limiting и API Keys
Разные клиенты получают разные лимиты:
| Тип клиента | Лимит создания посылок | Лимит трекинга | Webhooks |
|---|---|---|---|
| Free (тестовый) | 100/день | 1 000/день | Нет |
| Small Business | 10 000/день | 100 000/день | 10 endpoint'ов |
| Enterprise | Неограничено | Неограничено | Неограничено |
Rate limiting реализуется через Redis с алгоритмом Token Bucket или Sliding Window:
import redis import time def check_rate_limit( redis_client: redis.Redis, api_key: str, endpoint: str, limit: int, window_seconds: int ) -> bool: """Sliding Window rate limiter.""" now = time.time() window_start = now - window_seconds key = f"rate_limit:{api_key}:{endpoint}" pipe = redis_client.pipeline() # Удаляем старые запросы за пределами окна pipe.zremrangebyscore(key, 0, window_start) # Считаем текущие запросы pipe.zcard(key) # Добавляем текущий запрос pipe.zadd(key, {str(now): now}) # Устанавливаем TTL pipe.expire(key, window_seconds) results = pipe.execute() current_count = results[1] return current_count < limit
API Design: версионирование, rate limiting, идемпотентность — Глава 10: API Design. Безопасность API (HMAC подписи, OAuth) — Глава 17: Безопасность.
Базы данных: выбор и обоснование
Схема данных
PostgreSQL — основные бизнес-сущности:
-- Отправления CREATE TABLE shipments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tracking_number VARCHAR(30) UNIQUE NOT NULL, sender_id UUID NOT NULL REFERENCES customers(id), recipient_name VARCHAR(255) NOT NULL, recipient_email VARCHAR(255), recipient_phone VARCHAR(50), origin_address JSONB NOT NULL, dest_address JSONB NOT NULL, service_type VARCHAR(20) NOT NULL, -- 'express', 'standard', 'economy' weight_kg DECIMAL(8,3), dimensions_cm JSONB, -- {l, w, h} declared_value DECIMAL(12,2), current_status VARCHAR(50) NOT NULL DEFAULT 'created', current_location_code VARCHAR(50), eta TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), delivered_at TIMESTAMP WITH TIME ZONE, INDEX idx_tracking ON shipments(tracking_number), INDEX idx_recipient_email ON shipments(recipient_email), INDEX idx_status ON shipments(current_status), INDEX idx_created_at ON shipments(created_at) ); -- Хабы и сортировочные центры CREATE TABLE hubs ( code VARCHAR(20) PRIMARY KEY, -- "HUB-SVO-01" name VARCHAR(255) NOT NULL, city VARCHAR(100), country VARCHAR(2), -- ISO код lat DECIMAL(9,6), lng DECIMAL(9,6), hub_type VARCHAR(20), -- 'origin', 'sorting', 'delivery' capacity INTEGER -- посылок в сутки );
Cassandra — event log (высокий write throughput):
-- CQL (Cassandra Query Language) CREATE TABLE shipment_events ( tracking_number TEXT, event_id TIMEUUID, -- автоматически упорядочен по времени event_type TEXT, status TEXT, location_code TEXT, operator_id TEXT, device_id TEXT, occurred_at TIMESTAMP, metadata TEXT, -- JSON PRIMARY KEY (tracking_number, event_id) ) WITH CLUSTERING ORDER BY (event_id ASC) AND default_time_to_live = 220752000; -- 7 лет в секундах
Partition key — tracking_number: все события одной посылки на одной ноде Cassandra, чтение истории — один запрос без JOIN.
Дизайн partition key в Cassandra подробно рассмотрен в Главе 41: Проектирование Uber (там хранили GPS-события по водителям) и Главе 13: Шардирование и партиционирование.
Redis — кэш текущего статуса:
# Структура кэша: hash с полями статуса { "shipment:1Z999AA10123456784": { "status": "at_sorting_hub", "location": "FedEx Hub Frankfurt", "eta": "2024-12-22T18:00:00Z", "last_updated": "2024-12-20T04:10:00Z" } } # TTL: 30 дней (посылка точно будет доставлена) redis_client.hset(f"shipment:{tracking_number}", mapping=status_data) redis_client.expire(f"shipment:{tracking_number}", 30 * 24 * 3600)
Итоговый выбор хранилищ
| Данные | Хранилище | Обоснование |
|---|---|---|
| Основные данные отправлений | PostgreSQL | ACID, сложные запросы, JOIN |
| Event log (история сканирований) | Cassandra | Высокий write throughput, TTL, append-only |
| Текущий статус (горячий кэш) | Redis | Sub-millisecond read, TTL |
| Полнотекстовый поиск | Elasticsearch | Поиск по адресу, получателю, диапазонам дат |
| Фото proof-of-delivery | S3 / Object Storage | Бинарные данные, дешёвое хранение |
Сравнение типов баз данных и принципы выбора — Глава 4: Хранение данных: типы баз данных.
Масштабирование и надёжность
Holiday Spike: Чёрная Пятница и Рождество
В ноябре–декабре объёмы вырастают в 3–5 раз. Это не внезапный spike — он предсказуем. Стратегия:
1. Predictive Auto-Scaling (заблаговременное масштабирование):
За 2 недели до Чёрной Пятницы:
- Увеличить Kafka партиции и реплики
- Предварительно прогреть Redis кластер
- Масштабировать Cassandra ноды
- Зарезервировать compute capacity в облаке
2. Graceful Degradation по уровням:
Уровень 1 (норма): Полный функционал — real-time ETA, уведомления, search
Уровень 2 (80% load): Отключить live ETA обновления каждые 5 мин → каждые 30 мин
Уровень 3 (95% load): Задержать некритичные уведомления (picked_up, at_hub)
Уровень 4 (100%+): Только запись событий и критичные уведомления (delivered)
3. Шардирование Kafka по регионам:
# Партиционирование событий по хэшу трекинг-номера # Это гарантирует: все события одной посылки → одна партиция → один consumer def get_kafka_partition(tracking_number: str, num_partitions: int) -> int: # Первые 3 символа трекинг-номера — код страны/региона # Используем consistent hashing для предотвращения hot spots return hash(tracking_number) % num_partitions
Стратегии балансировки нагрузки при spike — Глава 8: Балансировка нагрузки. Партиционирование для горизонтального масштабирования — Глава 13: Шардирование и партиционирование. Репликация для отказоустойчивости — Глава 14: Репликация данных.
Circuit Breaker для внешних интеграций
Notification Service вызывает SMS-провайдеров, push-сервисы, почтовые шлюзы — все они могут падать:
from circuitbreaker import circuit @circuit(failure_threshold=5, recovery_timeout=60, expected_exception=Exception) async def send_sms_notification(phone: str, message: str) -> bool: """ Circuit Breaker: после 5 ошибок подряд — переходит в OPEN состояние на 60 секунд. В это время все вызовы сразу возвращают ошибку (fail fast). После 60 секунд — переходит в HALF-OPEN, пробует один запрос. """ response = await sms_provider.send(phone, message) return response.status_code == 200
Circuit Breaker паттерн — см. Главу 41: Проектирование Uber и Главу 42: Проектирование Uber Eats для более детального разбора.
Мультирегиональность
FedEx оперирует в 220+ странах. Данные должны быть близко к пользователю:
Регион EMEA (Европа, Ближний Восток, Африка):
Primary: Frankfurt AWS eu-central-1
Failover: Dublin AWS eu-west-1
Регион APAC:
Primary: Singapore AWS ap-southeast-1
Failover: Tokyo AWS ap-northeast-1
Регион Americas:
Primary: Virginia AWS us-east-1
Failover: Oregon AWS us-west-2
Стратегия репликации:
- Shipment events: active-active (пишем в ближайший регион)
- Cassandra: multi-region replication с LOCAL_QUORUM
- PostgreSQL: active-passive с cross-region read replicas
Подробнее об active-active репликации — Глава 14: Репликация данных. Контейнеризация и оркестрация мультирегиональных деплойментов — Глава 15: Контейнеризация и оркестрация.
Безопасность и комплаенс
Proof of Delivery (PoD)
При вручении посылки система фиксирует:
- Электронная подпись получателя — хранится как SVG/PNG в S3, привязана к событию
- Фотография у двери — если нет возможности получить подпись (contactless delivery)
- GPS-координаты в момент доставки — для подтверждения местоположения курьера
- Timestamp с подписью сервера — HMAC для защиты от фальсификации
import hashlib import hmac import base64 from datetime import datetime def sign_delivery_proof( tracking_number: str, signature_image_bytes: bytes, gps_lat: float, gps_lng: float, driver_id: str, server_secret: str ) -> dict: timestamp = datetime.utcnow().isoformat() payload = f"{tracking_number}:{timestamp}:{gps_lat}:{gps_lng}:{driver_id}" signature = hmac.new( server_secret.encode(), payload.encode(), hashlib.sha256 ).hexdigest() return { "tracking_number": tracking_number, "delivered_at": timestamp, "gps_lat": gps_lat, "gps_lng": gps_lng, "driver_id": driver_id, "signature_b64": base64.b64encode(signature_image_bytes).decode(), "proof_hash": signature, # верифицируем при спорах }
PII и таможенные данные
Посылки содержат персональные данные получателей (PII — Personally Identifiable Information):
- Шифрование at rest: адреса и телефоны шифруются AES-256 в PostgreSQL
- Маскирование в логах: номер телефона в logs →
+7 (9**) ***-**-34 - Таможенные данные: декларируемая стоимость, коды HS (Harmonized System) хранятся отдельно от основных данных — доступны только уполномоченным сервисам
- Data residency: данные граждан ЕС хранятся в регионе EU (GDPR), данные россиян — в России (152-ФЗ)
- Retention policy: персональные данные удаляются через 3 года после доставки, технические события хранятся 7 лет для таможни
Подробнее о защите PII, шифровании и compliance — Глава 17: Безопасность.
Жизненный цикл посылки: сквозной поток
Разберём весь путь посылки от создания до доставки, указав, какие сервисы участвуют на каждом шаге:
ШАГ 1: Отправитель создаёт посылку
→ API Gateway (auth, rate limit)
→ Shipment Service: создаёт запись в PostgreSQL, генерирует трекинг-номер
→ Routing Service: находит оптимальный маршрут через хабы
→ ETA Service: считает первичный ETA
→ Billing Service: рассчитывает стоимость, создаёт инвойс
→ PDF Service: генерирует этикетку с QR/штрихкодом
→ Kafka: publishes shipment_created event
→ Notification Service: email отправителю "Создано отправление"
ШАГ 2: Курьер забирает посылку
→ Scanning Service (с мобильного устройства курьера)
→ Event записывается в Cassandra
→ Kafka: publishes picked_up event
→ Redis: обновляется статус
→ Notification Service: email получателю "Посылка в пути"
ШАГ 3: Сортировочный центр (автоматически)
→ Конвейерные сканеры → Edge Gateway → Central Kafka
→ Scanning Service: arrived_at_facility
→ Routing Service: подтверждает следующий хаб
→ ETA Service: пересчитывает ETA с учётом реального времени прибытия
ШАГ 4: Перелёт / перегон между хабами (несколько раз)
→ Scanning Service: departed_facility, arrived_sorting_hub
→ При каждом прибытии: ETA Service пересчитывает прогноз
ШАГ 5: Местный сортировочный центр → Out for Delivery
→ Routing Service (Last-Mile): строит маршрут для курьера (VRP)
→ Курьер получает список адресов упорядоченный оптимально
→ Kafka: out_for_delivery event
→ Notification Service: Push + SMS получателю "Доставим сегодня"
ШАГ 6: Доставка
→ Курьер сканирует посылку у двери / получает подпись
→ Scanning Service: delivered event с GPS + подписью
→ Event → Cassandra + Kafka
→ Redis: финальный статус
→ Notification Service (Critical): Push + SMS + Email получателю и отправителю
→ Webhook: POST на URL бизнес-клиента
→ Billing Service: финализирует транзакцию
Компромиссы и ограничения
CAP-выбор для разных компонентов
| Компонент | Выбор | Обоснование |
|---|---|---|
| Event log (Cassandra) | AP | Важнее записать каждое сканирование, чем блокироваться при network partition |
| Текущий статус (Redis) | AP | Stale данные на 30 сек лучше, чем недоступность трекинга |
| Billing (PostgreSQL) | CP | Нельзя потерять транзакции, временная недоступность допустима |
| Routing (PostgreSQL) | CP | Маршрут должен быть консистентным, не создаём посылки при недоступности |
Event Sourcing: цена за иммутабельность
Плюсы:
- Полный аудит без дополнительных усилий — идеально для таможни и судебных споров
- Легко добавить новые read model (аналитика, ML) без изменения write пути
- Восстановление состояния на любой момент времени
Минусы:
- Чтение текущего состояния требует replay или отдельного read model — дополнительная сложность
- Со временем event log растёт; нужна стратегия архивирования (snapshots)
- Сложнее исправлять ошибки: нельзя просто сделать UPDATE, нужно компенсирующее событие
Batch vs Real-time Processing
| Задача | Подход | Обоснование |
|---|---|---|
| Запись событий сканирования | Real-time (Kafka) | Получатели ждут обновлений |
| Расчёт ETA | Near-real-time (каждые 5 мин) | Точный ML дороже, частое обновление избыточно |
| Аналитика хабов | Batch (ночью) | Операционные отчёты не нужны в реальном времени |
| Billing reconciliation | Batch (раз в день) | Финансовая сверка с перевозчиками — дневной цикл |
| Archiving событий в cold storage | Batch (раз в неделю) | Переносим события старше 90 дней в S3 Glacier |
Проблема «потерянных» посылок
Если посылка не сканировалась более X часов — это аномалия. Нужен anomaly detector:
# Celery task, запускается каждые 15 минут @celery_app.task def detect_stale_shipments(): # Посылки в статусе IN_TRANSIT без сканирования > 24 часа (standard) # или > 4 часа (express) thresholds = {'express': 4, 'standard': 24, 'economy': 48} for service_type, hours in thresholds.items(): stale = db.query( """SELECT tracking_number FROM shipments WHERE current_status = 'in_transit' AND service_type = %s AND last_scanned_at < NOW() - INTERVAL '%s hours' AND current_status != 'exception'""", (service_type, hours) ) for shipment in stale: # Создать Exception event и уведомить операторов create_exception_event(shipment.tracking_number, 'STALE_SHIPMENT')
Мониторинг аномалий и observability системы — Глава 16: Мониторинг и Observability.
Итоги
Ключевые архитектурные решения
- Event Sourcing — каждое сканирование иммутабельное событие в Cassandra
- CQRS — write path (Cassandra + Kafka) отделён от read path (Redis + Elasticsearch)
- Конечный автомат с валидацией переходов — защита от некорректных состояний
- Edge Computing на хабах — offline-first для бесперебойной работы сканеров
- Kafka как хребет системы — все downstream сервисы независимо потребляют события
- Graceful Degradation — при нагрузке отключаем некритичные функции
- Webhook + retry с exponential backoff для надёжной B2B интеграции
- Мультирегиональность — данные хранятся рядом с пользователем (GDPR, 152-ФЗ)
Числа, которые нужно помнить
| Метрика | Значение |
|---|---|
| Событий сканирования в секунду (avg) | ~3 000 |
| Пиковая нагрузка (праздники) | ~15 000 событий/с |
| Задержка записи события | < 100 мс (p99) |
| Задержка чтения статуса из Redis | < 5 мс |
| Уведомление после события | < 2 мин |
| Хранение event log | 7 лет |
| Объём данных за 7 лет | ~320 ТБ |
Вопросы для самопроверки
Уровень 1: Базовые концепции
- Чем отличается модель данных FedEx/UPS от модели данных Uber с точки зрения источника событий?
- Почему для event log выбирают Cassandra, а не PostgreSQL?
- Что такое CQRS и зачем разделять write и read модели в системе трекинга?
- Объясните разницу между line-haul и last-mile доставкой с точки зрения алгоритмов маршрутизации.
- Почему offset-репликация важна для Edge Gateway на сортировочных центрах?
Уровень 2: Архитектурные решения
- Как бы вы смоделировали конечный автомат посылки, чтобы предотвратить некорректные переходы состояний?
- Какую partition key выбрать в Cassandra для таблицы событий? Почему не стоит партиционировать по дате?
- Как обеспечить exactly-once семантику при записи событий сканирования — если сканер может послать дублирующее сообщение?
- Как организовать webhook delivery с гарантиями at-least-once и защитой от replay-атак?
- Опишите стратегию tiered storage для 7-летнего хранения событий (hot / warm / cold).
Уровень 3: Компромиссы и ограничения
- Какие CAP-компромиссы вы бы выбрали для Cassandra event log vs PostgreSQL billing? Обоснуйте.
- Event Sourcing добавляет сложность. В каких случаях для системы трекинга достаточно обычного CRUD с audit log?
- Как изменится архитектура, если требование «уведомление в течение 2 минут» ужесточить до «30 секунд»?
- Как бороться с hot spots в Cassandra, если 80% запросов приходится на 20% популярных трекинг-номеров (крупные B2B клиенты)?
- Какие проблемы возникнут при multi-region active-active setup для PostgreSQL (shipments таблица)?
Уровень 4: Проектирование под нагрузку
- Спроектируйте схему Kafka топиков для системы с 15 000 событий/с и 5 независимых consumer group.
- Как вы бы реализовали обнаружение потерянных посылок (stale shipments) масштабируемым способом без полного скана таблицы?
- Предложите архитектуру для batch-расчёта ETA для всех посылок в статусе IN_TRANSIT (их может быть 50 миллионов одновременно).
- Как организовать zero-downtime миграцию схемы Cassandra при изменении формата события (добавление поля)?
- Спроектируйте систему мониторинга качества доставки: как в реальном времени считать on-time delivery rate по регионам, типам сервиса и клиентам?
Ответ на вопрос 7: Partition key в Cassandra
Используем tracking_number как partition key. Это гарантирует, что все события одной посылки хранятся на одной ноде и читаются одним запросом без scatter-gather. Partition key по дате (например, DATE(occurred_at)) — классическая ошибка: все записи одного дня попадают на одну ноду, создавая hot spot. При 250 миллионах событий в день это приведёт к перегрузке одной ноды.
Clustering key — event_id типа TIMEUUID: это автоматически сортирует события внутри партиции по времени, что нужно для отображения истории посылки в хронологическом порядке.
Ответ на вопрос 8: Exactly-once семантику при дублировании сканирований
Сканеры работают в условиях нестабильной сети и могут отправить одно событие несколько раз. Стратегия:
-
Idempotency key: каждое событие имеет уникальный
device_id + sequence_number. Scanning Service проверяет Redis:SET idempotency:{device_id}:{seq_num} 1 NX EX 86400. Если ключ уже существует — событие дубликат, отбрасываем. -
Cassandra UPSERT: даже если дубликат прошёл проверку, вставка в Cassandra через
INSERT IF NOT EXISTSс тем жеevent_id(TIMEUUID, генерируется на устройстве) безопасна — повторная вставка с тем же первичным ключом идемпотентна. -
Kafka deduplication: Kafka Streams поддерживает exactly-once semantics (EOS) с
enable.idempotence=trueиtransactional.id— это гарантирует отсутствие дублей в downstream топиках.
Ответ на вопрос 17: Обнаружение потерянных посылок без полного скана
Полный скан 25 миллионов активных посылок каждые 15 минут — дорогостоящая операция. Решение через Delayed Message Queue:
При каждом событии сканирования публикуем в Kafka топик shipment-watchdog сообщение с TTL равным максимально допустимому времени до следующего сканирования (4 часа для express, 24 часа для standard). Kafka consumer читает этот топик с задержкой. Если за это время пришло новое событие для той же посылки — watchdog сбрасывается. Если нет — создаётся STALE_SHIPMENT exception.
Альтернатива: Redis Sorted Set, где score = expected_next_scan_timestamp. Celery task каждые 5 минут выполняет ZRANGEBYSCORE watchdog 0 {current_timestamp} — получает только посылки, у которых истёк дедлайн. Это O(K) где K — количество опоздавших, а не O(N) по всем активным посылкам.
Что читать дальше
Связанные главы книги
- Глава 7: Очереди сообщений и Event-Driven архитектура — Kafka как основа всей системы
- Глава 12: Согласованность в распределённых системах — почему eventual consistency допустима для трекинга
- Глава 13: Шардирование и партиционирование — детали Cassandra partition key design
- Глава 16: Мониторинг и Observability — как отслеживать качество доставки и детектировать аномалии
- Глава 17: Безопасность — защита PII, HMAC подписи, data residency
- Глава 41: Проектирование Uber — GPS-трекинг и matching в реальном времени
- Глава 42: Проектирование Uber Eats — Order State Machine, Saga Pattern, ETA с ML
- Глава 43: Проектирование Lyft / Bolt — алгоритмы маршрутизации, VRP
Внешние ресурсы
- FedEx Developer Portal — официальная документация API
- UPS Developer Kit — Track API и Webhook документация
- Google OR-Tools VRP — библиотека для Vehicle Routing Problem
- «Designing Data-Intensive Applications» (Martin Kleppmann) — главы об Event Sourcing и CQRS (глава 11)
- «Building Event-Driven Microservices» (Adam Bellemare) — практическое руководство по event sourcing
- AWS re:Invent: How UPS uses AWS for package tracking — архитектурный разбор реального кейса
В следующей главе мы спроектируем систему управления складом (Warehouse Management System): как Amazon отслеживает миллиарды единиц товара, оптимизирует размещение на полках и организует роботизированную сортировку.