JavaRush /Курсы /Go SELF /Что именно защищаем — state в app, storage и кеше

Что именно защищаем — state в app, storage и кеше

Go SELF
68 уровень , 4 лекция
Открыта

1. Зачем задаваться вопросом «что защищаем»

Когда вы впервые узнали про Mutex, очень хочется сделать универсальное заклинание: «Если страшно — оберни в mu.Lock()». Так иногда даже работает… пока проект маленький. Но как только появляется app-слой, storage-слой и «ой, давайте ещё кеш для скорости», универсальный замок превращается в универсальную причину зависаний, лагов и загадочных дедлоков.

Смысл этой лекции не в том, чтобы выучить ещё один API (его сегодня почти не будет), а в том, чтобы научиться отвечать на три вопроса: какое состояние у нас вообще есть, кто его владелец, и каким примитивом синхронизации его защищать. Потому что Mutex — это не «скрепка для всего», а скорее «ремень безопасности»: пристёгиваться надо, но не к чужой машине и не за шею.

Где живёт state: app, storage и cache

Если упростить, состояние (state) — это всё, что может меняться и на что одновременно могут смотреть/влиять разные goroutine. Но в реальных приложениях состояние обычно расползается по слоям: часть живёт в app (бизнес-логика), часть — в storage (хранение/БД/файлы), часть — в кеше (ускоритель и источник «слегка устаревшей правды»).

Удобно держать в голове такую таблицу — она не «истина в последней инстанции», но хороший компас:

Где живёт state Примеры Что важно защищать Типичный инструмент
App (usecases/service) счётчики запросов, «в полёте» операции, внутренняя карта дедупликации, in-memory очереди бизнес-инварианты и согласованность нескольких полей чаще Mutex, иногда atomic
Storage (repo/dao) map в памяти, файл, соединение к БД, индекс целостность хранилища и последовательность записи обычно Mutex, иногда RWMutex
Cache (ускоритель) кеш задач по id, кеш списка задач, кеш «последний результат» консистентность структуры кеша + политика инвалидции часто RWMutex + местами atomic для метрик

И тут важная мысль: не всё, что называется state, обязано быть общим. Иногда лучший способ «защитить state» — сделать так, чтобы его вообще не разделяли. В духе известной идеи Go “share memory by communicating” (делиться памятью через коммуникацию), когда владение данными у одной goroutine, а остальные общаются через каналы.

Но сегодня мы остаёмся в мире «у нас есть общее состояние, и мы хотим его укротить».

2. State в app-слое: что может ломаться

App-слой обычно воспринимается новичком как «просто функции, которые вызывают storage». И из-за этого его часто оставляют без синхронизации: «ну там же нет map…». А потом внезапно появляется фича «покажи статистику», «не запускай два одинаковых импорта параллельно», «дедуплицируй одинаковые запросы», «считай метрики» — и вот у app-слоя уже есть собственное изменяемое состояние.

Чтобы было конкретнее, будем мысленно развивать наше учебное приложение — менеджер задач (условно назовём его tasker). У нас есть сущность Task и операции «создать», «получить список», «пометить выполненной». HTTP/CLI могут вызывать эти операции параллельно (в HTTP это вообще норма).

Пример app-state: «не запускать две одинаковые операции параллельно»

Представьте, что у нас есть импорт задач из файла. Мы хотим, чтобы два параллельных запроса import одного и того же файла не запускались одновременно (иначе дубли, гонки, странные эффекты). Это чистый app-state: это не storage и не кеш, это бизнес-ограничение «один импорт на файл».

package app

import "sync"

type ImportGuard struct {
	mu       sync.Mutex
	inFlight map[string]bool
}

func (g *ImportGuard) TryStart(name string) bool {
	g.mu.Lock()
	defer g.mu.Unlock()

	if g.inFlight == nil {
		g.inFlight = make(map[string]bool)
	}
	if g.inFlight[name] {
		return false
	}
	g.inFlight[name] = true
	return true
}

Здесь мы защищаем инвариант: «для каждого name либо импорт идёт, либо нет». И этот инвариант касается сразу map и логики проверки/установки — поэтому Mutex, а не atomic.

