Дилемма блокировки: когда sync.Mutex уже недостаточно
Вы знаете это чувство, когда осознаёте, что ваш драгоценный мьютекс в процессе уже не справляется? Да, мы все бывали в такой ситуации. Ваши предположения об однопотоковом выполнении работали нормально, пока ваша система не решила вырасти и стать распределённой. Вдруг у вас появляется несколько сервисов, работающих на разных машинах, все пытаются получить доступ к одному и тому же ресурсу, а ваш sync.Mutex сидит и выглядит растерянным — потому что он блокирует только внутри одного процесса.
Добро пожаловать в мир распределённых блокировок, где координация доступа между несколькими машинами больше похожа не на швейцарские часы, а на попытку собрать кошек, которые не говорят на одном языке. Но не волнуйтесь — ZooKeeper здесь, чтобы стать вашим специалистом по сбору кошек.
Что делает распределённые блокировки такими особенными?
Прежде чем мы углубимся в код, давайте поговорим о том, что делает распределённую блокировку принципиально отличной от её одномашинного аналога. Когда у вас есть несколько сервисов на разных машинах, которые хотят получить эксклюзивный доступ к одному и тому же ресурсу, вам нужна система, которая гарантирует:
- Взаимное исключение: только один процесс держит блокировку в любой момент времени (без гонок условий, точка)
- Предотвращение тупиковых ситуаций: если процесс падает, удерживая блокировку, блокировка автоматически освобождается
- Справедливый порядок: сервисы должны получать блокировки в том порядке, в котором они их запрашивали (или хотя бы иметь предсказуемый порядок)
- Устойчивость к сбоям: механизм блокировок выживает при сетевых разделах и сбоях сервисов
Локальная блокировка мьютекса? Она сломается в тот момент, когда ваш процесс умрёт или сеть даст сбой. ZooKeeper? Он был буквально создан для таких кошмаров.
Магия ZooKeeper: эфемерные последовательные узлы
Вот где всё становится интереснее. ZooKeeper реализует распределённые блокировки с помощью обманчиво простого, но блестящего механизма: эфемерных последовательных узлов. Позвольте мне объяснить:
Эфемерные узлы — это временные узлы, которые автоматически исчезают, когда клиент, создавший их, отключается. Это элегантно решает проблему «зависшей блокировки при сбое процесса». Не нужно таймаутов или заданий по очистке — блокировка просто исчезает.
Последовательные узлы — это узлы с автоматически увеличивающимися суффиксами. Когда вы создаёте /locks/lock-, ZooKeeper даёт вам /locks/lock-0. Следующий получит /locks/lock-1 и так далее. Это создаёт естественную очередь.
Протокол обманчиво элегантный:
- Клиент A создаёт /locks/lock- → Получает /locks/lock-0 и захватывает блокировку
- Клиент B создаёт /locks/lock- → Получает /locks/lock-1 и ждёт
- Клиент C создаёт /locks/lock- → Получает /locks/lock-2 и ждёт ещё терпеливее
- Когда Клиент A освобождает блокировку, Клиент B автоматически уведомляется и захватывает её
- Когда Клиент B заканчивает, Клиент C получает свою очередь
Это похоже на идеально организованную систему очереди в ресторане, за исключением того, что ваш ресторан распределён по нескольким центрам обработки данных.
Обзор архитектуры
Прежде чем мы напишем фактический код, давайте визуализируем, как работает вся эта система:
ЗАХВАЧЕНО| 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 не волнует, если вы держите её вечно. Это тайм-аут для получения блокировки. Это на самом деле хорошо — это предотвращает прерывание держателя блокировки. Но будьте дисциплинированы в освобождении блоки
