分类
devops

Fencing and lease

分布式锁中的 Fencing:防止过期锁的幽灵写入

引言

在分布式系统中,锁是保护共享资源的关键机制。但仅有锁还不够——我们需要确保已过期的锁持有者即使恢复后也无法继续修改资源。这就是 Fencing 要解决的问题。

本文将深入探讨分布式锁中的 Fencing 机制,以及它与 Lease Time 的关系。


第一部分:为什么需要 Fencing?

分布式锁中的幽灵写入问题

想象一个看似合理的场景:

  1. 客户端 A 成功获得了一个锁
  2. 由于网络延迟或 JVM 的垃圾回收暂停,客户端 A 停止响应(但没有显式释放锁)
  3. 锁服务器认为 A 已经”死了”,将锁分配给客户端 B
  4. B 开始修改共享资源
  5. A 突然恢复了! 它仍然认为自己拥有锁,也开始修改资源

灾难后果

时间轴:
T0:     A 获得锁 → 开始修改资源
T10:    A 宕机(网络分割)
T15:    锁服务器收回锁,给 B 分配
T20:    B 开始修改资源
T25:    A 突然恢复!仍然认为有锁
T30:    A 和 B 同时修改资源
        ↓
     数据损坏、不一致、丢失 ❌

这种情况被称为 “幽灵写入”(Phantom Write) 或 “裂脑”(Split Brain)。仅靠 Lease Time(租约时间)无法完全防止这种情况。


第二部分:什么是 Fencing?

核心定义

Fencing 是一种防止已过期的锁持有者继续访问共享资源的安全机制。 它通过强制”围栏”来隔离过期的客户端,确保只有当前有效的锁持有者才能操作资源。

关键思想

与其被动地等待锁过期,Fencing 采取主动防御的策略:在资源侧添加验证机制,拒绝任何来自过期锁持有者的操作。


第三部分:Fencing 的三种实现方式

1. 基于 Token/Epoch 的 Fencing

这是最常见和最灵活的 Fencing 实现方式。

原理: 为每次锁授予附加一个单调递增的令牌(Token)或纪元号(Epoch)。资源在接收操作时验证 Token 的有效性。

工作流程:

锁服务器:
  - 给 A 分配锁,Token = 1
  - A 失联,收回锁
  - 给 B 分配锁,Token = 2(更新的 Token)

A 的操作:
  "修改文件,Token = 1"  ← 资源对比版本号
  资源侧检查:Token 1 < 当前 Token 2
  结果:操作被拒绝 ✗

B 的操作:
  "修改文件,Token = 2"  ← 资源对比版本号
  资源侧检查:Token 2 = 当前 Token 2
  结果:操作被接受 ✓

代码示例(Python):

import uuid

class DistributedLock:
    def __init__(self, resource_id):
        self.resource_id = resource_id
        self.current_token = None
        self.current_version = 0

    def acquire(self, client_id):
        """客户端获取锁"""
        self.current_token = str(uuid.uuid4())
        self.current_version += 1
        return {
            'token': self.current_token,
            'version': self.current_version,
            'lease_time': 30  # 秒
        }

    def write(self, token, version, data):
        """资源层的 Fencing 检查"""
        if version < self.current_version:
            raise FencingException(
                f"Token 已过期: {version} < {self.current_version}"
            )
        if token != self.current_token:
            raise FencingException("Token 不匹配")
        # 执行真正的写入
        self.perform_write(data)

优点:
– 实现简单,应用层可控
– 不依赖系统时钟的精确性

缺点:
– 需要资源侧充分配合
– 资源必须维护当前的 Token/版本号


2. 基于租约的 Fencing

这种方式结合了 Lease Time 和 Fencing 的思想,由锁服务器维护租约的生命周期。

原理: 不仅仅有 Lease Time 的过期机制,还为每个租约分配一个唯一的 ID。资源验证请求的租约 ID 是否仍然有效。

工作流程:

T0:    客户端 A 获得租约
       lease_id = 100, TTL = 30秒

T15:   A 续期(保持 lease_id = 100 活跃)

T20:   A 宕机,无法续期

T35:   租约 100 过期,锁服务器撤回

T40:   客户端 B 获得新租约
       lease_id = 101, TTL = 30秒

T45:   A 恢复,尝试写入数据
       请求携带:lease_id = 100
       资源检查:lease_id 100 已过期
       结果:拒绝 ✓

常见实现:
– etcd:通过 Lease 机制实现
– ZooKeeper:通过 Session 实现
– Chubby:Google 内部系统

代码示例(etcd 风格):

