分布式锁中的 Fencing:防止过期锁的幽灵写入
引言
在分布式系统中,锁是保护共享资源的关键机制。但仅有锁还不够——我们需要确保已过期的锁持有者即使恢复后也无法继续修改资源。这就是 Fencing 要解决的问题。
本文将深入探讨分布式锁中的 Fencing 机制,以及它与 Lease Time 的关系。
第一部分:为什么需要 Fencing?
分布式锁中的幽灵写入问题
想象一个看似合理的场景:
- 客户端 A 成功获得了一个锁
- 由于网络延迟或 JVM 的垃圾回收暂停,客户端 A 停止响应(但没有显式释放锁)
- 锁服务器认为 A 已经”死了”,将锁分配给客户端 B
- B 开始修改共享资源
- 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 | 资源硬件验证 | 金融/医疗,最高安全性 |
核心要点
- Fencing 是必要的:仅靠 Lease Time 无法防止幽灵写入
- 两层防护最佳:Lease Time(被动恢复)+ Fencing(主动隔离)
- 资源层验证最关键:Token 必须在资源侧强制验证
- 幂等性是基础:即使操作重复也应该安全
- 选择合适的实现:根据系统关键性而定
参考资源
- 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》
