Долгое время я пытался научиться слепому десятипальцевому методу печати, но всегда это заканчивалось поражением. Учился на 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.
