Долгое время я пытался научиться слепому десятипальцевому методу печати, но всегда это заканчивалось поражением. Учился на Keybr — на нём освоил английский. Частотный метод, когда ты печатаешь настоящие слова из самых частых букв, мне подошёл. Но столкнулся с тем, что заглавные буквы, пунктуация и цифры спрятаны за кучей настроек. Подумал — зачем это прятать, если можно сделать структурированные этапы и дать чёткий путь прохождения? Так я начал разработку TypeStep — тренажёра слепой печати с частотным методом и этапами прохождения. А теперь — про то, на чём это всё построено и с чем пришлось столкнуться.

Выбор стека: почему Go, а не Spring Boot

Мой основной опыт в разработке — это стек Java и Spring Boot. Да, вначале это были EJB и application-серверы, но в мире, где все используют микросервисную архитектуру, в Java почти все сидят на Spring Boot. Также был опыт использования облаков, в основном AWS.

В этот раз я решил использовать Yandex Cloud и посмотреть, что же происходит на российском рынке и где мы сейчас в плане облачной инфраструктуры.

Сразу скажу: вкладываться и платить за виртуальные серверы я не хотел. Бюджет ограничен, хотел создать максимально экономичное приложение — и в плане производительности, и в плане денег. Это добавило дополнительной сложности, потому что будь у меня возможность — я бы выбрал что-то типа ECS Fargate + Spring Boot + PostgreSQL. В Яндексе прямого аналога Fargate нет, поэтому выбор пал на Serverless Containers и YDB.

Для Serverless Containers Spring Boot не подходит — долгое время старта и проблема прогрева. Проще говоря, первые запросы после деплоя будут тормозить. Скажу честно: на одном из прошлых проектов я пробовал засунуть уже работающий Spring Boot в GraalVM — с наскока не получилось, это требует времени, чтобы разобраться. Поэтому для бессерверной архитектуры я решил попробовать Go, хотя знаний и опыта в нём не было.

Почитав немного и попробовав hello world и REST с GET /health, я был удивлён временем компиляции и холодного запуска. Go компилируется в один статический бинарник — никакой JVM, никакого прогрева JIT-компилятора, никаких зависимостей в рантайме. Контейнер с Go-бинарником стартует за миллисекунды, а не за секунды, как со Spring Boot. Для бессерверных контейнеров, где контейнер может подниматься на каждый запрос, это критично.

YDB вместо PostgreSQL: экономика

Дальше — СУБД. Да, в Яндексе есть Serverless PostgreSQL, но для меня это была слишком большая стоимость. Поэтому решил попробовать YDB. Быстро прочитал статьи, как стартануть, и вот я уже пишу первые SQL-запросы. Выглядело очень сладко — 1 млн магических Request Units в месяц бесплатно. Я не сделал большого исследования (о чём впоследствии пожалел, но не очень сильно) и не вдавался в то, как же на самом деле они считаются. Даже до сих пор это немного магия для меня, с учётом прочитанных статей и документации.

В этой статье я опишу, с чем сталкивался как Java-разработчик и как решал проблемы. Не претендую на то, что все решения оптимальны и соответствуют лучшим практикам этих технологий, но надеюсь, что статья будет полезна людям, которые будут использовать похожий стек.

Архитектура

Всё максимально просто и не добавляет дополнительной стоимости. Фронтенд — Next.js со статической генерацией, лежит в Object Storage и отдаётся напрямую. Бэкенд — Go в Docker-контейнере на Serverless Containers. База — YDB Serverless. Браузер загружает статику из Object Storage, а API-запросы отправляет в Serverless Container. Без API Gateway, без лишних прослоек.

Первые шаги с Go и YDB

И вот я уже пишу SQL, используя Go и YDB. Да, после того как долгое время используешь JPA и Repository, переходить на чистое написание и проектирование DB-слоя кажется немного странным. Но ничего — было время, когда я писал на JDBC и jOOQ, так что быстро справился.

Создаю слой DB-сущностей, для них — репозитории, где использую YQL. С первыми запросами пришлось повозиться: не получалось написать с первого захода, но используя примеры с Яндекса, всё-таки преодолел.

Было непривычно делать такие вещи — например, для колонки перечислений. После Java, где enum просто работает из коробки с JPA, в Go приходится писать руками:

type StageType string

const (
    Lowercase   StageType = "lowercase"
    Uppercase   StageType = "uppercase"
    Punctuation StageType = "punctuation"
    Numbers     StageType = "numbers"
)

А затем добавлять преобразование для сканирования из базы и обратно:

func (s *StageType) Scan(value interface{}) error {
    return ScanStringEnum(s, value, "StageType")
}

