Задача от маркетинга, знакомая почти каждому бэкендеру: кампании пестрят длинными UTM-ссылками, которые ломаются в мессенджерах, а в отчёте нужно знать, сколько переходов принесла каждая. Бриф короткий: сервис коротких ссылок на Go и Redis — создать пару код-адрес, редиректить по коду, считать переходы, показывать топ. Хорошая новость: ничего нового учить не нужно. GET и SET с TTL из урока про строки, INCR из урока про счётчики, ZSET из урока про лидерборды и пайплайны дают полный проект за один урок. Собираем сервис целиком: схема ключей, три хендлера, ловушки продакшена и тесты — и в конце посмотрим, какие уроки курса в какую деталь легли.
Сервис коротких ссылок на Go и Redis: схема ключей#
Начнём с контракта ключей, потому что он определяет весь сервис. link:<code> хранит длинный адрес строкой со сроком жизни кампании; clicks:<code> — счётчик переходов; top:links — ZSET, где член это код, а score — число переходов. Имена короткие и плоские: код сам по себе уникален, поэтому вложенность не нужна. TTL на ссылку ставится при создании: кампания живёт месяц, потом ключ умирает сам, и кронтаб чистки не существует как задача. Отдельное решение — судьба статистики: счётчик и ZSET живут дольше ссылок, потому что отчёт по кампании нужен и после её конца; распад пары «ссылка мертва, счётчик жив» — норма, ведь они отвечают на разные вопросы. Для брифа маркетинга такой схемы хватает с запасом.
Создание ссылки: SetNX с TTL
GOconst alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
func newCode(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
for i, b := range buf {
buf[i] = alphabet[int(b)%len(alphabet)]
}
return string(buf), nil
}
func createLink(ctx context.Context, rdb *redis.Client, url string, ttl time.Duration) (string, error) {
code, err := newCode(7)
if err != nil {
return "", err
}
ok, err := rdb.SetNX(ctx, "link:"+code, url, ttl).Result()
if err != nil {
return "", err
}
if !ok {
return "", ErrCodeTaken // коллизия: вызывающий повторит с новым кодом
}
return code, nil
}SetNX с TTL делает атомарно три вещи: проверяет занятость кода, записывает адрес и ставит срок жизни — одна команда вместо проверки и записи, между которыми влезет чужой запрос. Коллизия семи случайных символов из 62 знаков редка, но обработана честно: ErrCodeTaken и повторная попытка с новым кодом в хендлере. TTL здесь же, при создании: ссылка кампании умрёт сама, как в уроке про TTL и истечение.
Короткий код — валюта: чем меньше символов, тем удобнее печатать и тем выше шанс коллизии. Схема с SetNX переносит борьбу за код в Redis: два запроса на один код одновременно — победит один, второй получит false и сгенерирует заново. Это та же механика, что у распределённых блокировок из отдельного урока про SETNX, только вместо замка — сама ссылка. Алфавит стоит подбирать под носителя: шестьдесят два знака дают максимум ёмкости, но выбросьте похожие символы — ноль с буквой O и единицу с l — и читаемость на печати и в QR вырастет без потери ёмкости: шесть символов из шестидесяти знаков всё ещё покрывают десятки миллиардов комбинаций. Альтернатива с базой данных и уникальным индексом тоже легальна: Redis здесь не единственный вариант, но он даёт TTL бесплатно и держит редирект на одном GET. Если ссылки должны переживать перезапуск Redis и жить годами, источник истины переносят в базу, а Redis остаётся кэшем редиректов: код почти тот же, меняется дисциплина записи и чистки.
Редирект со счётом перехода
GOfunc redirect(rdb *redis.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "code")
url, err := rdb.Get(r.Context(), "link:"+code).Result()
if errors.Is(err, redis.Nil) {
http.Error(w, "link not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
pipe := rdb.Pipeline()
pipe.Incr(r.Context(), "clicks:"+code)
pipe.ZIncrBy(r.Context(), "top:links", 1, code)
if _, err := pipe.Exec(r.Context()); err != nil {
log.Printf("click stats failed: %v", err) // редирект уже случится
}
http.Redirect(w, r, url, http.StatusFound)
}
}Промах ссылки — redis.Nil и 404: пользовательский статус вместо 500, ветки разобраны в уроке про ошибки go-redis. Статистика едет пайплайном из урока про пайплайны одним рейсом: Incr двигает счётчик, ZIncrBy — место в топе, и обе команды уходят одной посылкой. Сбой записи статистики не отменяет редирект: пользователь ушёл по ссылке, потерянный клик в отчёте дешевле, чем 500 на глазах у клиента.
Счётчики переходов: что считаем, а что нет#
Счётчик — самый честный раздел проекта, потому что в нём надо решить, что вообще считается переходом. Базовый вариант из кода выше инкрементирует всё подряд: боты, предпросмотры мессенджеров, собственные проверки доступности — в отчёте окажется шум, которого в реальности не было. Промежуточная гигиена без аналитики: не считать запросы без Referer, фильтровать известных ботов по User-Agent, вычитать пинги мониторинга — три условия дают цифру, с которой уже можно разговаривать с маркетингом. Следующий уровень — писать события в Streams из урока про потоки и агрегировать воркером: тогда в отчёте режут по дням, странам и кампаниям, а сырьё не теряется. Для брифа маркетинга достаточно счётчика с фильтром ботов: точность плюс-минус пара процентов, зато сервис остаётся двумя командами Redis.
Статистика одной ссылки
GOfunc stats(rdb *redis.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := chi.URLParam(r, "code")
clicks, err := rdb.Get(r.Context(), "clicks:"+code).Int64()
if err != nil && !errors.Is(err, redis.Nil) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
score, err := rdb.ZScore(r.Context(), "top:links", code).Result()
if err != nil && !errors.Is(err, redis.Nil) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{
"code": code, "clicks": clicks, "score": score,
})
}
}redis.Nil для счётчика — легальный ноль: ссылку создали, переходов ещё не было. Int64 конвертирует строковый счётчик без ручного strconv. ZScore отдаёт место в топе: отчёт маркетингу собирается двумя командами, и никакой отдельной базы для этого не понадобилось.
Топ ссылок: ZSET без переполнения#
Топ живёт в ZSET top:links: ZIncrBy на каждый переход двигает score, ZRevRangeWithScores из урока про лидерборды отдаёт первые N с числами. Проблема роста здесь не острая — кодов у маркетинга десятки, — но дисциплина всё равно нужна: ZSET без границ на общедоступном сервисе превращается в свалку всех созданных когда-либо ссылок. Обрезка через ZRemRangeByRank с отрицательными индексами оставляет в наборе тысячу самых кликабельных кодов — топ честный, память ограничена. Второй вопрос — срок жизни самого top:links: счётчики кампании полезны и после её конца, поэтому ключу ставят TTL с продлением на каждом переходе — активный топ живёт, мёртвый умирает сам.
Выдача топа и обрезка хвоста
GOfunc topLinks(ctx context.Context, rdb *redis.Client, n int) ([]redis.Z, error) {
const keep = 1000
z, err := rdb.ZRevRangeWithScores(ctx, "top:links", 0, int64(n-1)).Result()
if err != nil {
return nil, err
}
// держим ZSET в границах: тысяча самых кликабельных кодов
rdb.ZRemRangeByRank(ctx, "top:links", 0, int64(-keep-1))
return z, nil
}ZRevRangeWithScores отдаёт список от самого кликабельного: первый элемент — лидер отчёта. ZRemRangeByRank с индексом минус тысяча минус один выметает хвост после лидеров: набор не растёт бесконечно, а топовые позиции не затрагиваются. Рядом с кодом в отчёте обычно нужен длинный адрес: HGet по link:<code> для каждого элемента или хранение адреса прямо в члене — компромисс между размером члена и числом команд.
Срок жизни ссылки: TTL по кампании#
Срок жизни ссылки — продуктовое решение, и Redis делает его одной строкой при создании. Кампании живут месяцами, технические ссылки на скачивание — днями, одноразовые приглашения — часами: TTL подставляет нужное число, и чистка не существует как задача. Пограничный случай — продление: кампанию продлили, а ссылка через неделю умрёт. Лекарство — Expire с новой датой из админки или повторный SetNX с тем же кодом и новым сроком: оба варианта возвращают управление человеку, а не таймеру. Отдельно решите судьбу счётчиков: если отчётность нужна за весь год, счётчики живут без срока, а ссылки — по сроку кампании; у этого распада пары есть и обратная сторона — старые счётчики без ссылок надо когда-то чистить SCAN-ом по расписанию или мириться с ними как с архивом.
Классическая ошибка финальных проектов — связать редирект и статистику так, что сбой записи ломает переход: пользователь, который не попал по ссылке из-за упавшего ZIncrBy, — потерянный визит и минус в отчёте. Порядок жёсткий: редирект важнее метрики, статистика пишется с логом ошибки и без возврата 500. Вторая ошибка — KEYS по ссылкам для сводки: на публичном сервисе это остановка Redis; только SCAN или ZSET-реестр.
Чему учит проект: карта уроков курса#
Проект собрал курс в рабочую систему, и полезно пройтись по списку связей — именно они спрашиваются на собеседованиях. SET с NX и TTL — из уроков про строки GET и SET и срок жизни ключей: атомарное создание со сроком. INCR и ZIncrBy — из уроков про счётчики и рейтинги: арифметика без гонок при любом числе инстансов. Пайплайн — два счётчика одним рейсом на горячем пути редиректа. redis.Nil и разделение промаха с аварией — из урока про ошибки. Пул соединений из урока про пул остался дефолтным и справился: редирект — короткая команда, самая благоприятная нагрузка для пула. А тесты из урока про miniredis закрывают контракт: первый переход считается, повторный берёт адрес из кэша ссылок, а статистика растёт на единицу. Рассказ об этом проекте на собеседовании — это, по сути, пересказ курса с обоснованием каждого выбора, и после него вопросы про Redis обычно заканчиваются.
Сервис запускается локальным Go с Redis в Docker: три хендлера на chi, клиент из main, и curl-цикл — создать ссылку, перейти пять раз, снять статистику и топ. Песочница проекта сетевые вызовы не открывает, но генерацию кода base62 и логику сравнения счётчиков можно скопировать в песочницу на локальном Go и проверить на числах.
Проверка: борьба за код
Почему создание ссылки делается одной SetNX вместо проверки Exists и последующего Set?
Проверка: топ ссылок
Какой командой получить первые десять самых кликабельных ссылок из ZSET?
Что покажет статистика после двух переходов?
Прочитайте код и выберите, что он выведет:
// ссылка создана: link:go = https://go.dev, счётчики пустые
rdb.Get(ctx, "link:go").Result()
rdb.Incr(ctx, "clicks:go")
rdb.ZIncrBy(ctx, "top:links", 1, "go")
rdb.Incr(ctx, "clicks:go")
rdb.ZIncrBy(ctx, "top:links", 1, "go")
n, _ := rdb.Get(ctx, "clicks:go").Int64()
s, _ := rdb.ZScore(ctx, "top:links", "go").Result()
fmt.Println(n, s)Соберите создание ссылки
Допишите атомарную запись адреса со сроком жизни и обработку занятого кода.
Соберите хендлер редиректа
Расставьте строки по порядку: чтение кода, поиск адреса, ветка промаха, статистика, редирект.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: итоговый проект
Курс закрыт полным проектом — и это хороший момент, чтобы перечитать маршрут: страница курса по Redis показывает путь от первого подключения до сервиса. Проектные приёмы подробно разобраны в уроках про строки GET и SET, TTL и истечение, счётчики INCR, ZSET и лидерборды и пайплайны. Что развить в проекте дома: ограничение частоты создания ссылок через rate limiter, тесты через miniredis, а безопасность cookie-сессий админки — по чек-листу курса аутентификации. Финальная домашка ниже добавляет одноразовые ссылки — и проверяет, насколько прочно вы усвоили атомарность.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Схема ключей проекта: link:<code> со сроком жизни, clicks:<code> — счётчик, top:links — ZSET топа; имена плоские, код уникален сам.
- SetNX с TTL создаёт ссылку атомарно: проверка занятости, запись и срок одной командой; коллизия обрабатывается повтором с новым кодом.
- Редирект различает 404 по redis.Nil и 500 по аварии; статистика пишется пайплайном и не ломает переход при сбое.
- Топ на ZSET: ZIncrBy на переход, ZRevRangeWithScores для отчёта, обрезка хвоста ZRemRangeByRank держит память в границах.
- Ссылка умирает по TTL кампании, счётчики живут дольше — они отвечают на вопрос отчётности, а не доступности.
- Проект собрал весь курс: строки, TTL, счётчики, ZSET, пайплайны, ошибки, пул и тесты — типовой набор Redis-приёмов бэкенда.
Домашнее задание
Соберите сервис целиком на локальном Go с Redis в Docker и прогоните curl-цикл: создать, перейти пять раз, снять статистику и топ, проверить 404 на мёртвом коде. Затем добавьте одноразовые ссылки: переход в пайплайне делает Del по link:<code>, и повторный переход обязан отдать 404 — проверьте гонку двумя параллельными curl. В конце прикройте создание ссылок лимитером из урока про rate limiter и напишите тесты через miniredis.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему коды генерируются случайно, а не счётчиком base62?
Счётчик предсказуем: соседние коды перебираются роботами и раскрывают объём базы. Случайные семь символов из 62 знаков дают пространство в три с лишним триллиона комбинаций — перебор бессмыслен, а коллизии обрабатывает SetNX.
Что произойдёт со счётчиками, если Redis перезапустится без persistence?
Счётчики и топ обнулятся, ссылки пропадут — сервис продолжит работу, но история кампаний потеряется. Для брифа маркетинга это обычно неприемлемо, поэтому включают AOF или переносят счётчики в базу пакетной записью раз в минуту.
Как добавить аналитику по дням и странам, не ломая текущую схему?
Писать события переходов в Streams из урока про потоки и агрегировать воркером в ключи вида clicks:<code>:2025-06-01. Счётчик останется быстрым числом для витрины, а детализация появится на стороне агрегации — горячий путь редиректа не изменится.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin