---
title: "Итоговый проект: сервис коротких ссылок"
description: "Итоговый проект: сервис коротких ссылок на Go и Redis. SET с NX и TTL, счётчики переходов INCR, топ ссылок на ZSET, пайплайн статистики и тесты."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

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

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 25 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/25-itogovyj-proekt/*

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

Задача от маркетинга, знакомая почти каждому бэкендеру: кампании пестрят длинными 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 и истечение](/courses/go-redis/lessons/05-ttl-i-istechenie/).

Короткий код — валюта: чем меньше символов, тем удобнее печатать и тем выше шанс коллизии. Схема с SetNX переносит борьбу за код в Redis: два запроса на один код одновременно — победит один, второй получит false и сгенерирует заново. Это та же механика, что у распределённых блокировок из отдельного урока про [SETNX](/courses/go-redis/lessons/10-raspredelennye-blokirovki/), только вместо замка — сама ссылка. Алфавит стоит подбирать под носителя: шестьдесят два знака дают максимум ёмкости, но выбросьте похожие символы — ноль с буквой 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](/courses/go-redis/lessons/19-oshibki-go-redis/). Статистика едет пайплайном из урока про [пайплайны](/courses/go-redis/lessons/16-pipy-lineinye/) одним рейсом: Incr двигает счётчик, ZIncrBy — место в топе, и обе команды уходят одной посылкой. Сбой записи статистики не отменяет редирект: пользователь ушёл по ссылке, потерянный клик в отчёте дешевле, чем 500 на глазах у клиента.

### Счётчики переходов: что считаем, а что нет

Счётчик — самый честный раздел проекта, потому что в нём надо решить, что вообще считается переходом. Базовый вариант из кода выше инкрементирует всё подряд: боты, предпросмотры мессенджеров, собственные проверки доступности — в отчёте окажется шум, которого в реальности не было. Промежуточная гигиена без аналитики: не считать запросы без Referer, фильтровать известных ботов по User-Agent, вычитать пинги мониторинга — три условия дают цифру, с которой уже можно разговаривать с маркетингом. Следующий уровень — писать события в Streams из урока про [потоки](/courses/go-redis/lessons/15-potoki-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 из урока про [лидерборды](/courses/go-redis/lessons/13-zset-reitingi/) отдаёт первые 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-ом по расписанию или мириться с ними как с архивом.

> **Падение статистики не должно ломать переход.** Классическая ошибка финальных проектов — связать редирект и статистику так, что сбой записи ломает переход: пользователь, который не попал по ссылке из-за упавшего ZIncrBy, — потерянный визит и минус в отчёте. Порядок жёсткий: редирект важнее метрики, статистика пишется с логом ошибки и без возврата 500. Вторая ошибка — KEYS по ссылкам для сводки: на публичном сервисе это остановка Redis; только SCAN или ZSET-реестр.

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

Проект собрал курс в рабочую систему, и полезно пройтись по списку связей — именно они спрашиваются на собеседованиях. SET с NX и TTL — из уроков про [строки GET и SET](/courses/go-redis/lessons/04-stroki-get-set/) и [срок жизни ключей](/courses/go-redis/lessons/05-ttl-i-istechenie/): атомарное создание со сроком. INCR и ZIncrBy — из уроков про [счётчики](/courses/go-redis/lessons/09-schetchiki-i-incr/) и [рейтинги](/courses/go-redis/lessons/13-zset-reitingi/): арифметика без гонок при любом числе инстансов. Пайплайн — два счётчика одним рейсом на горячем пути редиректа. redis.Nil и разделение промаха с аварией — из урока про ошибки. Пул соединений из урока про [пул](/courses/go-redis/lessons/20-conn-pool/) остался дефолтным и справился: редирект — короткая команда, самая благоприятная нагрузка для пула. А тесты из урока про [miniredis](/courses/go-redis/lessons/24-testy-miniredis/) закрывают контракт: первый переход считается, повторный берёт адрес из кэша ссылок, а статистика растёт на единицу. Рассказ об этом проекте на собеседовании — это, по сути, пересказ курса с обоснованием каждого выбора, и после него вопросы про Redis обычно заканчиваются.

> **Прогон за десять минут.** Сервис запускается локальным Go с Redis в Docker: три хендлера на chi, клиент из main, и curl-цикл — создать ссылку, перейти пять раз, снять статистику и топ. Песочница проекта сетевые вызовы не открывает, но генерацию кода base62 и логику сравнения счётчиков можно скопировать в песочницу на локальном Go и проверить на числах.

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

**Вопрос:** Почему создание ссылки делается одной SetNX вместо проверки Exists и последующего Set?

- ✅ Между проверкой и записью влезет чужой запрос — гонка на занятость кода
- SetNX быстрее Exists
- Exists не работает со строками
- Так требует chi

Check-then-set — классическая гонка: два запроса на один код оба увидят свободный ключ и оба запишут. SetNX атомарен: победит один, второй получит false и сгенерирует новый код.

### Проверка: топ ссылок

**Вопрос:** Какой командой получить первые десять самых кликабельных ссылок из ZSET?

- ✅ ZRevRangeWithScores
- LRANGE
- SMEMBERS
- HGETALL

ZSET хранит членов по score, поэтому реверсивный диапазон отдаёт топ: нулевой индекс — лидер. LRANGE и SMEMBERS принадлежат другим типам данных, а HGETALL — хэшу без упорядоченности.

### Что покажет статистика после двух переходов?

```go
// ссылка создана: 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)
```

**Что выведет программа?**

- ✅ 2 2: счётчик и score растут синхронно
- 2 1
- 1 2
- 0 0: GET не считается переходом

Каждый переход делает Incr и ZIncrBy одной парой: счётчик и score идут синхронно. Get по link:go читает адрес и статистику не двигает — иначе один просмотр отчёта раздувал бы переходы.

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

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

```go
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, ___
```

Подсказка: Атомарная запись со сроком жизни — SetNX, false означает занятый код, а успех возвращает nil-ошибку.

Правильные ответы в пропусках: `SetNX`, `ok`, `nil`

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

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

Правильный порядок строк:

1. `func redirect(rdb *redis.Client) http.HandlerFunc {`
2. `    code := chi.URLParam(r, "code")`
3. `    url, err := rdb.Get(r.Context(), "link:" + code).Result()`
4. `    if errors.Is(err, redis.Nil) { http.Error(w, "not found", 404); return }`
5. `    pipe.Incr(r.Context(), "clicks:" + code); pipe.ZIncrBy(r.Context(), "top:links", 1, code)`
6. `    http.Redirect(w, r, url, http.StatusFound)`

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

- **link:<code>** — Строка с длинным адресом и TTL кампании. Создаётся SetNX: атомарная проверка занятости, запись и срок жизни одной командой.
- **clicks:<code>** — Счётчик переходов на INCR: атомарен при любом числе инстансов. Промах — легальный ноль, а не ошибка.
- **top:links** — ZSET топа: член — код, score — переходы. ZIncrBy на каждый переход, ZRevRangeWithScores для отчёта, обрезка хвоста ZRemRangeByRank.
- **Порядок: редирект важнее метрики** — Сбой записи статистики не ломает переход: пользователь уходит по ссылке, потеря клика дешевле 500. Пайплайн сводит Incr и ZIncrBy в один рейс.
- **TTL ссылки и судьба счётчиков** — Ссылка живёт по сроку кампании и умирает сама; счётчики и топ полезны после конца кампании и живут дольше. Распад пары — норма.
- **Что собрал проект** — GET/SET с TTL, INCR, ZSET, пайплайн, redis.Nil, пул и miniredis: типовой набор Redis-приёмов бэкенда в одном сервисе.

Курс закрыт полным проектом — и это хороший момент, чтобы перечитать маршрут: страница [курса по Redis](/courses/go-redis/) показывает путь от первого подключения до сервиса. Проектные приёмы подробно разобраны в уроках про [строки GET и SET](/courses/go-redis/lessons/04-stroki-get-set/), [TTL и истечение](/courses/go-redis/lessons/05-ttl-i-istechenie/), [счётчики INCR](/courses/go-redis/lessons/09-schetchiki-i-incr/), [ZSET и лидерборды](/courses/go-redis/lessons/13-zset-reitingi/) и [пайплайны](/courses/go-redis/lessons/16-pipy-lineinye/). Что развить в проекте дома: ограничение частоты создания ссылок через [rate limiter](/courses/go-redis/lessons/22-rate-limiter/), тесты через [miniredis](/courses/go-redis/lessons/24-testy-miniredis/), а безопасность cookie-сессий админки — по [чек-листу курса аутентификации](/courses/jwt-go/lessons/24-luchshie-praktiki/). Финальная домашка ниже добавляет одноразовые ссылки — и проверяет, насколько прочно вы усвоили атомарность.

## Коротко: что запомнить

- Схема ключей проекта: 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. Счётчик останется быстрым числом для витрины, а детализация появится на стороне агрегации — горячий путь редиректа не изменится.
