Дилемма блокировки: когда sync.Mutex уже недостаточно

Вы знаете это чувство, когда осознаёте, что ваш драгоценный мьютекс в процессе уже не справляется? Да, мы все бывали в такой ситуации. Ваши предположения об однопотоковом выполнении работали нормально, пока ваша система не решила вырасти и стать распределённой. Вдруг у вас появляется несколько сервисов, работающих на разных машинах, все пытаются получить доступ к одному и тому же ресурсу, а ваш sync.Mutex сидит и выглядит растерянным — потому что он блокирует только внутри одного процесса.

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

Что делает распределённые блокировки такими особенными?

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

  • Взаимное исключение: только один процесс держит блокировку в любой момент времени (без гонок условий, точка)
  • Предотвращение тупиковых ситуаций: если процесс падает, удерживая блокировку, блокировка автоматически освобождается
  • Справедливый порядок: сервисы должны получать блокировки в том порядке, в котором они их запрашивали (или хотя бы иметь предсказуемый порядок)
  • Устойчивость к сбоям: механизм блокировок выживает при сетевых разделах и сбоях сервисов

Локальная блокировка мьютекса? Она сломается в тот момент, когда ваш процесс умрёт или сеть даст сбой. ZooKeeper? Он был буквально создан для таких кошмаров.

Магия ZooKeeper: эфемерные последовательные узлы

Вот где всё становится интереснее. ZooKeeper реализует распределённые блокировки с помощью обманчиво простого, но блестящего механизма: эфемерных последовательных узлов. Позвольте мне объяснить:

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

Последовательные узлы — это узлы с автоматически увеличивающимися суффиксами. Когда вы создаёте /locks/lock-, ZooKeeper даёт вам /locks/lock-0. Следующий получит /locks/lock-1 и так далее. Это создаёт естественную очередь.

Протокол обманчиво элегантный:

  1. Клиент A создаёт /locks/lock- → Получает /locks/lock-0 и захватывает блокировку
  2. Клиент B создаёт /locks/lock- → Получает /locks/lock-1 и ждёт
  3. Клиент C создаёт /locks/lock- → Получает /locks/lock-2 и ждёт ещё терпеливее
  4. Когда Клиент A освобождает блокировку, Клиент B автоматически уведомляется и захватывает её
  5. Когда Клиент B заканчивает, Клиент C получает свою очередь

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

Обзор архитектуры

Прежде чем мы напишем фактический код, давайте визуализируем, как работает вся эта система:

graph TD A[Сервис A] -->|Create /locks/lock-| ZK[ZooKeeper Ensemble] B[Сервис B] -->|Create /locks/lock-| ZK C[Сервис C] -->|Create /locks/lock-| ZK ZK -->|/locks/lock-0
ЗАХВАЧЕНО| A ZK -->|/locks/lock-1
ОЖИДАНИЕ| B ZK -->|/locks/lock-2
ОЖИДАНИЕ| C A -->|Освободить блокировку| ZK ZK -->|Узел удалён
ЗАХВАЧЕНО| B ZK -->|Уведомление| C B -->|Освободить блокировку| ZK ZK -->|Узел удалён
ЗАХВАЧЕНО| C

Настройка вашего Go проекта

Давайте приступим. Сначала создайте новый Go проект и получите клиент ZooKeeper:

go get github.com/samuel/go-zookeeper/zk

Это стандартный клиент Go для ZooKeeper. Он проверен в бою и используется в производственных системах по всему миру. Альтернативы? Скажем так, вы будете благодарны мне за рекомендацию этого клиента.

Создание вашего первого распределённого локера

Вот реализация для производства, которая обрабатывает крайние случаи и условия ошибок, которые неизбежно возникнут в производстве:

package lock
import (
	"fmt"
	"sort"
	"time"
	"github.com/samuel/go-zookeeper/zk"
)
type DistributedLocker struct {
	conn      *zk.Conn
	basePath  string
	lockPath  string
	timeout   time.Duration
	sessionID string
}
// NewDistributedLocker создаёт новый экземпляр распределённого локера
func NewDistributedLocker(hosts []string, basePath string, 
	lockTimeout time.Duration) (*DistributedLocker, error) {
	// Подключение к ZooKeeper ensemble
	conn, _, err := zk.Connect(hosts, 10*time.Second)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to ZooKeeper: %w", err)
	}
	// Убедиться, что базовый путь существует (создать, если нужно)
	exists, _, err := conn.Exists(basePath)
	if err != nil {
		conn.Close()
		return nil, fmt.Errorf("failed to check path existence: %w", err)
	}
	if !exists {
		// Создать путь с пустыми данными - это просто инфраструктура
		_, err = conn.Create(basePath, []byte{}, 0, zk.WorldACL(zk.PermAll))
		if err != nil && err != zk.ErrNodeExists {
			conn.Close()
			return nil, fmt.Errorf("failed to create lock path: %w", err)
		}
	}
	return &DistributedLocker{
		conn:      conn,
		basePath:  basePath,
		timeout:   lockTimeout,
		sessionID: fmt.Sprintf("%d", time.Now().UnixNano()),
	}, nil
}

Реальное использование: объединение всего вместе

Вот как вы можете использовать это в своём приложении. Обратите внимание, как это выглядит почти идентично использованию обычного sync.Mutex — за исключением того, что это работает по всему интернету:

package main
import (
	"fmt"
	"log"
	"time"
	"yourmodule/lock"
)
func main() {
	// Конфигурация
	zkHosts := []string{"localhost:2181"}
	basePath := "/my-app/locks"
	lockTimeout := 30 * time.Second
	// Создать локер
	locker, err := lock.NewDistributedLocker(zkHosts, basePath, lockTimeout)
	if err != nil {
		log.Fatalf("Failed to create locker: %v", err)
	}
	defer locker.Close()
	// Использовать как обычный мьютекс
	if err := locker.Lock(); err != nil {
		log.Fatalf("Failed to acquire lock: %v", err)
	}
	defer locker.Unlock()
	// Критическая секция - только один сервис здесь одновременно
	fmt.Println("Блокировка получена! Выполнение критической работы...")
	criticalOperation()
	fmt.Println("Работа завершена, освобождение блокировки")
}
func criticalOperation() {
	// Имитация некоторой работы, которая должна быть эксклюзивно доступна
	time.Sleep(2 * time.Second)
}

Нюансы и как их избежать

После работы с распределёнными блокировками в производстве (и поверьте, производство находит все крайние случаи), вот настоящие подводные камни:

1. Потеря сеанса — ваш враг

Если ваш клиент теряет соединение с ZooKeeper дольше, чем тайм-аут сеанса, игра окончена. Ваш эфемерный узел удаляется, и блокировка освобождается — но ваш процесс может ещё не знать об этом. Вот почему у вас всегда должен быть мониторинг сердцебиения:

// Мониторинг состояния соединения
go func() {
	for {
		select {
		case event := <-locker.conn.SessionEvent():
			if event == zk.StateDisconnected {
				log.Println("Потеряно соединение с ZooKeeper!")
				// Обработать переподключение или грациозное завершение
			}
		}
	}
}()

2. Тайм-аут блокировки не является реальным тайм-аутом

Параметр lockTimeout, который мы использовали, не ограничивает время удержания блокировки. ZooKeeper не волнует, если вы держите её вечно. Это тайм-аут для получения блокировки. Это на самом деле хорошо — это предотвращает прерывание держателя блокировки. Но будьте дисциплинированы в освобождении блоки