И обратите внимание: это состояние не должно утекать наружу. Никто не должен получать ссылку на inFlight, иначе мы потеряем смысл защиты.

Пример app-state: быстрые счётчики через atomic

Иногда app-слой хранит очень простые числа: «сколько было запросов», «сколько было ошибок». Это удобно для диагностики. Тут действительно часто уместен atomic, потому что нам не нужен большой инвариант — мы просто увеличиваем счётчик.

package app

import "sync/atomic"

type Metrics struct {
	Requests atomic.Int64
	Errors   atomic.Int64
}

func (m *Metrics) IncRequest() { m.Requests.Add(1) }
func (m *Metrics) IncError()   { m.Errors.Add(1) }

Важно помнить правило: если вы выбрали atomic для переменной — все обращения к ней должны быть через Load/Store/Add. «Один разочек m.Requests++» — это как «один разочек в проде без миграций»: может сработать, но потом будет стыдно.

4. State в storage-слое: где появляются гонки

Storage — это место, где хранится «истина» (или то, что приложение считает истиной). Даже если у вас вместо БД пока что map в памяти или файл на диске, требования примерно одинаковые: запись должна быть последовательной и предсказуемой, чтение не должно видеть полусломанное состояние, а данные не должны «случайно» меняться из-за того, что кто-то удержал ссылку на внутренности.

In-memory storage: map + Mutex и копии наружу

Начнём с простого: хранилище задач в памяти. map[int]Task нельзя читать/писать конкурентно без согласования. Даже «только чтение» опасно, если где-то рядом есть запись.

package storage

import "sync"

type Task struct {
	ID    int
	Title string
	Done  bool
}

type MemStore struct {
	mu    sync.Mutex
	tasks map[int]Task
	next  int
}

Метод добавления задачи должен менять сразу два поля (next и tasks), поэтому это одна критическая секция.

package storage

func (s *MemStore) Create(title string) Task {
	s.mu.Lock()
	defer s.mu.Unlock()

	if s.tasks == nil {
		s.tasks = make(map[int]Task)
		s.next = 1
	}
	t := Task{ID: s.next, Title: title}
	s.tasks[t.ID] = t
	s.next++
	return t
}

Смотрите на это как на кассу в магазине: пока кассир пробивает товар, второй кассир не должен одновременно менять те же цифры на табло.

А теперь ключевой момент: если мы делаем List(), нельзя возвращать наружу что-то, что позволит внешнему коду обойти синхронизацию. С map это очевидно (не отдаём сам map). Со slice часто «неочевидно» — но принцип тот же: отдаём копию.

package storage

func (s *MemStore) List() []Task {
	s.mu.Lock()
	defer s.mu.Unlock()

	out := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		out = append(out, t)
	}
	return out
}

Да, это чуть дороже. Но это цена за то, что storage остаётся хозяином своих данных.

Файловый storage: состояние — это ещё и протокол записи

Когда storage пишет в файл, состояние состоит не только из переменных в памяти. Состояние — это ещё и процесс: открыть файл, записать, закрыть, возможно заменить старую версию новой. Если два параллельных запроса начнут писать в один и тот же файл, можно получить перемешанные байты и файл, который «вроде есть, но его нельзя прочитать».

Поэтому для файлового storage часто делают простое правило: все операции записи сериализованы одним Mutex. И очень желательно не держать этот lock дольше нужного: под lock мы делаем I/O, да, но стараемся не делать под ним лишнюю работу вроде JSON-валидации входных данных (её лучше сделать раньше в app-слое).

5. Cache: ускоряем чтение, но не подменяем истину

Кеш — это как шпаргалка на экзамене. Он ускоряет, но если шпаргалка устарела, можно уверенно написать чушь. Поэтому кеш важно проектировать с пониманием: что является источником истины, а что является производным ускорителем.

В нашем tasker кеш может хранить, например, «последний список задач» и сбрасываться (инвалидироваться) при любой записи (create/done/delete). Тогда чтения будут быстрыми, а записи будут сбрасывать кеш.

Кеш-обёртка над Store: RWMutex + инвалидция

