
在转转电商业务中有一些关键操作需要在分布式环境中进行协调,保证并发操作的原子性、数据的最终一致性、业务流程的准确性,如商品改价场景:
待交易的商品发布后,购买者下单后进行付款操作,发布者进行改价操作。这时的改价操作与付款操作需要做串行处理,避免成交流程错乱。
类似以上场景均使用了 ZZLock 确保业务流程的正确执行,每天有千万级的锁操作,平均耗时1.34ms,有效支撑业务服务的平稳建设。以下介绍 ZZLock 的项目过程。
1. 需求分析
经过各业务部门的需求采集,最终确定以下核心功能。
可重入:从客户端线程持有锁开始,到主动释放锁之前,线程内部可通过API再次拿到该锁
可靠互斥:服务端产品本身具备可靠的互斥能力,不会由于服务端节点failover出现多锁的情况
阻塞与非阻塞API:使用者可选择在获取锁失败时继续等待,或者直接返回竞争失败
服务端TTL:在客户端持有锁后出现意外中断,服务端能够淘汰过期锁
监控:在不入侵业务的前提下,自动汇总API的调用量、耗时等数据,做可视化监控告警
2. 选型设计
2.1 业界方案
数据库:依靠数据库写操作的原子特性,新建一张锁表,执行insert key成功的操作视为成功获取锁,持有者执行delete key视为释放锁。该方案最大的问题在于数据库没有TTL能力,释放逻辑执行失败会导致长期持有锁,再实现配套逻辑的成本非常高。
Redis:利用 setnx(key,value) 的原子特性,执行结果为1的视为成功获取锁,使用 expire(key,ttl) 设置过期时间。该方案锁功能的吞吐能力较好,但是与数据库的主从结构一样,在进行主从切换过程中无法保证锁数据同步的完整性,不满足强一致性的需求。
Zookeeper:通过ZAB协议保证数据一致性,开源项目 Curator 对 Zookeeper 在分布式锁的应用场景已有较好的封装实现。该方案中服务端的淘汰机制是依赖链接Session,创建临时节点作为锁,并发效率不是很理想,而且极端场景中锁的持有者网络闪断过期后无法续租。仅Java语言客户端接入比较顺手,能适应的场景有限。
Etcd:通过Raft协议保证数据一致性,支持TTL。该方案最大问题是 Etcd 客户端还没有稳定靠谱的Release版本,需要对接HTTP接口自实现,客户端开发成本较高。
2.2 Etcd可行性分析
Etcd CAS 接口 :/v2/keys/{path}[prevValue=string||prevIndex=int][prevExist=boolean],prevExist 用来做写入时判断key是否存在,True表示强制写入或覆盖,False表示值已存在就返回错误码。
prevValue 用来判断写请求是否合法。该接口的特性可以满足分布式锁的场景,但为满足实际需求有以下四点需要考虑:
① 重入逻辑需要与线程绑定,由客户端本地实现;
② 阻塞锁的功能需要在客户端通过设置循环次数的方式,用自旋逻辑实现阻塞效果;
③ 配套节点探活能力,开发节点探活定时任务,网络连接异常的高可用策略;
④ 监控能力可以接入转转公司内部的监控平台,定时上报数据后自动展示与告警。
2.3 架构设计
基于以上选型分析可以看出相比于采用开源的 Curator + Zookeeper,自研客户端 + Etcd 的开发成本虽然高,但是整体功能边界是可控的,能够适应更多的使用场景,架构设计如图所示也不复杂。

① 业务微服务通过使用 ZZLock 客户端API,定义锁的名称,嵌套业务逻辑做锁的竞争和释放操作;
② ZZLock 客户端内部实现同步竞争锁、异步续租、异步监控、异步Etcd节点探活、高可用策略;
③ ZZMonitor 平台是转转内部的监控平台,ZZLock 客户端异步上报各方的API调用次数、产生异常次数、竞争失败次数、耗时情况进行展示,支持自定义告警设置;
④ Etcd 集群采用开源社区版本号 3.3.9,5节点集群。
2.4 逻辑流程设计
ZZLock 锁操作逻辑的时序如图所示,业务侧关注的接口只是竞争锁和释放锁,返回值关注成功或失败,锁的逻辑细节是完全透明的,客户端内部关联Etcd集群,对接监控平台,把整个流程打通,完成业务侧的预期诉求。

