Powrót do strony głównej

Go i YDB Serverless zamiast Spring Boot

Deweloper zmigrował aplikację z Spring Boot i PostgreSQL na Go i YDB Serverless w Yandex Cloud. Opisane wyzwania ręcznego SQL, transakcji, DI i optymalizacji RU dla darmowego tieru.

Go + YDB: mieszczymy się w darmowym tierze Yandex Cloud
Advertisement 728x90

Przejście z Spring Boot na Go i YDB Serverless: doświadczenie migracji trenera pisania głuchym

Rozwój aplikacji typu TypeStep, trenera pisania głuchym, został przeprowadzony przez programistę z doświadczeniem w Java i Spring Boot. Aplikacja została przeniesiona do chmury Yandex Cloud Serverless. Zrezygnowanie z płatnych maszyn wirtualnych i rozwiązań podobnych do ECS doprowadziło do wyboru języka Go dla backendu oraz YDB zamiast PostgreSQL. Efektem było osiągnięcie bezpłatnego limitu przy bardzo szybkich startach kontenerów (milisekundy) i 1 milion jednostek żądań miesięcznie.

Go kompiluje się do statycznego pliku binarnego bez JVM i potrzeby ogrzewania JIT. Kontener uruchamia się natychmiast — kluczowe dla Serverless Containers, gdzie dla każdego żądania może zostać utworzony nowy instancja. Spring Boot z GraalVM wymaga dodatkowej konfiguracji, co nie mieściło się w ograniczonym budżecie.

YDB Serverless zastąpił PostgreSQL ze względu na koszty. Dostępne są 1 milion jednostek RU miesięcznie za darmo, ale obliczanie jednostek nadal pozostaje częściowo niejasne bez szczegółowego przejrzenia dokumentacji.

Google AdInline article slot

Architektura bez zbędnych warstw

Frontend oparty na Next.js z generacją statyczną przechowywany jest w Object Storage. Backend to Go w Dockerze na Serverless Containers. Baza danych to YDB Serverless. Żądania płyną bezpośrednio z przeglądarki: statyka z magazynu, API do kontenerów. Brak API Gateway minimalizuje opóźnienia i koszty.

// Przykład enum dla kolumn YDB

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
}

Po przejściu z JPA do ręcznego projektowania schematów i zapytań YQL odczuwa się regresję. Każde pole wymaga DECLARE, typizowanych parametrów i ręcznego skanowania.

Repozytoria i zapytania YQL

Repozytoria wykorzystują YQL z transakcjami. Przykład tworzenia rekordu:

Google AdInline article slot
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()

        // ... skanowanie wyniku do &result
        return res.Err()
    })
    return result, err
}

Dodanie kolumny wymaga zmian we wszystkich operacjach: INSERT, UPDATE, SELECT. W przeciwieństwie do JPA, gdzie encja aktualizuje się automatycznie.

  • Zalety ręcznego SQL: pełna kontrola nad zapytaniami, brak problemu N+1.
  • Wady: zbyt dużo kodu, ręczne mapowanie typów i enumów.
  • Porównanie z JPA: JPA jest prostsze dla prostych schematów, ale ukrywa szczegóły wykonania.

Transakcje w rozproszonej YDB

YDB nie obsługuje kluczy obcych, ale oferuje transakcje. Bez @Transactional wymagane jest ręczne zarządzanie.

TransactionManager sprawdza kontekst na obecność tx:

Google AdInline article slot
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)
    })
}

To symuluje propagację Spring: nowa transakcja lub ponowne użycie istniejącej. Usuwa powtarzanie metod (Create vs CreateTx).

Usługi i repozytoria wstrzykują menedżera. Wiele operacji w jednej transakcji działa bez problemów.

DI i ręczna kompozycja zależności

Bez Spring IoC zależności są składane w main.go w kolejności: DB → repozytoria → usługi → handlerzy. Brak refleksji, proxy czy cykli czasu wykonania.

Zalety:

  • Jasny graf zależności.
  • Brak magii i niespodziewanych błędów.
  • Łatwy refaktoryzacja.

Wady: ręczny boilerplate dla każdego komponentu.

Obsługa błędów z kontekstem

Błędy w Go to wartości bez śladów stosu. Rozwiązanie: unikalne prefiksy w 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)
    }
    // ...
}

W logach: ProcessItem: failed to find item abc123: ItemRepository.FindById: query failed: connection refused. Łańcuch wskazuje dokładne miejsce awarii.

Optymalizacja jednostek RU w YDB

Początkowa struktura kopiuje PostgreSQL: statystyki liter w osobnych wierszach, INSERT/UPDATE w pętli po klawiszach. To zużywa duże ilości jednostek RU.

Optymalizacje:

  • Agregacja danych po stronie klienta przed wstawieniem.
  • Batchowanie operacji.
  • Przegląd indeksów i partycjonowania.

Po poprawkach mieszczą się w bezpłatnym limicie.

Co jest ważne

  • Go zapewnia chłodne starty <1 sekundy dla Serverless Containers, w porównaniu do sekund w Spring Boot.
  • YDB wymaga ręcznego YQL i DECLARE-parametrów; upraszczaj schematy, aby oszczędzić jednostki RU.
  • TransactionManager symuluje @Transactional bez duplikowania kodu.
  • Ręczna DI jest bardziej przejrzysta niż Spring, ale wymaga dyscypliny w main.go.
  • Błędy otaczaj prefiksami dla lepszej czytelności logów.

— Editorial Team

Advertisement 728x90

Czytaj dalej