func (s *StageType) Value() (driver.Value, error) {
    return string(*s), nil
}

В JPA тоже есть возможность кастомизировать маппинг enum’ов, но если строковое представление совпадает с именем — это просто работает из коробки.

Методы в репозиториях получаются примерно такие — покажу на упрощённом примере:

func (r *ItemRepository) CreateItem(ctx context.Context, item Item) (*Item, error) {
    var result *Item
    err := r.txManager.DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
        res, err := tx.Execute(ctx, `
            DECLARE $id AS Utf8;
            DECLARE $name AS Utf8;
            DECLARE $status AS Utf8;
            DECLARE $created_at AS Datetime;

            INSERT INTO items (id, name, status, created_at)
            VALUES ($id, $name, $status, $created_at)
            RETURNING id, name, status, created_at;
        `,
            table.NewQueryParameters(
                table.ValueParam("$id", types.UTF8Value(item.Id)),
                table.ValueParam("$name", types.UTF8Value(item.Name)),
                table.ValueParam("$status", types.UTF8Value(string(item.Status))),
                table.ValueParam("$created_at", types.DatetimeValueFromTime(item.CreatedAt)),
            ),
        )
        if err != nil {
            return fmt.Errorf("failed to execute CreateItem: %w", err)
        }
        defer res.Close()

        // ... scan result into &result
        return res.Err()
    })
    return result, err
}

Суть в том, что каждое поле нужно объявить через DECLARE, передать как типизированный параметр и не забыть просканировать при чтении. Добавляешь новую колонку — и начинается: добавь DECLARE, добавь параметр, добавь поле в Scan при чтении, при обновлении, при вставке. После привычного JPA, где ты просто добавляешь поле в Entity и всё подхватывается автоматически, это ощущается как шаг назад. Но зато видишь каждый запрос, который уходит в базу.

Многие ругают JPA и Hibernate за то, что генерируется много запросов и не всегда понятно, как оно работает. Но лично мне разрабатывать приложения с не очень сложной структурой куда приятнее на JPA. Да, если есть узкое место, где именно JPA мешает, стоит переписать на более низком уровне, используя JDBC. Но я не сторонник преждевременной оптимизации — делай простые вещи просто.

Транзакции без @Transactional

Следующая проблема: YDB — распределённая база и не поддерживает foreign keys. Ладно, не проблема — есть транзакции, и YDB позволяет их использовать.

В Spring Boot я бы сделал примерно так:

@Transactional
public User createUser(User user) {
    UserEntity entity = userMapper.toEntity(user);
    entity = userRepository.save(entity);

    ProgressionEntity progression = new ProgressionEntity();
    progression.setUserEntity(entity);
    progressionRepository.save(progression);

    return userMapper.toDto(entity);
}

Повесил @Transactional, вызвал два репозитория — и если что-то упадёт, всё откатится. В Go так не получится.

Первый подход: транзакции внутри каждого метода репозитория. Но тогда нельзя объединить два репозитория в одну транзакцию — каждый коммитит сам по себе.

Второй подход: вынести создание транзакции в сервис, а в репозитории передавать tx как параметр. Работает, но появляются дублирующиеся методы — CreateUser (сам создаёт транзакцию) и CreateUserTx (принимает существующую). Дублирование множится с каждым новым методом.

В итоге я ввёл простой Transaction Manager:

func (t *TransactionManager) DoTx(ctx context.Context, fn func(ctx context.Context, tx table.TransactionActor) error) error {
    if tx, ok := TxFromContext(ctx); ok {
        return fn(ctx, *tx)
    }
    return t.ydb.NativeDriver.Table().DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
        ctx = context.WithValue(ctx, txKey{}, &tx)
        return fn(ctx, tx)
    })
}

Суть: если в контексте уже есть транзакция — переиспользуем её, если нет — создаём новую. Примерно как пропагация транзакций в Spring, только руками. Теперь и сервисы, и репозитории инжектят этот менеджер, дублирование ушло, и можно спокойно объединять несколько операций в одну транзакцию.

IoC и DI без фреймворка

Ещё одна вещь — IoC-контейнер, который в Spring Boot воспринимается как само собой разумеющееся. Навесил @Component, @Autowired — и фреймворк сам разруливает граф зависимостей через рефлексию, проксирование и BeanFactory. В Go ничего этого нет. Захотел заинжектить зависимость — будь добр, добавь параметр в конструктор сам. Все зависимости собираются руками в main.go, в нужном порядке: сначала создаёшь подключение к базе, потом репозитории, потом сервисы, потом хэндлеры. Никакой магии — но зато точно знаешь, что куда идёт, и не получишь циклическую зависимость в рантайме.

