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