单机上要保证「同一时刻只有一个线程进临界区」,一个 synchronized 或 ReentrantLock 就够了——因为所有线程共享同一块内存、同一个 JVM。可一旦服务部署成多个进程、跨多台机器,这个前提就没了:进程 A 的锁对象和进程 B 的锁对象是两个毫不相干的东西。于是我们需要把「锁」这件事外置到一个所有进程都能访问的公共存储上——这就是分布式锁。
听起来只是「把锁挪到 Redis / ZooKeeper 上」,但真正难的从来不是加锁,而是在进程崩溃、GC 停顿、网络分区、时钟漂移这些异常下,锁还正不正确。这一篇就把 Redis、ZooKeeper、etcd 三条主流路线拆到机制级,讲清它们各自「防住了什么、防不住什么」。
本文是分布式系统系列的第 3 篇。 系列顺序:① 分布式共识(开篇)· Paxos 与 Raft 图解;② ZooKeeper 与 etcd 深挖 · ZAB、Raft、Watch 与租约;③ 分布式锁深挖 · Redis Redlock vs ZooKeeper/etcd(本篇);④ 分布式事务深挖 · 2PC、TCC、Saga 与 Outbox;⑤ 分布式 ID 深挖 · 雪花算法及其变体。
一句话定位:分布式锁不是「把 synchronized 搬到网络上」,而是「在没有共享内存、随时可能崩溃与分区的世界里,尽量逼近互斥」——它永远是正确性、性能与复杂度之间的一场取舍,而不是一个能 100% 保证的原语。
TL;DR
- 四条正确性要求:互斥(任意时刻至多一个持有者)、防死锁(持有者崩了锁必须能释放)、容错(锁服务少数节点挂了仍可用)、可重入(同一持有者可重复加锁)——大多数事故都是某一条没兜住。
- Redis 单实例锁的正确姿势:
SET key uuid NX PX ttl一条命令原子「加锁 + 设超时」;value 必须是唯一标识(谁持有);释放必须走 Lua「比对 value 再 DEL」,否则会误删他人锁。 - 锁续期(看门狗):业务耗时可能 > TTL。Redisson 的 watchdog 默认每
TTL/3把锁续回,业务没跑完锁就不过期;tryLock若不带 watchdog 就要把 TTL 设得足够长,或承担超时被抢的风险。 - Redlock:向 N 个独立 Redis 实例依次加锁,拿到**多数派(≥ N/2+1)**且总耗时 < TTL 才算成功,用多实例抵御单点故障。
- Kleppmann 的质疑:Redlock(以及任何靠 TTL 的锁)扛不住 GC 停顿 / 网络延迟——持有者停顿超过 TTL,锁过期被他人获取,「复活」后仍会写脏。根治靠 fencing token:单调递增的令牌 + 存储侧拒绝陈旧写入,把「互斥」的责任下沉到被保护的资源。
- ZooKeeper 锁:在锁节点下建临时顺序节点,序号最小者持锁;其余各自只 watch 前一个节点 → 避免惊群。防死锁靠临时节点(会话断开自动删),比 TTL 更可靠。
- etcd 锁:
Lease(带 TTL,需KeepAlive续租)+Txn(CAS,比较CreateRevision)实现,按 revision 顺序排队 watch 前驱;etcdctl lock/ concurrency 包已封装好。 - CP vs AP:ZooKeeper(ZAB)、etcd(Raft)是 CP——锁语义强、分区时宁可不可用;Redis 主从异步复制是偏 AP——快、可用性高,但主从切换可能丢锁。要正确性选 CP,要极致性能且能容忍偶发失效选 Redis。
- 什么时候别用分布式锁:能用幂等、乐观锁(版本号/CAS)、单分区串行化(按 key 路由到单线程/单分区)解决的,就别上锁——锁是并发控制的下策,详见 幂等与一致性。
- AdTech 落地:竞价扣减用 Redis 锁没加续期,业务超 TTL 锁自动过期被他人获取 → 两进程同时进临界区 → 重复扣减 / 超投;修复靠看门狗续期 + fencing token + 缩小临界区,最终改成幂等扣减去掉锁。
Table of contents
Open Table of contents
1. 为什么需要分布式锁:四条正确性要求
先把问题定义清楚。单机锁依赖「共享内存 + 操作系统调度」,多进程跨机后这两个前提都不在了,我们要用一个外部存储来协调。但一个够格的分布式锁,必须同时满足四条要求——事故几乎都是其中某一条没兜住:
- 互斥(Mutual Exclusion):任意时刻,至多一个客户端持有锁。这是锁存在的意义。
- 防死锁(Deadlock-free):持有锁的客户端崩溃 / 断网 / 被 kill 后,锁必须能被释放,否则临界区永远进不去。这条决定了「锁一定要有超时或会话绑定」——不能只靠客户端主动
unlock。 - 容错(Fault Tolerance):锁服务本身部分节点故障时,加解锁仍能进行。这条把我们推向「多副本」——单实例 Redis 一挂锁就全没了。
- 可重入(Reentrant):同一持有者(同一线程 / 同一请求链路)可以重复加同一把锁而不自锁死。实现上靠「持有者标识 + 计数器」。
这四条之间是互相拉扯的。比如:为了「防死锁」给锁加 TTL,就引入了「持有者还在干活、锁却过期了」的风险(§2、§3 的主线);为了「容错」上多副本,又要面对「副本间不一致 / 主从切换丢锁」(§6 的 CP vs AP)。没有免费的分布式锁——你总在拿一条要求换另一条。
记住这四条,后面每个方案都可以拿它来「验收」:Redis 单实例满足前两条、容错弱;Redlock 补容错但仍受 TTL 之困;ZooKeeper/etcd 用共识 + 会话把四条都做扎实,代价是更重、更慢。
2. Redis 单实例锁:SET NX + 唯一 value + Lua 释放 + 看门狗
绝大多数团队的分布式锁是从「一台 Redis」开始的。它足够快、足够简单,但每一个细节都有坑。
2.1 加锁:一条 SET 搞定原子性
错误示范是「先 SETNX 再 EXPIRE」两条命令——如果 SETNX 成功后进程崩了,EXPIRE 没执行,这把锁就永不过期,直接违反「防死锁」。正确写法是用一条命令同时完成「不存在才设 + 设超时」:
SET lock:order:42 <uuid> NX PX 30000
NX:key 不存在才设置——保证互斥(只有一个能设成功)。PX 30000:30s 后自动过期——兜「防死锁」,持有者崩了锁也会到期释放。<uuid>:value 必须是当前持有者的唯一标识(如UUID + 线程 ID)。这一步很多人省掉,直接埋下 §2.3 的误删雷。
2.2 释放:为什么必须走 Lua
释放锁的「直觉写法」是 DEL lock:order:42——大错特错。考虑这个交错:
- 客户端 A 加锁成功,TTL 30s;
- A 的业务卡了 31s,锁自动过期;
- 客户端 B 获取到锁(合法,因为 A 的锁已过期);
- A 业务终于跑完,执行
DEL——把 B 的锁删了!
于是 B 还在临界区里,锁却没了,第三个客户端 C 又能进来……互斥彻底崩坏。根因是 DEL 没有校验「这把锁还是不是我的」。正确释放要「先比对 value 是不是自己的 uuid,是才删」,而「比对 + 删」这两步必须原子,所以用 Lua(Redis 执行 Lua 脚本是单线程原子的,见 Redis 线程模型与性能):
-- KEYS[1]=lock key, ARGV[1]=本客户端的 uuid
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0 -- 不是我的锁,绝不删
end
Redis 锁的完整生命周期:SET NX PX 原子加锁 + 唯一 value 标识持有者 + Lua 比对后释放,再叠一个看门狗周期续期。三样缺一,锁就在某个异常路径上失守。
2.3 误删问题:唯一 value 是安全释放的前提
§2.2 的交错说明了:value 必须唯一。如果所有客户端都用固定 value(甚至没有 value),Lua 里的 GET == ARGV[1] 就形同虚设,误删照样发生。所以 value 要能唯一标识「本次持有」——常见做法是 UUID:threadId。可重入锁(§2.5)也是靠这个 value 来判断「是不是同一个持有者又来加锁」。
2.4 锁续期:看门狗(Redisson watchdog)
给锁设 TTL 是为了防死锁,但 TTL 带来一个新矛盾:TTL 设短了怕业务没跑完锁就过期,设长了怕持有者崩溃后锁迟迟不释放。
Redisson 的解法是 watchdog(看门狗):lock() 时若不显式指定 leaseTime,Redisson 会给一个默认 30s 的 TTL,并启动一个后台线程,每 TTL/3(≈10s)检查一次——只要持有者还活着、业务还没结束,就用 Lua 把锁续回 30s。这样:
- 业务再久锁也不过期:看门狗持续续期,直到
unlock()才停狗; - 持有者崩溃自动释放:进程一挂,看门狗线程也没了,没人续期 → 锁在最多 30s 后自然过期。
RLock lock = redisson.getLock("lock:order:42");
lock.lock(); // 不传 leaseTime → 启用看门狗,默认 30s 自动续期
try {
doBusiness(); // 哪怕跑 5 分钟,watchdog 也会一路续到业务结束
} finally {
lock.unlock(); // 释放并停狗(内部走 Lua 比对 value)
}
// 对比:显式 leaseTime 则「不续期」,到点即释放,适合能严格约束耗时的场景
lock.lock(10, TimeUnit.SECONDS);
看门狗解决了「TTL 与业务耗时」的矛盾,但它解决不了 GC 停顿:如果持有者 STW 停顿超过了 TTL,看门狗线程也一起被冻住、无法续期,锁照样过期——这正是 §3 Kleppmann 质疑的核心,也是为什么「续期」只是缓解、
fencing token才是根治。
2.5 可重入:value + 计数器
可重入锁要记录「持有者是谁」和「重入了几层」。Redisson 用 Hash 结构实现:field 是持有者标识(UUID:threadId),value 是重入计数。同一线程再次 lock() 时计数 +1,unlock() 时 -1,减到 0 才真正删 key。这样递归调用、同一请求链路里的多次加锁不会自锁死。
3. Redlock 算法与 Kleppmann 的质疑
3.1 单实例的致命弱点:主从切换丢锁
§2 的单实例锁有个绕不过去的问题:Redis 挂了怎么办? 上主从 + 哨兵(见 Redis 持久化与高可用)能提高可用性,但主从复制是异步的,于是:
- 客户端 A 在 master 上加锁成功;
- master 还没把这条写同步给 slave 就宕机了;
- 哨兵把 slave 提升为新 master——新 master 上没有这把锁;
- 客户端 B 在新 master 上又加锁成功 → A、B 同时持锁,互斥破坏。
这是异步复制的固有问题:锁的「已提交」在故障切换时可能丢失。
3.2 Redlock:多实例多数派
为了不依赖主从复制,Redis 作者 antirez 提出了 Redlock:部署 N 个完全独立(无主从关系)的 Redis 实例(通常 N=5),加锁时:
- 记录开始时间,用相同的 key 和 value,依次向 N 个实例请求加锁(每次请求带一个远小于锁 TTL 的超时,避免卡在某个挂掉的实例上);
- 只有在**多数派(≥ N/2 + 1,即 5 取 3)**实例上加锁成功,且「获取锁总耗时 < 锁 TTL」,才算加锁成功;
- 加锁成功后,锁的实际有效时间 = 初始 TTL − 获取锁耗时;
- 若失败(没拿到多数派 / 总耗时超了),向所有实例发释放请求(包括没加成功的,兜底)。
多数派的意义:即使少数实例宕机,只要多数还在就能加解锁,且两个客户端不可能同时在多数派上成功(鸽巢原理)。这补上了单实例「容错」的短板。
3.3 Kleppmann 的质疑:GC / 网络延迟让锁失效
2016 年,Martin Kleppmann 发表了著名的 How to do distributed locking,指出 Redlock(乃至任何靠 TTL 的锁)在时序假设上是不安全的。核心论点:分布式系统里没有可靠的时间边界——进程可能因 GC、缺页、CPU 调度而任意长时间停顿,网络包可能任意延迟。于是:
Kleppmann 的经典场景:持锁进程 GC 停顿超过 TTL,锁过期被他人获取,「复活」后仍以为自己持锁而写脏。靠锁本身无法阻止——因为锁已经合法地易主了。
时序拆解:① 进程 1 获取锁,TTL 30s;② 进程 1 触发 STW GC 停顿 40s;③ 锁在第 30s 过期,进程 2 合法获取;④ 进程 2 开始写;⑤ GC 结束,进程 1「复活」,它不知道锁早就不是自己的了,继续往下写 → 进程 1、2 的写交错,数据被写坏。
关键在于:这不是 Redlock 特有的 bug,任何用 TTL 自动过期的锁都有这个问题(ZooKeeper 会话超时同理)。看门狗也救不了——GC 期间续期线程也被冻住。
3.4 fencing token:把互斥责任下沉到资源
Kleppmann 给的根治方案是 fencing token(防护令牌):锁服务每次授予锁时,附带一个单调递增的 token(进程 1 拿到 33,进程 2 拿到 34)。客户端每次访问被保护的资源都带上这个 token,而资源侧(存储 / DB / 文件系统)记住见过的最大 token,拒绝任何更小的。
于是回到上面的场景:进程 2 带 token=34 写成功,存储记下 max=34;进程 1「复活」带 token=33 来写,存储发现 33 < 34 → 拒绝。即使锁层面出现了「双持有者」,资源层面依然保证了互斥。
fencing token 的深刻之处在于:它承认「锁不可能 100% 互斥」,转而把最终的互斥保证下沉到被保护的资源本身。ZooKeeper 的
zxid、etcd 的revision天然就是单调递增的,可以直接当 fencing token 用;Redis 则需要你自己维护一个INCR计数器。要真正的正确性,光有锁不够,还得让下游认 token。
3.5 争论的结论
antirez 随后回应称 Kleppmann 对时序模型的假设过于苛刻,并强调 Redlock 在实践中的定位。这场争论没有「谁对谁错」的简单答案,但给工程实践留下了清晰的指引:
- 锁只是「效率优化」(避免重复干活) → Redis 单实例锁足够,偶发失效只是多做一次,无所谓;
- 锁关乎「正确性」(不能有两个持有者写同一资源) → 要么用 CP 系统(ZK/etcd),要么无论用什么锁都必须叠加 fencing token。
4. ZooKeeper 锁:临时顺序节点 + 前驱 Watch
ZooKeeper 走的是另一条路——它本身是个 CP 的协调服务(底层 ZAB 协议,见 ZooKeeper 与 etcd 深挖),用它的数据模型能构造出正确性更强的锁。
4.1 两种关键节点
- 临时节点(EPHEMERAL):与创建它的会话绑定,会话断开(客户端崩溃 / 网络断)时自动删除。这天然解决「防死锁」——不用 TTL 拍脑袋,持有者一挂,节点就没了,锁自动释放。
- 顺序节点(SEQUENTIAL):创建时 ZK 会在名字后追加一个单调递增的序号(如
lock-0000000001)。这个序号既能用来排队,本身也是天然的 fencing token。
组合起来就是 临时顺序节点(EPHEMERAL_SEQUENTIAL)。
4.2 加锁:序号最小者持锁,其余 watch 前驱
ZooKeeper 锁:所有竞争者在锁根下建临时顺序节点,序号最小者持锁;其余各自只监听「前一个」节点。前驱释放时只唤醒紧邻的一个,这是避免惊群的关键设计。
加锁流程:
- 在锁根
/locks/order:42下创建临时顺序节点,拿到自己的序号(如lock-0000000002); - 列出锁根下所有子节点,如果自己序号最小 → 获得锁;
- 否则,只对「前一个」序号的节点注册 Watch,然后阻塞等待;
- 前驱节点被删除(前驱释放锁或崩溃)时,Watch 触发,客户端被唤醒,回到第 2 步判断——此时自己通常已是最小 → 拿到锁。
释放锁就是删除自己创建的那个节点;若持有者崩溃,会话超时后临时节点被 ZK 自动删除。
4.3 为什么 watch 前驱而不是 watch 父节点:避免惊群
一个朴素实现是「所有等待者都 watch 锁根 / 都 watch 当前持锁节点」。问题是:持锁者一释放,所有等待者同时被唤醒、一起去抢锁,但最终只有一个能成功——这就是惊群效应(herd effect),N 个客户端里 N-1 个是白忙活,还给 ZK 造成瞬时压力。
只 watch 前一个节点就优雅地避开了:形成一条「链」,前驱释放时只唤醒它的直接后继一个客户端。每次锁的交接只涉及一次唤醒,扩展性好得多。这是 ZooKeeper 锁最精妙的设计,Curator 的 InterProcessMutex 就是这么实现的(还自带可重入)。
// Apache Curator:生产级 ZK 锁,已封装临时顺序节点 + 前驱 watch + 可重入
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order:42");
if (lock.acquire(10, TimeUnit.SECONDS)) { // 带超时地获取
try {
doBusiness();
} finally {
lock.release(); // 删除自己的临时顺序节点
}
}
ZK 锁比 Redis 锁正确性更强:临时节点根治防死锁(无需 TTL)、顺序节点提供排队与 fencing、ZAB 共识保证多数派一致。代价是性能——每次加解锁都是一次要过半数节点确认的写操作(见 §6)。
5. etcd 锁:Lease + Txn(CAS)+ Revision 顺序
etcd 是云原生时代的协调存储(Kubernetes 的元数据就存在它上面),底层是 Raft(见 分布式共识开篇)。它实现锁的思路和 ZK 神似,只是原语不同。
5.1 三个关键原语
- Lease(租约):一个带 TTL 的对象。把 key 绑定到某个 lease 上,lease 过期时 key 自动删除——等价于 ZK 的临时节点,兜「防死锁」。客户端要通过
KeepAlive周期性续租(等价于 Redis 看门狗),持有者崩溃 → 停止续租 → lease 过期 → 锁释放。 - Txn(事务)+ CAS:etcd 的
Txn支持「比较-then-执行」的原子操作,典型是比较某 key 的CreateRevision == 0(即 key 不存在)才 Put——这就是 CAS 式加锁,保证互斥。 - Revision(版本号):etcd 全局单调递增的版本号。每个 key 有
CreateRevision(创建时的全局版本),可以用它给竞争者排序——等价于 ZK 的顺序号,也是天然的 fencing token。
5.2 加锁流程(concurrency 包已封装)
etcd 官方的 clientv3/concurrency 包提供了现成的 Mutex,逻辑与 ZK 高度对称:
- 创建一个带 TTL 的 Session(内含 lease + 自动 KeepAlive);
- 以「公共前缀 + lease id」为 key,用 Txn CAS 把自己 Put 进去,拿到自己的
CreateRevision; - 查询该前缀下所有 key,如果自己的
CreateRevision最小 → 获得锁; - 否则 watch 紧邻的前一个 revision 的 key(同样避免惊群),等它被删除后再判断。
cli, _ := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})
// Session 内含 lease + 自动 KeepAlive(续租),会话结束锁自动释放
sess, _ := concurrency.NewSession(cli, concurrency.WithTTL(30))
defer sess.Close()
mu := concurrency.NewMutex(sess, "/locks/order:42")
mu.Lock(context.TODO()) // CAS 抢占 + 按 revision 排队 watch 前驱
defer mu.Unlock(context.TODO())
doBusiness()
命令行也能直接用:etcdctl lock order:42 -- <command>——拿到锁后执行 command,退出即释放。
etcd 锁 = ZK 锁的「Raft 版」:
Lease↔ 临时节点、Revision↔ 顺序号、Txn CAS↔ 创建即排队、watch 前驱 ↔ 避免惊群。两者都是 CP,正确性同级;选型更多看技术栈(云原生 / K8s 生态选 etcd,Java / Hadoop 生态选 ZK)。
6. 三方案对比:性能、正确性、复杂度、CP vs AP
把三条路线放到一张表里横向比:
| 维度 | Redis(单实例 / Redlock) | ZooKeeper | etcd |
|---|---|---|---|
| 底层模型 | 内存 KV,主从异步复制(偏 AP) | ZAB 共识(CP) | Raft 共识(CP) |
| 互斥保证 | 单实例强、主从切换可能丢锁;Redlock 靠多数派 | 强(共识 + 顺序节点) | 强(共识 + revision) |
| 防死锁 | TTL 自动过期(需看门狗续期) | 临时节点(会话断开自动删,最可靠) | Lease(需 KeepAlive 续租) |
| 排队 / 公平 | 无原生排队(抢占式,可能饿死) | 顺序节点,天然 FIFO 公平 | revision 排序,天然 FIFO 公平 |
| 避免惊群 | —(抢占式) | watch 前驱 | watch 前驱 revision |
| fencing token | 需自己维护 INCR | 顺序号天然可用 | revision 天然可用 |
| 性能 / 延迟 | 最高(内存操作,无共识) | 中(每次加解锁要过半数确认) | 中(Raft 过半确认) |
| 复杂度 / 运维 | 低(一个 Redis 就能起) | 高(要维护 ZK 集群) | 中高(要维护 etcd 集群) |
| 适用场景 | 高并发、锁只为提效、能容忍偶发失效 | 强一致协调、Java 生态 | 云原生 / K8s 生态、强一致 |
一句话选型口诀:
锁只为「效率」(避免重复劳动)→ Redis(快、简单,偶尔失效无非多做一次);锁关乎「正确性」(绝不能双持有者写同一资源)→ ZooKeeper / etcd(CP、有天然 fencing token),且下游最好再认一次 token。别用 Redlock 去追求「既快又强一致」——那正是 Kleppmann 质疑的灰色地带,多数场景要么退回单实例 Redis + fencing,要么直接上 CP。
CP vs AP 的本质区别在网络分区时的取舍:ZK/etcd 在分区、无法达成多数派时会拒绝服务(不可用)以保证不出现两个持有者(一致性优先);Redis 主从在分区时倾向继续可用,代价是可能出现「老 master 和新 master 各授一把锁」(可用性优先)。这也是为什么「要正确性就得忍受 CP 系统在极端情况下的不可用」。
7. 什么时候「别用分布式锁」
分布式锁是并发控制里最重、最容易用错的工具。上锁之前,先问一句:这个问题真的需要互斥吗,还是有更轻的办法? 很多时候答案是后者:
7.1 能幂等就别加锁
如果操作天然幂等(执行一次和执行 N 次结果相同),根本不需要互斥——重复执行无害。例如「订单状态从 待支付 改为 已支付」,用条件更新即可:
-- 只有当前是「待支付」才更新,重复执行第二次影响 0 行、无副作用
UPDATE orders SET status = 'PAID' WHERE id = 42 AND status = 'UNPAID';
这比「加锁 → 读状态 → 判断 → 改状态 → 解锁」轻太多,且没有锁的任何异常路径。幂等设计的系统方法见 幂等与一致性。
7.2 能乐观锁就别用悲观锁
读多写少、冲突概率低时,用版本号 / CAS(乐观锁)代替分布式锁(悲观锁):读时带上版本,写时校验版本没变才提交,变了就重试。没有锁的持有、续期、释放,也没有死锁:
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 42 AND version = 7; -- 影响 0 行说明被人改过 → 重试
7.3 能单分区串行化就别跨节点抢锁
如果能把「同一把锁保护的操作」路由到同一个分区 / 同一个线程串行处理,就把「分布式互斥」降维成了「单机串行」——根本不需要锁。典型如:按 订单ID hash 把请求路由到固定的 Kafka 分区,由单消费者线程顺序处理(同分区天然有序);或 actor 模型里同一实体的消息由单 actor 串行处理。这也是缓存一致性里「单 key 串行」思路的延伸。
分布式锁的排序:幂等 > 乐观锁 / CAS > 单分区串行化 > 分布式锁。真到了非用不可(比如「全局只能有一个定时任务实例在跑」「秒杀扣库存又没法做成幂等」),再上锁,并且按 §3 的结论决定要不要叠 fencing token。锁是最后的手段,不是第一反应。
参考
- Redis 官方文档 · Distributed Locks with Redis(含单实例正确姿势与 Redlock):
SET NX PX、Lua 释放、Redlock 算法的一手说明。 - Martin Kleppmann · How to do distributed locking:对 Redlock 时序假设的质疑与 fencing token 方案,分布式锁必读。
- Salvatore Sanfilippo (antirez) · Is Redlock safe?:Redlock 作者对 Kleppmann 的回应,两篇对照读能看清争论边界。
- Apache Curator · Shared Reentrant Lock(InterProcessMutex):ZooKeeper 临时顺序节点 + 前驱 watch 的生产级实现。
- etcd · Distributed locks(clientv3/concurrency):etcd Lease + Txn + revision 锁的官方接口文档。