Сделаем интерфейс хранилища (он у нас уже логически существует в проекте), а сверху — кеширующую обёртку:

package app

import "tasker/storage"

type Store interface {
	Create(title string) (storage.Task, error)
	List() ([]storage.Task, error)
	MarkDone(id int) error
}

Кеш:

package app

import "sync"

type CachedStore struct {
	mu          sync.RWMutex
	underlying  Store
	cachedList  []storage.Task
	hasList     bool
}

Чтение списка: под RLock проверяем кеш, при наличии возвращаем копию, чтобы никто не мог «подправить кеш руками».

package app

import "tasker/storage"

func (c *CachedStore) List() ([]storage.Task, error) {
	c.mu.RLock()
	if c.hasList {
		cp := make([]storage.Task, len(c.cachedList))
		copy(cp, c.cachedList)
		c.mu.RUnlock()
		return cp, nil
	}
	c.mu.RUnlock()

	list, err := c.underlying.List()
	if err != nil {
		return nil, err
	}

	c.mu.Lock()
	c.cachedList = list
	c.hasList = true
	c.mu.Unlock()

	return list, nil
}

Да, здесь есть тонкость: мы не держим lock, пока ходим в underlying storage. Это важно: storage может делать I/O, может быть медленным, и держать под это кеш-замок — плохая идея. Мы берём данные «снаружи», а потом аккуратно кладём в кеш.

Запись: после успешной записи инвалидируем кеш. Инвалидировать под Lock, иначе можно оставить полусостояние.

package app

func (c *CachedStore) MarkDone(id int) error {
	if err := c.underlying.MarkDone(id); err != nil {
		return err
	}

	c.mu.Lock()
	c.hasList = false
	c.cachedList = nil
	c.mu.Unlock()

	return nil
}

И вот тут главный концепт: кеш защищает свою структуру (hasList, cachedList), но не должен «влезать» в защиту underlying storage. У storage — свой замок, у кеша — свой.

6. Как выбирать и располагать замки

Формулируем «объекты защиты»

Когда вы думаете «нужно поставить Mutex», попробуйте сформулировать фразу в стиле:

«Я защищаю _____ от _____, чтобы гарантировать _____».

Например:

«Я защищаю MemStore.tasks и MemStore.next от конкурентных записей, чтобы гарантировать, что ID уникальны и map не ломается».

Или:

«Я защищаю CachedStore.cachedList от конкурентного чтения/записи, чтобы гарантировать, что кеш либо полностью валиден, либо полностью инвалидирован».

Эта формулировка помогает понять важную вещь: мы защищаем не код, а смысл (инвариант). И один и тот же Mutex должен охранять один набор связанных смыслов. Если замок «охраняет всё подряд» — вы рано или поздно начнёте держать его во время чего-то долгого и получите очередь на вход.

Где должен жить замок: app vs storage vs cache

Очень хочется сделать так:

var mu sync.Mutex // один на всё!

А потом вызывать mu.Lock() в начале каждого handler’а. Это действительно убирает data race… ценой того, что ваше приложение становится строго однопоточным. Вы как будто купили Go за конкурентность, а потом заклеили все горутины скотчем «не бегать».

Гораздо здоровее мыслить через владельцев состояния:

Storage владеет данными хранения и защищает их своим замком. Cache владеет своим кешем и защищает его своим замком. App владеет своими бизнес-инвариантами и защищает их своим замком/атомиками.

И крайне желательно, чтобы замки не торчали наружу как часть публичного API. Если кто-то снаружи может сделать store.mu.Lock(), вы теряете контроль: внешний код может забыть Unlock(), может держать lock слишком долго, может устроить дедлок, и вы даже не сможете это нормально отлаживать (потому что «это не я, это он» — аргумент слабый).

Схема запроса и порядок lock’ов

Когда приходит HTTP-запрос POST /tasks/{id}/done, примерно происходит вот что:

sequenceDiagram
    participant H as HTTP handler
    participant A as App service
    participant C as Cache wrapper
    participant S as Storage

    H->>A: MarkDone(id)
    A->>A: atomic Requests.Add(1)
    A->>C: MarkDone(id)
    C->>S: MarkDone(id) (storage lock внутри)
    S-->>C: ok
    C->>C: cache lock -> invalidate list
    C-->>A: ok
    A-->>H: 204 No Content

В этой схеме важно, что замки не «пересекаются слоями»: storage делает своё, кеш — своё, app — своё. Так проще соблюдать порядок и не попасть в ситуацию «держу app-lock и жду storage, а storage внутри ждёт что-то, что ждёт app-lock».

Нюанс: defer Unlock и циклы

defer mu.Unlock() — прекрасная привычка, но у неё есть характер: defer выполнится в конце функции, а не «в конце текущей итерации цикла». Из-за этого можно случайно удерживать lock гораздо дольше, чем вы думаете.

Эта ошибка настолько реальная, что её любят ловить в продакшене уже после того, как всё «вроде работало». В одном разборе проблем производительности прямо показана ситуация, где defer внутри цикла приводит к удержанию lock до конца функции и создаёт огромную очередь ожидания.

Мораль тут простая: defer используем, но осознанно. Если lock должен отпускаться на каждой итерации — делаем Unlock() явно или выносим тело итерации в отдельную маленькую функцию, чтобы defer сработал «пораньше».

7. Типичные ошибки

Ошибка №1: путать «источник истины» и кеш.
Часто кеш начинают использовать как хранилище: «а давайте просто менять cachedList, и в storage не ходить». Это быстро приводит к рассинхронизации: разные операции начинают видеть разные версии реальности, а при перезапуске приложения всё внезапно «теряется». Правильная дисциплина такая: storage — это правда, кеш — это ускоритель, который можно выкинуть в любой момент без потери смысла.

Ошибка №2: один Mutex на весь мир.
Глобальный замок кажется простым: гонок нет, можно спать спокойно. Но вы очень быстро замечаете, что приложение «почему-то тормозит», хотя у вас аж goroutine. Причина обычно в том, что вы превратили конкурентную систему в очередь к одному замку. Гораздо лучше разделить состояние по владельцам и охранять каждый смысл своим lock’ом.

Ошибка №3: держать lock, пока вызываете «чужой» код или делаете долгий I/O.
Самая токсичная комбинация — держать Mutex и одновременно ждать чего-то: чтения файла, ответа сети, сообщения из канала. Это прямой путь к дедлокам и «подвисаниям без паники». Гораздо надёжнее брать под lock только то, что нужно для snapshot/обновления, затем отпускать lock и уже потом делать долгие операции.

Ошибка №4: отдавать наружу внутренние структуры и тем самым обходить синхронизацию.
Если storage возвращает ссылку на свой slice или map, внешний код может поменять данные без lock — и вы снова в мире гонок, только уже без явных Lock() в коде, то есть баг становится «магическим». В таких местах лучше возвращать копию (snapshot). Да, это аллокации. Но они дешевле, чем неделя отладки «почему иногда Done=true сам по себе».

Ошибка №5: смешивать atomic и Mutex для одного и того же значения без чёткой причины.
Иногда делают так: «под lock читаю, а инкремент через atomic — быстрее же». Это создаёт очень мутную модель памяти в голове команды: никто не понимает, где правда. Если переменная атомарная — она атомарная всегда. Если переменная часть инварианта — она под Mutex. Микс возможен, но только когда вы можете чётко объяснить, что именно вы гарантируете (и обычно новичкам лучше туда не ходить).

1
Задача
Go SELF, 68 уровень, 4 лекция
Недоступна
Метрики без мьютекса
Метрики без мьютекса
1
Задача
Go SELF, 68 уровень, 4 лекция
Недоступна
Заметки в памяти
Заметки в памяти
1
Задача
Go SELF, 68 уровень, 4 лекция
Недоступна
Страж операций
Страж операций
1
Задача
Go SELF, 68 уровень, 4 лекция
Недоступна
Кеш поверх стора
Кеш поверх стора
1
Опрос
Mutex/RWMutex, 68 уровень, 4 лекция
Недоступен
Mutex/RWMutex
Mutex/RWMutex
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