import etcd3

client = etcd3.client()

# A 获得租约
lease_a = client.lease(30)  # 30秒 TTL
lease_a.grant()
client.put('lock', 'clientA', lease=lease_a)

# A 定期续期
lease_a.refresh()

# A 宕机后,租约自动过期
# B 获得新租约
lease_b = client.lease(30)
lease_b.grant()
client.put('lock', 'clientB', lease=lease_b)

# A 恢复后的操作会失败(因为 lease_a 已过期)

优点:
– 自动失效,无需外部干预
– 锁服务器和资源层都有防护

缺点:
– 需要锁服务器支持租约机制
– 客户端需要定期续期


3. 基于资源层的 Fencing(硬件级)

这是最强大但最复杂的 Fencing 实现,在资源硬件层实现防护。

数据库行锁示例

-- 在数据库中记录锁的所有者、版本号和时间戳
CREATE TABLE resource_locks (
    resource_id INT PRIMARY KEY,
    owner VARCHAR(255),
    lock_version INT,
    updated_at TIMESTAMP
);

-- 初始状态:资源被 A 锁定
INSERT INTO resource_locks VALUES (1, 'clientA', 1, NOW());

-- 30秒后,A 未续期,B 获得新锁
UPDATE resource_locks SET 
    owner = 'clientB',
    lock_version = 2,
    updated_at = NOW()
WHERE resource_id = 1 AND lock_version = 1;

-- A 恢复后,仍然尝试更新(使用旧的 lock_version = 1)
UPDATE resource_locks SET value = 'newValue'
WHERE resource_id = 1 AND lock_version = 1;
-- 结果:0 行受影响(Fencing!)✓

NFS/块存储硬件级示例

某些 NFS 服务器和 iSCSI 目标支持 SCSI 3 Persistent Reservations:

- 每个"预留"分配一个唯一的代理令牌
- 只有持有最新令牌的客户端可以写入磁盘块
- 过期客户端的操作被硬件级拒绝
- 即使客户端是恶意的,硬件也会拒绝

示例命令:
# 客户端 A 注册预留
sg_persist --out --register-ignore --param-sgt=1 \
  --param-res-key=0x1234567890abcdef /dev/sda

# 30秒后,A 未续期,客户端 B 注册新预留
# 硬件自动撤销 A 的写入权限

优点:
– 最强的安全保证
– 硬件级防护,无法绕过
– 即使客户端是恶意的也无效

缺点:
– 需要特殊硬件支持
– 配置和调试复杂
– 性能开销较大


第四部分:Fencing vs. Lease Time

核心区别

这两个概念紧密相关但层次不同:

维度 Lease Time Fencing
是什么 锁的有效期限(时间属性) 防止过期锁被使用的机制(安全策略)
作用层 锁服务层 资源层
保证类型 时间保证(”30秒后失效”) 逻辑隔离(”无法操作”)
是否充分 ❌ 不充分 ✅ 充分
防御目标 防止永久占用 防止幽灵写入

为什么 Lease Time 不充分?

Lease Time 的问题:仅靠时间无法防止已恢复的客户端

时间线:
T0:    A 获得锁,Lease Time = 30秒
       A 开始修改资源(位置 X)

T10:   A 宕机(但锁还有效)

T15:   锁服务器给 B 分配锁
       B 开始修改资源(位置 X)

T20:   A 恢复,仍然认为自己有锁
       A 继续修改资源(位置 X)

T30:   A 的 Lease 过期(太晚了!)
       A 和 B 已经同时修改了 20 秒

结果:数据损坏 ❌

为什么会这样? 因为 Lease Time 只是通知锁服务器”不要再给这个锁续期”,但无法告诉资源侧“拒绝这个客户端的操作”。

两者的关系:互补

Lease Time + Fencing = 完整的安全保证

┌────────────────────────────────────────┐
│  第一道防线:Lease Time(时间维度)     │
│  - 自动收回超期锁                       │
│  - 防止锁被永久占用                     │
│  - 保证系统最终恢复                     │
└────────────────────────────────────────┘
                  +
                  |
┌────────────────────────────────────────┐
│  第二道防线:Fencing(逻辑维度)        │
│  - 验证操作的合法性                     │
│  - 拒绝过期的操作                       │
│  - 防止幽灵写入                         │
└────────────────────────────────────────┘
                  =
                  ↓
        完整的分布式锁保证

第五部分:实际案例对比

案例 1:只有 Lease Time(危险)

import redis
import time

r = redis.Redis()

# 设置锁,10 秒过期
r.set('lock:resource1', 'clientA', ex=10)

