GPRS-оптимизация в 2012: как мы делали мобильные API для медленного интернета
Летом 2012 года мы запускали мобильное приложение для одного из бишкекских клиентов. Первые тесты на офисном Wi-Fi выглядели отлично. Первый тест с реальным телефоном в реальных условиях — GPRS на «Мегакоме» — показал загрузку главного экрана за 14 секунд.
14 секунд. На экран с 8 элементами.
Это не было проблемой сервера. Это была проблема архитектуры, которую мы спроектировали без понимания канала доставки.
Почему GPRS убивал стандартный REST
GPRS — это не просто «медленный Wi-Fi». У него другая физическая природа:
- Пропускная способность: 40–80 кбит/с при хорошем сигнале, реально 20–40
- Задержка (latency): 400–800 мс на один round trip
- Потери пакетов: 2–5% в норме, 10–15% при слабом сигнале
- Нестабильность: соединение может прерваться в середине передачи
Наш API делал 6 последовательных запросов при загрузке главного экрана. Каждый запрос — отдельный HTTP round trip. При задержке 600 мс только на установку соединений уходило 3,6 секунды ещё до передачи данных.
Запрос 1: GET /user/profile → 600ms latency + 200ms данные
Запрос 2: GET /feed?page=1 → 600ms latency + 800ms данные
Запрос 3: GET /notifications/count → 600ms latency + 100ms данные
...
Итого: ~8-10 секунд только на latency
Что мы изменили
1. Агрегированный endpoint для первого экрана
Вместо шести запросов — один. Сервер собирает все данные для начального рендера и возвращает их одним ответом:
// Было: клиент делает 6 запросов
// GET /user/profile, /feed, /notifications, /banners, /categories, /stats
// Стало: один endpoint с данными для старта приложения
// GET /app/bootstrap
$response = [
'user' => $this->getUserProfile($userId),
'feed' => $this->getFeedPreview($userId, 5), // только 5 постов
'notifications' => $this->getUnreadCount($userId),
'categories' => $this->getActiveCategories(), // кэшировалось 10 минут
'version' => APP_VERSION,
];
6 round trips → 1. На GPRS это экономило 3–5 секунд.
2. Агрессивное сжатие
JSON без gzip на медленном канале — преступление. Мы добавили gzip с уровнем 6 (баланс скорость/степень сжатия) и убрали лишние пробелы из ответов.
// Включить gzip на сервере (nginx)
gzip on;
gzip_types application/json text/plain text/css;
gzip_comp_level 6;
gzip_min_length 256;
// В PHP: убираем лишние пробелы из JSON
echo json_encode($data, JSON_UNESCAPED_UNICODE);
// Не JSON_PRETTY_PRINT — это добавляет сотни байт
Типичный ответ с профилем пользователя: 4,2 КБ → 1,1 КБ после сжатия. На GPRS это разница в 0,8 секунды на один запрос.
3. Минимальный payload
Мы возвращали только те поля, которые клиент реально использовал. Стандартная ошибка — отдавать всю запись из БД:
// Плохо: отдаём всё
SELECT * FROM users WHERE id = ?
// → 40 полей, 2KB JSON, половина не нужна мобильному клиенту
// Хорошо: только нужное для этого экрана
SELECT id, name, avatar_url, is_verified FROM users WHERE id = ?
// → 4 поля, 180 байт JSON
Мы ввели правило: каждый endpoint имеет документированный список возвращаемых полей, и это список пересматривается с мобильной командой при каждом изменении.
4. Дельта-синхронизация вместо полной загрузки
При повторных загрузках клиент передавал timestamp последней синхронизации. Сервер отдавал только изменения:
// Клиент передаёт: ?since=1342612345
$since = (int) $_GET['since'];
if ($since > 0) {
// Дельта: только новые/изменённые посты
$posts = Post::where('updated_at', '>', $since)
->where('user_id', $userId)
->limit(20)
->get();
$mode = 'delta';
} else {
// Первая загрузка
$posts = Post::latest()->limit(20)->get();
$mode = 'full';
}
echo json_encode(['mode' => $mode, 'items' => $posts, 'ts' => time()]);
После первой загрузки обновление ленты стало передавать в среднем 3–5 новых постов вместо 20. Трафик упал в 4–6 раз на повторных запросах.
5. Таймауты и fallback
GPRS-соединение может зависнуть на 30+ секунд, не вернув ни ошибки, ни данных. Клиент без таймаутов просто висит.
// Android, 2012 (Apache HttpClient, который тогда использовали все)
HttpParams params = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(params, 8000); // 8 сек на коннект
HttpConnectionParams.setSoTimeout(params, 12000); // 12 сек на чтение
// При таймауте: показываем кэшированные данные + индикатор "нет сети"
try {
JSONObject data = apiClient.getBootstrap();
updateUI(data);
} catch (SocketTimeoutException e) {
JSONObject cached = localCache.getBootstrap();
if (cached != null) {
updateUIFromCache(cached);
showOfflineBanner();
} else {
showRetryScreen();
}
}
Результаты после оптимизации
| Метрика | До | После |
|---|---|---|
| Загрузка главного экрана (GPRS) | 14 сек | 3,8 сек |
| Количество HTTP-запросов при старте | 6 | 1 |
| Средний размер ответа /bootstrap | 18 КБ | 2,2 КБ |
| Трафик при обновлении ленты | 42 КБ | 7 КБ |
3,8 секунды на GPRS — всё ещё медленно по современным меркам. По меркам 2012 года в Бишкеке — достаточно быстро, чтобы пользователи не удаляли приложение.
Принцип, который остался
Тестируй на реальном устройстве в реальных условиях сети до того, как покажешь клиенту. Не на эмуляторе. Не на офисном Wi-Fi. Выйди на улицу, включи мобильный интернет, зайди в переход или подвал — и смотри как ведёт себя приложение там.
В 2012 году это было требование выживания. Сегодня, когда значительная часть пользователей Центральной Азии всё ещё работает на нестабильном 3G в районах с плохим покрытием, это требование актуально в той же мере.