Честно, вначале я гуглил что-то типа «Spring Boot and Go transaction» и находил на Reddit и Stack Overflow топики, где спрашивали «есть ли на Go что-то похожее на Spring Boot?», а ответы были из разряда «БОЖЕ УПАСИ». Это, конечно, улыбнуло.

Обработка ошибок

Go, как и Java, не блещет синтаксическим сахаром, и базовый синтаксис достаточно прост. Горутины мне не понадобились — своя конкурентность в приложении попросту не нужна.

Было сложно привыкнуть к обработке ошибок. В Java кинул исключение — и в стектрейсе видишь всю цепочку вызовов: какой файл, какая строка, что вызывало что. В Go ошибки — это просто значения, и стектрейса по умолчанию нет. Получаешь что-то вроде failed to execute query: connection refused — и гадай, из какого метода это прилетело.

В итоге пришёл к подходу с уникальными метками в каждом fmt.Errorf. Каждый уровень оборачивает ошибку со своим контекстом:

func (s *ItemService) ProcessItem(ctx context.Context, id string) error {
    item, err := s.repo.FindById(ctx, id)
    if err != nil {
        return fmt.Errorf("ProcessItem: failed to find item %s: %w", id, err)
    }

    if err := s.repo.UpdateStatus(ctx, item, "processed"); err != nil {
        return fmt.Errorf("ProcessItem: failed to update status for %s: %w", id, err)
    }

    return nil
}

func (r *ItemRepository) FindById(ctx context.Context, id string) (*Item, error) {
    // ...
    if err != nil {
        return nil, fmt.Errorf("ItemRepository.FindById: query failed: %w", err)
    }
    // ...
}

В логах это разворачивается в цепочку: ProcessItem: failed to find item abc123: ItemRepository.FindById: query failed: connection refused. Не полноценный стектрейс, но по уникальным меткам сразу видно, где именно упало. Главное — не лениться и писать осмысленные сообщения, а не просто failed.

Оптимизация Request Units: как я сжёг бесплатный тиер и починил это

Когда я начинал разработку на YDB, я проектировал схему так, как делал бы это с PostgreSQL. Это было ошибкой.

Статистику по буквам я хранил каждую в отдельной строке. Когда приходили данные по уроку, происходило следующее: детальная статистика по каждой клавише вставлялась в одну таблицу, а агрегированные данные по тем же клавишам обновлялись в другой. Всё это в цикле — сколько клавиш пришло, столько INSERT’ов и UPDATE’ов.

Какого же было моё удивление, когда я задеплоил этот код в облако и посмотрел на потребление. Распределённая СУБД не любит таких операций. На практике я увидел, что метод, который обрабатывал результаты одного урока, обходился примерно в 500 RU.

Схематично это выглядело так:

func saveLessonResults(ctx context.Context, lesson Lesson, stats map[string]KeyStats) error {
    return txManager.DoTx(ctx, func(ctx context.Context, tx table.TransactionActor) error {
        // 1. Сохранить сессию урока
        lessonId, err := saveSession(ctx, tx, lesson)

        // 2. INSERT статистики по каждой клавише — в цикле!
        for letter, keyStats := range stats {
            saveSingleLetterStat(ctx, tx, lessonId, letter, keyStats)
        }

        // 3. UPDATE агрегатов по каждой клавише — тоже в цикле!
        for letter := range changedLetters {
            updateSingleLetterAggregate(ctx, tx, letter)
        }

        // 4. UPDATE прогресса пользователя
        updateProgression(ctx, tx, progression)

        return nil
    })
}

Немного о том, как считаются RU в YDB. Для YQL-запросов вычисляются две стоимости — CPU и ввод-вывод — и берётся максимальная. Чтение тарифицируется блоками по 4 КБ: одна операция чтения = 1 RU. Запись — блоками по 1 КБ: одна операция записи = 2 RU. При этом берётся максимум между количеством строк и количеством блоков. Даже маленькая строка — это минимум 2 RU на запись.

Шаг 1: батчи вместо циклов

Классических батчей в YDB нет, но можно передать все данные списком и выполнить один запрос, сэкономив на создании плана выполнения:

// Вместо цикла с отдельными INSERT — один запрос со списком
func saveLetterStatsBatch(ctx context.Context, tx table.TransactionActor, rows []LetterStat) error {
    _, err := tx.Execute(ctx, `
        DECLARE $rows AS List<Struct<
            lesson_id: Int64,
            letter: Utf8,
            correct: Int32,
            speed_wpm: Int32
        >>;

        INSERT INTO letter_stat (lesson_id, letter, correct, speed_wpm)
        SELECT lesson_id, letter, correct, speed_wpm
        FROM AS_TABLE($rows);
    `, buildListParams(rows))
    return err
}