# clientA 开始修改资源
current_value = int(r.get('resource1') or 0)  # 读取当前值 = 10

# 模拟 clientA 宕机
time.sleep(3)

# clientB 获得锁
r.set('lock:resource1', 'clientB', nx=True, ex=10)

# clientA 恢复,继续修改(仍然用旧值 10)
new_value = current_value + 1  # 11
r.set('resource1', new_value)  # ❌ 幽灵写入!

# clientB 也修改资源
b_value = int(r.get('resource1') or 0)  # 读到 11
r.set('resource1', b_value + 100)  # 111

# 预期:10 + 1 + 100 = 111 ✓
# 实际:可能因为读写顺序混乱导致错误 ❌

问题: 两个客户端并发修改,导致数据不一致。


案例 2:Token Fencing(安全)

import redis
import uuid

r = redis.Redis()

class FencedLock:
    def __init__(self, resource_id):
        self.resource_id = resource_id

    def acquire(self):
        """获取锁"""
        token = str(uuid.uuid4())
        version = r.incr(f'lock:{self.resource_id}:version')
        r.set(f'lock:{self.resource_id}:token', token, ex=30)
        return {'token': token, 'version': version}

    def write(self, token, version, new_value):
        """写入(带 Fencing 检查)"""
        # 检查 Token 是否过期
        current_version = int(r.get(f'lock:{self.resource_id}:version') or 0)
        current_token = r.get(f'lock:{self.resource_id}:token')

        if version < current_version:
            raise Exception(f"Token 过期: {version} < {current_version}")

        if str(current_token, 'utf-8') != token:
            raise Exception("Token 不匹配")

        # 执行写入
        r.set(f'resource:{self.resource_id}', new_value)

# 使用案例
lock = FencedLock('resource1')

# clientA 获得锁
lock_a = lock.acquire()  # token='abc-123', version=1
print(f"A 获得锁: {lock_a}")

# clientA 读取资源
current = int(r.get('resource:resource1') or 0)  # 10

# 模拟 clientA 宕机
time.sleep(1)

# clientB 获得锁
lock_b = lock.acquire()  # token='xyz-789', version=2
print(f"B 获得锁: {lock_b}")

# clientB 修改资源
lock.write(lock_b['token'], lock_b['version'], 20)  # ✓ 成功

# clientA 恢复,尝试写入旧值
try:
    lock.write(lock_a['token'], lock_a['version'], current + 1)
    print("A 写入成功")
except Exception as e:
    print(f"A 写入被拒绝: {e}")  # ✓ Token 过期被拒绝!

# 最终资源值
final = int(r.get('resource:resource1') or 0)
print(f"最终资源值: {final}")  # 20(只有 B 的修改被保留)

结果: A 的幽灵写入被成功阻止!✓


第六部分:Fencing 在开源系统中的应用

etcd 的实现

etcd 使用 Lease + Session 机制实现了天然的 Fencing:

# 创建带 TTL 的 Lease
etcdctl lease grant 30
# 返回:lease 694d5765fc0d9106 granted with TTL(30s)

# 在 Lease 下存储数据
etcdctl put /lock/resource1 "clientA" --lease=694d5765fc0d9106

# 客户端定期续期
etcdctl lease keep-alive 694d5765fc0d9106

# 如果客户端宕机,租约自动过期
# 新客户端获得新的 Lease ID,自动形成 Fencing

原理: etcd 的 Lease 机制本质上就是 Token Fencing——每个 Lease 都有唯一 ID,资源侧验证请求是否来自有效的 Lease。


ZooKeeper 的实现

ZooKeeper 通过 Session 实现类似的机制:

ZooKeeper zk = new ZooKeeper("localhost:2181", 30000, event -> {});

// 创建临时节点(自动关联 Session)
String lockPath = zk.create(
    "/locks/resource1",
    "clientA".getBytes(),
    ZooDefs.Ids.OPEN_ACL_UNSAFE,
    CreateMode.EPHEMERAL  // 临时节点
);

// 如果客户端宕机,Session 自动失效
// 节点自动删除,其他客户端可以获得锁
// 这形成了自然的 Fencing

关键机制: 临时节点 + Session Timeout = 自动的 Fencing 隔离。


Redis 的限制

Redis 原生缺乏硬 Fencing 机制,社区推荐使用 Redlock 算法:

from redlock import RedLock

# 获得锁(带 Token)
lock = RedLock("lock:resource1", connection_pool=redis_pool)
lock.acquire()  # 获得唯一 Token