3. 开发实现
3.1 代码实现
主要类如图所示,用户创建单例 ZZLockClient 即可,定义锁名称获取 ZZLock 对象(EtcdLock 实现),在业务逻辑中调用 ZZLock 对象的三个方法:acquire(int) 非阻塞竞争锁、acquire(int,long,int) 定义重试次数和时间间隔的阻塞竞争锁、release() 释放锁,在成功竞争到锁后,ZZLock 内部对应一个 renew 线程作为该锁的异步续租任务。每次调用API都会产生监控数据交给 LockMonitor 对象进行统计,定时上报监控平台。EtcdHeartbeatTask 周期性的探测 Etcd 集群。

3.2 代码接入
接入者编码非常简单,在客户端初始化完成后,可使用客户端对象进行如下操作:
try(ZZLock lock = client.newLock("lockName")){
if(lock.acquire(10)) {
// 成功获得锁
}
}
该代码展示非阻塞锁的用法,其中没有显式调用lock.release()释放锁,因为 ZZLock 对象实现了 Closeable 接口,在JDK 1.7+ 语法中,try语句执行完成后会自动执行close(),ZZLock 把close()指向release()。
3.3 监控运维
监控能力也非常易用,使用者无须在客户端做监控配置,在监控平台上关联到具体的服务节点即可展示锁的使用情况,下图展示了转转某服务集群一天内使用 ZZLock 的监控情况。

4. 特殊场景说明
4.1 业务幂等问题
分布式锁只是在同一自然时间的互斥锁,本身不解决幂等性问题,接入业务需要完善从获得锁到释放锁中间的数据幂等逻辑。以下例举两个业务场景:
① 定时器 Scheduled 的使用时,由于服务器时间可能存在差异,可能会出现定时任务在不同自然时间启动,视觉上多台服务器有可能同时获得锁,导致相同业务逻辑重复执行;
② 多个业务节点同时获取锁时,其中获得到锁的节点处理速度足够快,马上释放了锁,其他节点的请求才到达 Etcd 集群,又成功获取到了锁,视觉上是多个节点都获得了锁,都进入了相同的业务处理逻辑。
4.2 续租问题
租期较短可能导致锁没有按照预期续租。这时需要将租期设置稍长一点,尽量让续租操作有足够的时间窗口做高可用策略。
① 在维持心跳续租线程没有成功续租,就有可能在业务没有执行完毕就释放了锁,此时在某些场景下,其他客户端就有可能乘机获得锁;
② 客户端在成功获取到锁到释放锁之前,JVM进行GC,如果执行GC STW时间大于租期,那么就会导致锁过期失效,此时其他客户端就有可能乘机获得锁。
4.3 Etcd内部问题
API会以 ZZLockException 形式抛出异常,需要业务上做逻辑补偿处理 (重试或其他策略)。
① Leader 节点挂了,处于选主过程,此时 Etcd不接受任何请求;
② Etcd 内部进行raft 日志数据同步发生错误或者一致性问题(通过选票产生多数不一致)。
5. 总结
以上是 ZZLock 从0到1的过程,该实践完全适应转转公司内部的需求,基于Etcd v2协议可以满足,后期需要并发能力提升时可切换到Etcd v3 + Grpc的方式,平均耗时可降到百微秒级别。另外,如果业务侧对产品的可用性要求非常高、并且具备完善的补偿逻辑机制,就不需要强一致的分布式锁了,也可以在API保持不变的情况下,依托主从结构的 Redis 服务做另一套实现。
关于分布式锁的实现原理,大神 Martin Kleppmann 早年在文章《How to do distributed locking》给出过详细论证,其中针对 Redlock 方案的质疑,在极端场景中是有概率出现的,该方案无法保证在自然时间上的强一致锁。
个人认为如果服务端选型没有一致性算法的支持,就无法做到自然时间的强一致,但即使做到了自然时间的强一致,业务上要应对本地服务器时间的不一致,也要做进一步的校验和补偿逻辑,所以选型最重要的是让业务理解产品逻辑并做匹配,才能共同达到预期目标。
作者介绍
陈阳,转转架构部。

