Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~24 мин +20 XP

Итоговый проект: сервис коротких ссылок

Итоговый проект курса — сервис коротких ссылок на Go и Redis: POST создаёт пару код-адрес с TTL, GET редиректит и считает переходы, ZSET держит топ ссылок. Ничего нового учить не придётся: сервис коротких ссылок собирается из GET и SET со сроком жизни, счётчиков INCR, ZSET-рейтинга и пайплайнов — весь Redis-инструментарий курса в одном работающем приложении.

Содержание урока · 8 разделов

Задача от маркетинга, знакомая почти каждому бэкендеру: кампании пестрят длинными UTM-ссылками, которые ломаются в мессенджерах, а в отчёте нужно знать, сколько переходов принесла каждая. Бриф короткий: сервис коротких ссылок на Go и Redis — создать пару код-адрес, редиректить по коду, считать переходы, показывать топ. Хорошая новость: ничего нового учить не нужно. GET и SET с TTL из урока про строки, INCR из урока про счётчики, ZSET из урока про лидерборды и пайплайны дают полный проект за один урок. Собираем сервис целиком: схема ключей, три хендлера, ловушки продакшена и тесты — и в конце посмотрим, какие уроки курса в какую деталь легли.

Сервис коротких ссылок на Go и Redis: схема ключей#

Начнём с контракта ключей, потому что он определяет весь сервис. link:<code> хранит длинный адрес строкой со сроком жизни кампании; clicks:<code> — счётчик переходов; top:links — ZSET, где член это код, а score — число переходов. Имена короткие и плоские: код сам по себе уникален, поэтому вложенность не нужна. TTL на ссылку ставится при создании: кампания живёт месяц, потом ключ умирает сам, и кронтаб чистки не существует как задача. Отдельное решение — судьба статистики: счётчик и ZSET живут дольше ссылок, потому что отчёт по кампании нужен и после её конца; распад пары «ссылка мертва, счётчик жив» — норма, ведь они отвечают на разные вопросы. Для брифа маркетинга такой схемы хватает с запасом.

Создание ссылки: SetNX с TTL

GO
const 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 остаётся кэшем редиректов: код почти тот же, меняется дисциплина записи и чистки.

Редирект со счётом перехода

GO
func 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.

Статистика одной ссылки

GO
func 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 с продлением на каждом переходе — активный топ живёт, мёртвый умирает сам.

Выдача топа и обрезка хвоста

GO
func 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-ом по расписанию или мириться с ними как с архивом.

Чему учит проект: карта уроков курса#

Проект собрал курс в рабочую систему, и полезно пройтись по списку связей — именно они спрашиваются на собеседованиях. SET с NX и TTL — из уроков про строки GET и SET и срок жизни ключей: атомарное создание со сроком. INCR и ZIncrBy — из уроков про счётчики и рейтинги: арифметика без гонок при любом числе инстансов. Пайплайн — два счётчика одним рейсом на горячем пути редиректа. redis.Nil и разделение промаха с аварией — из урока про ошибки. Пул соединений из урока про пул остался дефолтным и справился: редирект — короткая команда, самая благоприятная нагрузка для пула. А тесты из урока про miniredis закрывают контракт: первый переход считается, повторный берёт адрес из кэша ссылок, а статистика растёт на единицу. Рассказ об этом проекте на собеседовании — это, по сути, пересказ курса с обоснованием каждого выбора, и после него вопросы про Redis обычно заканчиваются.

Проверка: борьба за код

Почему создание ссылки делается одной 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)

Соберите создание ссылки

Допишите атомарную запись адреса со сроком жизни и обработку занятого кода.

code, err := newCode(7) if err != nil { return "", err } ok, err := rdb.(ctx, "link:"+code, url, ttl).Result() if err != nil { return "", err } if ! { return "", ErrCodeTaken } return code,

Соберите хендлер редиректа

Расставьте строки по порядку: чтение кода, поиск адреса, ветка промаха, статистика, редирект.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: итоговый проект

Термин

link:<code>

нажмите, чтобы перевернуть
Определение

Строка с длинным адресом и TTL кампании. Создаётся SetNX: атомарная проверка занятости, запись и срок жизни одной командой.

1 / 6

Курс закрыт полным проектом — и это хороший момент, чтобы перечитать маршрут: страница курса по 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Redis · Итоговый проект: сервис коротких ссылок)

Вопросы и ответы по уроку

Почему коды генерируются случайно, а не счётчиком base62?

Счётчик предсказуем: соседние коды перебираются роботами и раскрывают объём базы. Случайные семь символов из 62 знаков дают пространство в три с лишним триллиона комбинаций — перебор бессмыслен, а коллизии обрабатывает SetNX.

Что произойдёт со счётчиками, если Redis перезапустится без persistence?

Счётчики и топ обнулятся, ссылки пропадут — сервис продолжит работу, но история кампаний потеряется. Для брифа маркетинга это обычно неприемлемо, поэтому включают AOF или переносят счётчики в базу пакетной записью раз в минуту.

Как добавить аналитику по дням и странам, не ломая текущую схему?

Писать события переходов в Streams из урока про потоки и агрегировать воркером в ключи вида clicks:<code>:2025-06-01. Счётчик останется быстрым числом для витрины, а детализация появится на стороне агрегации — горячий путь редиректа не изменится.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

JWT25 уроков~12 чпрактика25 уроков уже доступны

JWT-аутентификация в Go: golang-jwt и bcrypt

Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.

  • Понимать анатомию JWT и почему payload не зашифрован
  • Выпускать и проверять токены через golang-jwt v5
  • Хешировать пароли bcrypt и сравнивать безопасно
  • Писать middleware аутентификации для chi и Gin