# 执行操作
do_something()

# 释放锁(必须验证 Token 匹配)
lock.release()  # 内部检查 Token

问题: Redis 无法在资源侧强制 Fencing,只能靠应用层遵守协议。


第七部分:最佳实践

选择 Fencing 策略的决策树

┌─ 你的系统有多关键?
│
├─ 超关键(金融、医疗)
│  └─→ 使用硬件级 Fencing(SCSI 预留、数据库约束)
│      + 租约超时(Lease Time)
│      成本:高,但安全性最强 ✓
│
├─ 重要(分布式数据库、缓存)
│  └─→ 使用 Token/Epoch Fencing
│      + 自动续期机制(etcd/ZooKeeper)
│      成本:中等,安全性足够 ✓
│
└─ 普通(缓存失效、任务调度)
   └─→ 使用简单 Lease Time
       + 幂等操作设计
       成本:低,需注意边界情况

实现 Fencing 的 4 个原则

1. 幂等性设计

# 即使操作重复,也应该得到相同结果
def transfer_money(account, amount, token):
    # 检查是否已经执行过
    if already_executed(token):
        return previous_result(token)

    # 执行操作并记录 token
    result = do_transfer(account, amount)
    record_execution(token, result)
    return result

2. Token 强一致性

# 资源侧必须原子地检查 Token
def write_resource(token, version, data):
    with atomic_transaction:
        if current_version > version:
            raise FencingException()
        perform_write(data)
        increment_version()

3. 定期续期机制

# 客户端在工作期间持续续期
def do_critical_work(lock):
    refresher = start_background_thread(lock.refresh, interval=5)
    try:
        perform_work()
    finally:
        refresher.stop()
        lock.release()

4. 监控和告警

# 追踪 Fencing 事件
metrics.counter('fencing.rejected').inc()
logger.warn(f"Fencing 触发: token={old_token}, version={old_version}")
alert(f"客户端幽灵写入被防止")

第八部分:常见陷阱

❌ 陷阱 1:依赖系统时钟

# 错误:用时间戳作为 Fencing 依据
def bad_fencing(lock_time):
    if time.time() - lock_time > 30:
        reject()  # ❌ 系统时钟可能被篡改或跳跃

正确做法: 使用单调递增的序列号或版本号,不依赖绝对时间。


❌ 陷阱 2:忘记验证资源侧

# 错误:只在客户端检查 Token
class BadLock:
    def check(self, token):
        if token == self.valid_token:  # 客户端侧
            return True  # ❌ 无法防止直接修改资源

# 正确:资源侧必须验证
class GoodLock:
    def write(self, token, data):
        # 资源侧检查 ✓
        if not self.is_valid_token(token):
            raise FencingException()
        self.perform_write(data)

❌ 陷阱 3:Lease Time 过长

# 错误:Lease = 1 小时
r.set('lock', 'clientA', ex=3600)

# 如果客户端宕机,其他人要等 1 小时才能获得锁
# 系统不可用 ❌

# 正确:Lease = 30 秒 + 自动续期
r.set('lock', 'clientA', ex=30)
start_background_refresh(lease, interval=10)

❌ 陷阱 4:缺少错误处理

# 错误:假设 Token 总是有效
def write_data(token, data):
    self.resource.write(data)  # ❌ Fencing 异常未处理

# 正确:捕获并重试
def write_data_safe(token, data):
    try:
        self.resource.write(token, data)
    except FencingException:
        logger.error("失去锁的所有权,立即停止")
        self.stop_work()
    except Exception as e:
        logger.error(f"写入失败: {e}")
        raise

总结

概念 定义 使用场景
Lease Time 锁的时间有效期 防止永久占用
Token Fencing 单调递增的版本号 应用层实现,简单场景
Lease-based Fencing 租约 ID 隔离 etcd/ZooKeeper,可靠系统
硬件级 Fencing 资源硬件验证 金融/医疗,最高安全性

核心要点

  1. Fencing 是必要的:仅靠 Lease Time 无法防止幽灵写入
  2. 两层防护最佳:Lease Time(被动恢复)+ Fencing(主动隔离)
  3. 资源层验证最关键:Token 必须在资源侧强制验证
  4. 幂等性是基础:即使操作重复也应该安全
  5. 选择合适的实现:根据系统关键性而定

参考资源

  • Martin Kleppmann 的文章:《How to do distributed locking》
  • etcd 官方文档:Lease API
  • ZooKeeper 官方文档:Sessions and Watches
  • Google Chubby 论文:《The Chubby lock service for loosely-coupled distributed systems》