我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
本文作者:飞书商业应用研发部 李成武
欢迎大家关注飞书技术,每周定期更新飞书技术团队技术干货内容,想看什么内容,欢迎大家评论区留言~
为什么需要锁
小热身
下面程序运行的结果是多少?
package main
import (
"sync"
)
func main() {
count := 0
wg := &sync.WaitGroup{}
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++
}()
}
wg.Wait()
println(count)
}
答案
多次运行会得到不同的结果。那么如何得到正确的结果呢
package main
import (
"sync"
)
func main() {
count := 0
wg := &sync.WaitGroup{}
lock := &sync.Mutex{}
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
lock.Lock()
count++
lock.Unlock()
}()
}
wg.Wait()
println(count)
}
当某个资源数据具有共享性的时候,如果同一时刻有多个请求更新共享资源就可能导致数据的不一致,这时候就必须要求在同一时刻只能被一个请求访问,所以我们需要使用锁来协调对于共享数据的更新,以确保数据的一致性。
Golang中的锁/单机锁
互斥锁 Mutex
type Mutex struct {
state int32
sema uint32
}
type Locker interface {
Lock()
Unlock()
}
// Mutex 实现了Locker接口
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
使用注意点:
- 调用 unlock 方法时如果锁未被占用则会触发 panic
fatal error: sync: unlock of unlocked mutex
- 调用 lock 方法时如果锁被占用则该协程会阻塞知道锁被释放,所以一定要记得执行完后及时释放锁,避免这种情况的最有效方式就是使用defer。
lock.Lock()
defer lock.Unlock()
- 互斥锁不支持可重入,如果连续调用 lock 则会触发panic
fatal error: all goroutines are asleep - deadlock!
。 - 锁作为参数时需要传递指针。原因在于 Mutex 是一个有状态的对象,如果传递对象那么会复制一个 Mutex 并将相应的状态也复制过来,这样每个线程执行函数时内部就是单独的锁,就达不到预期效果了。
func Add(lock *sync.Mutex) {
lock.Lock()
defer lock.Unlock()
count++
}
读写锁 RWMutex
type RWMutex struct {
w Mutex // held if there are pending writers
writerSem uint32 // semaphore for writers to wait for completing readers
readerSem uint32 // semaphore for readers to wait for completing writers
readerCount int32 // number of pending readers
readerWait int32 // number of departing readers
}
// 加读锁
func (rw *RWMutex) RLock()
// 解读锁
func (rw *RWMutex) RUnlock()
// 加写锁
func (rw *RWMutex) Lock()
// 解写锁
func (rw *RWMutex) Unlock()
读写锁是针对读写操作的互斥锁,可以分别针对读操作与写操作进行锁定和解锁操作,这样对于性能有一定的提升。
读写锁的访问控制规则如下:
- 多个写操作之间是互斥的
- 写操作与读操作之间也是互斥的
- 多个读操作之间不是互斥的
读 | 写 | |
---|---|---|
读 | 不互斥 | 互斥 |
写 | 互斥 | 互斥 |
使用注意点: 读写锁使用注意点和互斥锁类似,但是由于多个读操作之间不是互斥的,因此对于读解锁更容易被忽视。对于同一个读写锁,添加多少个读锁定,就必要有等量的读解锁,否则程序会出现异常。
分布式锁
到了分布式系统时代,这种线程之间的锁就发挥不了作用了,系统会有多份并且部署在不同的机器上,这些资源已经不是在线程之间共享了,而是属于不同实例之间共享的资源。为了解决这个问题,我们就要引入「分布式锁」。目前常见的分布式锁实现方式有三种,分别是基于数据库实现、基于Redis实现以及基于ZooKeeper实现。
分布式锁的三个属性:
- 互斥(Mutual Exclusion): 这是锁最基本的功能,同一时刻只能有一个客户端持有锁。
- 避免死锁(Dead lock free): 如果某个客户端获得锁之后花了太长时间处理,或者客户端发生了故障,锁无法释放会导致整个处理流程无法进行下去,所以要避免死锁。
- 容错(Fault tolerance): 为避免单点故障,锁服务需要具有高可用性。
基于数据库
简单场景
比较简单的场景是并发更新数据库中某条记录。对于这种场景我们可以采用乐观锁的方式来实现,即在更新之前先查出更新字段的值,更新时检查对应值是否和刚才查出的相同,如果相同则更新。
-- 账户原有100元全部转出
update account set balance=0 where id=账户ID and balance=100
但是这种方式存在一个问题,即ABA问题:假设要修改的值V原先为A,它被更新到B,后面又被更新到A,此时客户端通过CAS操作无法分辨当前V值是否发生过变化。这样就会导致程序运行结果不符合预期。
- T1小明查询账户余额100元并全部转出,但是更新之前发生了程序暂停。
- T2时刻小明发现卡顿并重新操作。
- T3时刻小明成功转出100元。
- T4时刻小红又给小明转账100元并操作成功。
- T5时刻程序恢复时去执行更新,结果更新成功。
上述操作结果实际小明转出了200元,不符合预期只转出100元。解决ABA问题的一个方法就是在数据库表中引入一个版本号(version)字段来实现的。 当我们要从数据库中读取数据的时候,同时把这个version字段也读出来,更新后写回数据库时则需要将version加1,且必须在更新的时候同时检查目前数据库里version值是不是之前的那个version。如果是则正常更新,如果不是则更新失败,需要重新读取数据再操作。
由于T4时刻version已经被更新成2,这样T5时刻再执行 where version=1 的时候会执行失败,防止了预期外的更新
通用场景
如果要实现一个通用化的锁时,就要在数据库中创建一张单独的表用来记录锁,以下介绍一下shedlock的实现。
-- 这里name是全局唯一用来作为锁的标识
CREATE TABLE shedlock
(
name VARCHAR(64),
lock_until TIMESTAMP(3) NULL,
locked_at TIMESTAMP(3) NULL,
locked_by VARCHAR(255),
PRIMARY KEY (name)
)
-
加锁过程
- 通过插入同一个name(primary key)或者更新同一个name进行抢锁。先根据name查询数据库里是否有该记录,如果没有则执行insert操作,如果有则执行update操作。
-
-- insert INSERT INTO shedlock (name, lock_until, locked_at, locked_by) VALUES (锁名字, 当前时间+最多锁多久, 当前时间, 客户端名称) -- update UPDATE shedlock SET lock_until = 当前时间+最多锁多久, locked_at = 当前时间, locked_by = 客户端名称 WHERE name = 锁名字 AND lock_until <= 当前时间
-
解锁过程
- 通过设置lock_until来释放锁
-
UPDATE shedlock SET lock_until = 当前时间 WHERE name = 锁名字 and locked_by = 客户端名称
-
存在的问题
- 写操作只能访问主库,高并发场景下对数据库的压力比较大
基于Redis
基于Redis实现分布式锁应该是最常见了,提到 Redis 分布锁大家第一反应想到的便是 setnx
SET if Not Exists
加锁方式
一提到 setnx 有些同学会想到 setnx 这个命令,那么加锁会是
SETNX lockKey value
EXPIRE lockKey time
但是这样却存在问题:这两个命令执行不是原子的,如果客户端在执行完 SETNX 后崩溃了,那么就没有机会执行 EXPIRE 了,导致它一直持有这个锁。
正确的加锁姿势是使用 SET 命令加上 NX 参数
SET lockKey lockValue NX PX 30000
- lockKey: 加锁的锁名。
- lockValue: 由客户端生成的一个随机字符串,需要保证其唯一性。
- NX: 表示只有当lockKey对应的key值不存在的时候才能SET成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
- PX 30000: 设置过期时间,单位是毫秒(ms),表示这个锁有一个30000ms即30s的自动过期时间,当然这里需要根据不同的业务场景设置合适的过期时间。
lockValue的作用
大家有没有遇到这样的代码?这样的加锁方式存在什么问题?
lock.Lock(key, "1", lockTime)
- T1时刻客户端A获取锁成功。
- 客户端A在某个操作上阻塞了很长时间。
- T2过期时间到了,锁自动释放了。
- T3时刻客户端B获取到了对应同一个资源的锁。
- 客户端A执行完毕,释放掉了客户端B持有的锁。
- 客户端B还未执行完毕,此时客户端C已经可以成功请求到锁了。
所以lockValue的作用就是唯一标识加锁的 客户端 ,保证不能去释放别人的锁
解锁方式
上面介绍了lockValue要用来标识客户端,那么解锁的时候就需要判断value是否是自己设置的value,如果是才能执行解锁操作。
// 伪代码
String uuid := xxxx;
// 加锁
set lockName uuid NX PX 3000;
... 执行操作
// 解锁
if redis.get("lockName")==uuid {
redis.del("lockName");
}
到了这里又有细心的小伙伴发现了问题:这解锁的时候先get,判断后再 del ,这不是原子操作啊! 如果客户端A在对比后发生了程序暂停,此时锁到了过期时间自动释放了,客户端B成功申请到了锁,那么客户端A恢复后就又会释放了客户端B的锁。
那么删除锁的正确姿势之一就是使用 lua 脚本,通过 redis 的 eval/evalsha 命令来运行:
-- 这段Lua脚本在执行的时候需要传递参数
-- lockKey 作为KEYS[1]的值传进去
-- lockValue 作为ARGV[1]的值传进去
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
存在的问题
- 锁超时问题:客户端A获取锁后执行的过程中,由于执行时间太长锁过期后客户端B可以成功获取锁。解决这个问题的方式就是锁续期, 这个机制在redisson框架中已经实现,参考资料:redisson中的看门狗机制总结。
- 锁丢失问题:Redis的高可用都是基于「主从架构数据同步复制」实现的。这就意味着如果在master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,这时候master发生故障转移,slave节点升级为master节点,由于slave节点上没有刚刚加锁的key,所以导致出现了锁丢失。针对这个问题,redis之父antirez设计了Redlock算法,官网链接: Distributed Locks with Redis。
基于Zookeeper
Zookeeper简单介绍
ZooKeeper 是一个分布式应用程序协同服务,它设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。
ZooKeeper提供的命名空间与标准文件系统的名称空间非常相似,命名空间中的每个节点都由路径标识。与标准文件系统不同的是 ZooKeeper 中每个节点都可以拥有与其关联的数据以及子节点。这就像拥有一个允许文件也成为目录的文件系统,使用标准文件系统的“数据节点“的概念,ZooKeeper 数据节点称之为Znode。
在 Zookeeper 中 Znode 都是有生命周期的,其生命周期的长短取决于Znode的节点类型。节点类型可分为持久节点(PERSISTENT)、临时节点(EPHEMERAL)和顺序节点(SEQUENTIAL)三大类,在节点创建过程中可以使用以下四种组合型节点类型:
- 持久节点: 数据节点被创建之后,会一直存在于Zookeeper服务器上,直至有删除操作来主动删除这个节点。
- 持久顺序节点:和持久节点的基本特性保持一致,不同之处表现在顺序上。在Zookeeper中,每一个父节点都会为它的第一级子节点维护一份顺序,在创建子节点时,Zookeeper会自动给节点名称增加一个数字后缀,作为Znode的最终名称。
- 临时节点: 临时节点的生命周期和客户端的会话绑定,客户端的会话一旦失效,那么这个节点就会被自动清理掉。
- 临时顺序节点:和临时节点的基本特性保持一致,不同之处表现在顺序上。在创建子节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号。
Zookeeper的Watch机制
Zookeeper 允许客户端向服务端注册一个 Watcher 监听,当服务端的一些事件(如节点创建、节点改变、节点删除等)触发了这个 Watcher,那么就会给指定客服端发送一个事件通知。
基于这个机制就可以实现阻塞式锁。客户端在获取锁失败时可以注册一个 Watcher 监听,等待 lock 节点被删除时客户端会收到通知,这样就可以获取锁继续进行操作了。这个特性可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。
加锁&&解锁流程
-
比较简单的实现是创建临时节点的方式,利用 Zookeeper 同一级节点 key 名称是唯一的特性。
- 加锁的时候创建临时节点,创建成功则加锁成功
- 加锁失败则等待并给锁节点添加监听器
- 收到锁节点被删除的通知再次尝试加锁
但是这里所有节点都会监听锁节点,那么当锁节点删除时所有监听的客户端都要被唤醒,这就是惊群效应。 这种情况大量通知操作会造成zookeeper性能突然下降。
-
要解决这个问题可以使用创建临时顺序节点的方式实现,Zookeeper 会保证子节点的有序性。
- 加锁的时候在持久节点下创建临时顺序节点,然后获取该持久节点的所有子节点,如果当前节点为一个节点,则表示加锁成功。
- 加锁失败则等待并给前一个节点添加监听器,这样释放锁后就只会唤醒一个客户端。
- 客户端收到锁节点被删除的通知,然后重新获取持久节点的所有子节点,判断加锁是否成功。
存在的问题
到这里我们这个锁看起来貌似已经比较完美了,支持公平排队(先请求的先得到锁)而且既不用设置过期时间还可以在客户端出现问题时自动释放锁。但是问题来了:如何判断客户端出现问题呢?
实际上Zookeeper是通过与每个客户端维护着一个Session,而这个Session依赖定期的心跳(heartbeat)来维持。如果ZooKeeper长时间收不到客户端的心跳(这个时间称为Sesion的过期时间),那么它就认为Session过期了,通过这个Session所创建的所有的临时节点都会被自动删除。这种情况下就可能会出现 Zookeeper 对 Session 过期的误判,比如客户端出现长时间GC Pause 或者网络出现问题等等,那么就会出现两个客户端同时获取到锁的场景。
小结
- 基于mysql的分布式锁思路简单,但是需要新建单独的表,而且并发高后对数据库压力较大,可能会引发很多失败操作。另外在实施的过程中会遇到各种不同的问题(比如表数据量越来越大等),为了解决这些问题,实现方式将会越来越复杂。故一般我们不使用这种实现。
- 基于Redis的分布式锁性能高,支持锁自动过期,不过需要业务设置合理的超时时间,另外存在锁丢失的问题。所以适用于并发量大、性能要求很高的场景,其次对于可靠性需要其他方案保证。
- 基于Zookeeper的分布式锁正常情况下支持客户端持有锁任意长的时间,可以确保业务执行完操作后再释放锁。但是由于在加锁和解锁过程中需要创建和销毁节点,所以性能相对Redis较差。故一般使用于高可靠同时并发量不是很大的场景。
看了这里我们可以发现要实现一个绝对安全的分布式锁势必会引入更多的复杂性,所以我们在实际业务中需要考虑业务对极端情况的容忍度,寻找成本与收益之间的平衡点。
如何更好的使用锁
我们为了性能使用并发执行,由于并发访问共享数据会导致数据的不一致性,所以需要锁来保证数据一致性。但是锁会使得原本的并发执行转为串行执行,然而最后锁反过来又限制了性能。所以我们使用锁时要注意使用方式,在保证数据一致性的同时,尽量减少系统性能的损失。
减少锁的持有时间
背景:我们需要基于员工信息构造业务数据(以下使用BusinessData表示),然后将BusinessData写入DB,要求BusinessData不能出现重复。为了保证BusinessData不重复,所以我们写之前需要先查询DB中是否已经存在,不存在才执行写入操作。这样就可能遇到两个协程都查询到DB中不存在某条记录,之后都执行写操作就会出现插入了重复的BusinessData,故我们需要加锁来避免这种情况。
注:下面例子中 lock 为分布式锁,不是本地锁
func HandleCreateBusinessData(empID) error {
// 加锁
lock.Lock()
defer lock.Unlock()
// 1. 获取员工信息
emp := GetEmpInfo(empID)
// 2. 基于Emp构建BusinessData
data := BuildBusinessData(emp)
// 3. 如果已经存在则无需创建
if IsExist(data) {
return nil
}
// 4. 不存在则创建BusinessData
return CreateBusinessData(data)
}
- 这时候我们会发现上述 1、2 步在并发场景下也不会导致问题,所以无需在锁的范围内。
func HandleCreateBusinessData(empID) error {
// 1. 获取员工信息
emp := GetEmpInfo(empID)
// 2. 基于Emp构建BusinessData
data := BuildBusinessData(emp)
// 加锁
lock.Lock()
defer lock.Unlock()
// 3. 如果已经存在则无需创建
if IsExist(data) {
return nil
}
// 4. 不存在则创建BusinessData
return CreateBusinessData(data)
}
- 但是这种写法还有一个问题,如果后续有人继续增加代码,比如第4步后还有一些操作,那么锁的范围就又会自动扩大了,所以更加建议将锁的范围抽出单独的函数。
func HandleCreateBusinessData(empID) error {
// 1. 获取员工信息
emp := GetEmpInfo(empID)
// 2. 基于Emp构建BusinessData
data := BuildBusinessData(emp)
// 3. 加锁创建BusinessData
err := CreateBusinessDataWithLock(data)
if err != nil {
return err
}
// 4. 进行后续操作
OtherOperation()
return nil
}
func CreateBusinessDataWithLock(businessData) err {
// 加锁
lock.Lock()
defer lock.Unlock()
// 如果已经存在则无需创建
if IsExist(businessData) {
return nil
}
// 不存在则创建BusinessData
return CreateBusinessData(businessData)
}
优化锁的粒度
上述创建BusinessData案例中要求其不能重复,但是对于SaaS系统而言,不能重复一般是指同一租户内不能重复。那么针对这种场景我们会发现在上述加锁方式下,该接口同一时间只能处理一个请求,但是不同租户间其实是不存在冲突的。所以我们可以优化为对租户加锁。
func CreateBusinessDataWithLock(businessData) err {
// 对当前租户加锁
tenantID := GetCurrentTenantID()
lockKey := "lock_CreateBusinessDataWithLock_" + tenantID
lock.Lock(lockKey, uuid, lockTime)
defer lock.Unlock(lockKey, uuid)
// 如果已经存在则无需创建
if IsExist(businessData) {
return nil
}
// 不存在则创建BusinessData
return CreateBusinessData(businessData)
}
无锁
对于并发操作的场景我们不一定要使用锁来保证安全,可以使用原子操作或者额外空间来确保数据一致。
- 使用原子操作
// 比如我们开头的例子可以写成
package main
import (
"sync"
"sync/atomic"
)
func main() {
count := int32(0)
wg := &sync.WaitGroup{}
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt32(&count, 1)
}()
}
wg.Wait()
println(count)
}
- 空间换时间
func ParallelBatchGetGetDataByID(ctx context.Context, idList []string) ([]*Data, error) {
if len(idList) == 0 {
return []*Data{}, nil
}
goroutineSize := constant.BatchGetGetDataByIDGoroutineSize
// 将 idList 分成 goroutineSize 组
idListArray := util.SpitTask(ctx, idList, goroutineSize)
retVal := make([]*Data, 0, len(idList))
errList := make([]error, 0, goroutineSize)
wg := &WaitGroupWrapper{}
lock := &sync.Mutex{}
for i := idListArray {
batchIDList := idListArray[i]
wg.Wrap(ctx, func() {
data, err := BatchGetGetDataByID(ctx, batchIDList)
lock.Lock()
retVal = append(retVal, data...)
errList = append(errList, err)
lock.Unlock()
})
}
wg.Wait()
for _, err := range errList {
if err != nil {
return nil, err
}
}
return retVal, nil
}
上面代码可以优化成
func ParallelBatchGetGetDataByID(ctx context.Context, idList []string) ([]*Data, error) {
if len(idList) == 0 {
return []*Data{}, nil
}
goroutineSize := constant.BatchGetGetDataByIDGoroutineSize
// 将 idList 分成 goroutineSize 组
idListArray := util.SpitTask(ctx, idList, goroutineSize)
retVal := make([]*Data, 0, len(idList))
dataList := make([][]*Data, goroutineSize)
errList := make([]error, goroutineSize)
wg := &WaitGroupWrapper{}
for i := idListArray {
// 需要注意这里要copy
idx := i
wg.Wrap(ctx, func() {
dataList[idx], errList[idx] = BatchGetGetDataByID(ctx, idListArray[idx])
})
}
wg.Wait()
for _, err := range errList {
if err != nil {
return nil, err
}
}
for _, data := range dataList {
retVal = append(retVal, data...)
}
return retVal, nil
}
尽量避免锁嵌套
实际开发中尽量避免锁嵌套,否则可能出现死锁
/* 运行结果:
A1 get A lock
B2 get B lock
fatal error: all goroutines are asleep - deadlock!
*/
package main
import (
"fmt"
"sync"
)
var lockA = &sync.Mutex{}
var lockB = &sync.Mutex{}
func main() {
A1()
go func() {
A2()
}()
}
func A1() {
lockA.Lock()
fmt.Println("A1 get A lock")
B2()
defer lockA.Lock()
}
func A2() {
lockA.Lock()
fmt.Println("A2 get A lock")
defer lockA.Lock()
}
func B1() {
lockB.Lock()
fmt.Println("B1 get B lock")
A2()
defer lockB.Lock()
}
func B2() {
lockB.Lock()
fmt.Println("B2 get B lock")
defer lockB.Lock()
}
参考资料
- Go 为什么不支持可重入锁? - 掘金
- 聊聊分布式锁 - 掘金
- 细说Redis分布式锁🔒 - 掘金
- 深度剖析:Redis分布式锁到底安全吗?看完这篇文章彻底懂了! - 文章详情
- Redis Redlock 的争论 - 掘金
- Zookeeper 实现分布式锁 - 掘金
加入我们
扫码发现职位&投递简历
官网投递