Тот же приём для UPDATE — собираем все изменённые строки в список и делаем UPSERT ... SELECT ... FROM AS_TABLE($rows).

Это уменьшило количество компиляций запросов, но не решило главную проблему — количество добавляемых и изменяемых строк осталось тем же, а каждая строка стоит RU.

Шаг 2: объединить операции в один YQL-запрос

Дальше я объединил INSERT в одну таблицу, UPSERT в другую и UPDATE прогресса — всё в одном YQL-запросе:

func saveLessonWritesBatch(ctx context.Context, tx table.TransactionActor, ...) error {
    _, err := tx.Execute(ctx, `
        DECLARE $detail_rows AS List<Struct<...>>;
        DECLARE $aggregate_rows AS List<Struct<...>>;
        DECLARE $prog_id AS Int32;
        ...

        -- Детальная статистика
        INSERT INTO letter_stat (...)
        SELECT ... FROM AS_TABLE($detail_rows);

        -- Агрегаты
        UPSERT INTO letter_stats (...)
        SELECT ... FROM AS_TABLE($aggregate_rows);

        -- Прогресс
        UPDATE user_progression
        SET target_wpm = $target_wpm, ...
        WHERE id = $prog_id;
    `, params)
    return err
}

Один запрос, один план, одна транзакция. Но строк-то всё равно много — и каждая стоит RU.

Шаг 3: переделать схему — JSON вместо отдельных строк

Морщась, я решил переделать схему. Честно, на PostgreSQL я бы так не стал делать. Но для экономии Request Units пошёл на это.

Там, где раньше каждая буква хранилась отдельной строкой с UPDATE при каждом уроке, я создал одну колонку:

LetterStatsJson string `db:"letter_stats_json"`

Все агрегированные данные по клавишам теперь хранятся в одном JSON-поле. Чтение одной строки — 1 RU (пока данные меньше 4 КБ, это один блок чтения). Запись одной строки с JSON в 3 КБ — 3 блока по 1 КБ = 6 RU. Но это всё равно в разы дешевле, чем обновлять 15 отдельных строк по 2 RU каждая.

Да, это добавило работы в коде: десериализация JSON → проход по нужным клавишам → пересчёт → сериализация обратно → сохранение. Но это миллисекунды по сравнению с тем, сколько стоили отдельные строки в RU.

С историей уроков провернул тот же трюк, но с нюансом. Чтобы чтение укладывалось в один блок (4 КБ), я поставил порог в 3 КБ — выбрал на глаз с запасом, потому что данные в строке не выровнены по границам блоков. Пока JSON меньше 3 КБ — добавляю новую запись в существующую строку. Как только превышает — создаю новую строку с чистым JSON:

func saveLessonSession(ctx context.Context, tx table.TransactionActor,
    progressionId int32, entry SessionEntry) error {
    // Находим последнюю строку
    lastRow := findLatestRow(ctx, tx, progressionId)

    if lastRow != nil && lastRow.JsonSize < maxJsonSize {
        // Добавляем в существующую строку
        sessions := unmarshal(lastRow.SessionsJson)
        sessions = append(sessions, entry)
        updateRow(ctx, tx, lastRow.Id, marshal(sessions))
    } else {
        // Создаём новую строку
        insertRow(ctx, tx, progressionId, marshal([]SessionEntry{entry}))
    }
    return nil
}

Результат

Изначально один урок стоил 500–1000 RU. После всех изменений:

  • Холодный контейнер (без закэшированных планов, первый запрос): 50–70 RU

  • Прогретый контейнер (планы в кэше): 5–10 RU

Разница между холодным и прогретым контейнером объясняется кэшированием планов — YDB не перекомпилирует запрос, если план уже в кэше, а компиляция тратит CPU, который тоже переводится в RU.

Для мониторинга я использовал системную таблицу YDB:

SELECT IntervalEnd, QueryText, Count, SumRequestUnits, MaxRequestUnits
FROM `.sys/query_metrics_one_minute`
ORDER BY IntervalEnd DESC, MaxRequestUnits DESC
LIMIT 50;

Итоги

Я попробовал Yandex Cloud, написал бэкенд на Go, разобрался с YDB и подключил платёжку через Робокассу.

Честно — проект денег не приносит. Похожих тренажёров много, привлечь пользователей непросто, и будут ли они платить — я не знаю. Потратил на это много времени. Но опыт получил: Go с нуля, YDB, бессерверная архитектура, оптимизация под Request Units. На данный момент всё укладывается в бесплатный тир Яндекса.

Я всегда работал наёмным программистом и всегда хотел написать что-то своё, что приносило бы доход. Пока я далёк от этого, но маленький кирпичик положен.

Если интересно попробовать сам тренажёр — typestep.app.