1,分布式锁的目的
在后台开发过程中,通常是为了在同一时间内,保证是有一个客户端能访问共享资源。如果是单机服务,通常使用互斥锁即可保证共享资源在同一时间只被一个客户端访问。这样保证了数据资源的一致性。但是实际生产开发中,不可能使用仅仅使用单机。避免单点故障,都是使用集群分布式的模式进行部署模式开发的。同时,也产生了新的问题,在集群分布式中,如何才能保证统一时间,共享资源只被一个客户端访问?解决该问题的方法就是分布式锁。
2.分布式锁机中模型
a.利用数据的分布式事务来实现,具体模型如下:
使用数据库实现分布式锁的构想是,当要访问共享资源时,向数据库中插入一条数据,表示已经获取了该资源的锁,释放锁的时候,将该条记录从数据路中删除即可。
for instance:创建一张锁表信息:
Drop resource_lock if exists;
CREATE TABLE `resource_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource_lock_key` varchar(64) NOT NULL COMMENT '锁定的资源方法名',
`resource_lock_val` tinyint NOT NULL COMMENT '资源锁value',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, COMMENT '更新时间'
`PRIMARY KEY (`id`),
UNIQUE KEY `uidx_resource_lock` (`resource_lock_key`) USING BTREE CMMENT '资源锁key唯一存在'
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
加锁:
insert into resource_lock(resource_lock_key,resource_lock_val)value("lock","method_lock");
解锁:先查找出来锁,与当前的获取锁的客户端比较,相同之后,在进行删除,达到解锁。
begin;
result = select resource_lock_val from resource_lock where key = "lock";
if result.value =="method_lock":
delete from resource_lock where key = "lock";
commit;
以上情况是在理想状态下是没有问题的,当时实际情况是有如下问题所在。
缺陷:
a.客户端A访问一个资源,向数据表中插入一条数据,但是客户端A挂掉了,但是锁仍旧在数据表中,客户端B需要访问资源的时候,是无法获取到锁的。如下图:
解决方法:需要给该锁设置一个定时时效性,启动一个线程轮训数据表,检查该锁超过一定的时效性,就删除掉该记录,这样就可避免a情况的发生。另外也可在锁表中添加当前获取锁客户端信息字段,这样再一次需要获取锁,只需要判断客户端的字段跟当前的客户端是否一致,如果是,直接获取锁即可。
模拟两个客户端加锁解锁过程:
package main
import (
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
"strings"
"sync"
"time"
)
var SqlDB *sql.DB
var wg sync.WaitGroup
func clientFirst() {
defer wg.Done()
for {
fmt.Println("client first add lock...")
sqlstr := &strings.Builder{}
sqlstr.WriteString("insert into resource_lock(resource_lock_key,resource_lock_val)value(?,?)")
sqlargs := make([]interface{}, 0)
sqlargs = append(sqlargs, "lock", "method_lock")
_, err := SqlDB.Exec(sqlstr.String(), sqlargs...)
if err != nil {
fmt.Println("client first add lock failed", err.Error())
continue //
}
fmt.Println("client first process things")
// 解锁
sqlstr.Reset()
sqlstr.WriteString("delete from resource_lock where resource_lock_key = 'lock'")
_, err = SqlDB.Exec(sqlstr.String())
if err != nil {
fmt.Println("first delete lock failed ...", err.Error())
} else {
fmt.Println("client first delete lock...")
}
time.Sleep(time.Millisecond * 15)
}
}
func clientSecond() {
defer wg.Done()
for {
fmt.Println("client second add lock...")
sqlstr := &strings.Builder{}
sqlstr.WriteString("insert into resource_lock(resource_lock_key,resource_lock_val)value(?,?)")
sqlargs := make([]interface{}, 0)
sqlargs = append(sqlargs, "lock", "method_lock")
_, err := SqlDB.Exec(sqlstr.String(), sqlargs...)
if err != nil {
fmt.Println("client second add lock failed", err.Error())
time.Sleep(time.Millisecond * 3)
continue //
}
fmt.Println("client second process things")
// 解锁
sqlstr.Reset()
sqlstr.WriteString("delete from resource_lock where resource_lock_key = 'lock'")
_, err = SqlDB.Exec(sqlstr.String())
if err != nil {
fmt.Println("client second failed to delete lock", err.Error())
} else {
fmt.Println("client second delete lock...")
}
time.Sleep(time.Millisecond * 5)
}
}
func mointer() {
defer wg.Done()
curtime := ""
tmp := "2006-01-02 15:04:05"
for {
sqlstr := "select update_time from resource_lock where resource_lock_key = 'lock'"
var time_str string
rows := SqlDB.QueryRow(sqlstr)
err := rows.Scan(&time_str)
if err != nil || err == sql.ErrNoRows {
time.Sleep(time.Second * 1)
continue
}
if len(curtime) == 0 {
curtime = time_str
}
res, _ := time.ParseInLocation(tmp, time_str, time.Local)
cur_res, _ := time.ParseInLocation(tmp, curtime, time.Local)
tm := res.Unix() - cur_res.Unix()
if tm >= 10 {
strstr := "delete from resource_lock where resource_lock = 'lock'"
fmt.Println("mointer delete unexpired lock")
SqlDB.Exec(strstr)
curtime = "" // 置空
}
time.Sleep(time.Millisecond * 5)
}
}
func main() {
var err error
wg.Add(4)
SqlDB, err = sql.Open("mysql", "root:passwd@tcp(localhost:port)/sql_db?charset=utf8")
if err != nil {
fmt.Println("connect failed", err.Error())
}
fmt.Println("success to connect mysql db !")
go clientFirst()
go clientSecond()
go mointer()
go func() {
defer wg.Done()
wg.Wait()
}()
// 主进程阻塞
select {}
}
b.基于缓存模型的分布式锁
在缓存中redis当中,使用的是setnx命令进行实现的分布式锁,都是原子操作处理的,实现思想跟数据库一样,需要加锁的时候,在缓存中添加一条数据,释放锁的时候,删除掉该数据即可。如下所示:
加锁:如下代码利用uuid作为value值。
func redis_lock(lock_name string, acquire_time int, time_out time.Duration) (string, error) {
// """获取一个分布式锁"""
identifier := uuid.NewV1().String()
end := time.Now().Second() + acquire_time
lock := "string:lock:" + lock_name
for {
if t := time.Now().Second(); t <= end {
if client.SetNX(lock, identifier, time_out).Val() {
return identifier, nil
} else {
// 获取生命周期时间,并设置过期时间
client.Expire(lock, client.TTL(lock).Val())
}
} else {
break
}
time.Sleep(time.Second)
}
return "", nil
}
如上代码,获取锁时,尝试获取锁,并且给予时间内尝试获取锁,超时就表示获取失败。并在失败时,检查之前的,并设置该锁的有效时间长,避免该锁一直存在,导致其他客户端无法获取锁。
解锁:
func redis_unlock(lock_name string, identify string) {
lock := "string:lock:" + lock_name
for {
val, err := client.Get(lock).Result()
fmt.Println("unlock key-var", lock, "-", val)
if err == nil {
if val == identify {
client.Del(lock)
return
}
} else {
break
}
}
}
如上代码中,表示的是解锁。
但是如上也是有缺陷的:
a.A获取了锁,但是A的业务逻辑进入睡眠了,但是锁的有效期到了,redis中自动清理了该锁,B需要来加锁,也成功了获取了锁,但是,此刻A从业务中睡眠中醒来,在释放锁的时候,将该锁匙放掉了,这样导致了问题的产生。如下图:
所以使用时,避免业务休眠时间大于锁的有效期。从而避免出现如上图问题。
b.redis中使用get和del也要进行原子操作,使用lua脚本实现原子操作处理,如上代码修改如下:
const (
script = `if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end`
)
func redis_unlock(lock_name string, identify string) {
lock := "string:lock:" + lock_name
var count int = 0
for {
// 利用lua脚本来保证redis的get和del操作的原子性
if val, err := client.Eval(script, []string{lock}, identify).Result(); err != nil {
if !strings.Contains(err.Error(), "Noscript") {
fmt.Println("no script !!!")
}
// 尝试五次,放弃,所周期时间到了,也会释放该锁
if count < 5 {
break
}
count++
} else {
fmt.Println("unlock key-var", lock, "-", val)
client.ScriptLoad(script)
break
}
}
}
如上代码,保证了原子操作,get&del,用于释放锁。
c.redis中实现了如下方式实现分布式锁:
RedLock算法
这个算法基于N个完全独立的Redis节点(通常情况下N可以设置成5),客户端可以分成以下5个步骤来完成获取锁的操作
- 获取当前时间(毫秒数),记录当前的时间为 T1
- 按顺序依次向N个Redis节点执行 获取锁 的操作。这个获取操作跟前面基于单Redis节点的 获取锁 的过程相同,包含随机字符串
randomValue
,也包含过期时间(比如PX 30000
,即锁的有效时间)。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个 获取锁 的操作还有一个超时时间(T2),它要远小于锁的有效时间(T3几十毫秒量级,T2 << T3)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有(注:Redlock原文中这里只提到了Redis节点不可用的情况,但也应该包含其它的失败情况)。 - 计算整个获取锁的过程总共消耗了多长时间,计算方法是用获取锁成功的当前时间(T5 = T4 - T1,获取锁消耗的时间)。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time,也就是T3时间,并且T5 < T3),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
- 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间,即(T6 = T2 - T5),表示的是成功获取锁之后锁的有效时间。
- 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间T2),那么客户端应该立即向所有Redis节点发起 释放锁 的操作。