场景

假设我们有个批处理服务,实现逻辑大致是这样的:

  1. 用户在管理后台向批处理服务投递任务;
  2. 批处理服务将该任务写入数据库,立即返回;
  3. 批处理服务有启动单独线程定时从数据库获取一批未处理(或处理失败)的任务,投递到消息队列中;
  4. 批处理服务启动多个消费线程监听队列,从队列中拿到任务并处理;
  5. 消费线程处理完成(成功或者失败)后修改数据库中相应任务的状态;

流程如图:

现在我们单独看看上图中虚线框中的内容(3~6):批处理服务从数据库拉取任务列表投递到消息队列。

生产环境中,为了高可用,都会部署至少两台批处理服务器,也就是说至少有两个进程在执行虚线框中的流程。

有什么问题呢?

假设这两个进程同时去查任务表(这是很有可能的),它俩很可能会得到同一批任务列表,于是这批任务都会入列两次。

当然,这不是说一个任务入列两次就一定会导致任务被重复执行——我们可以通过多引入一个状态值来解决此问题。

消费者线程从队列中获取到任务后,再次用如下 SQL 更新任务状态:

-- status:1-待处理;2-已入列;3-处理中;4-失败待重试;5-彻底失败(不可重试);

update tasks set status=3 where status=2 and id=$id;

由于 where 条件有 status=2,即只有原先状态是“已入列”的才能变成“处理中”,如果多个线程同时拿到同一个任务,一定只有一个线程能执行成功上面的语句,进而继续后续流程(其实这就是通过数据库实现的简单的分布式锁——乐观锁)。

不过,当定时进程多了后,大量的重复数据仍然会带来性能等其他问题,所以有必要解决重复入列的问题。

有个细节:请注意上图中步骤 5、6,是先改数据库状态为“已入列”,再将消息投递到消息队列中——这和常规逻辑是反过来的。

能否颠倒 5 和 6 的顺序,先入列,再改数据库状态呢?

不能。从逻辑上来说确实应该如此,但它会带来问题。消费线程从队列中拿到任务后,会执行如下 SQL 语句:

update tasks set status=3 where status=2 and id=$id;

这条 SQL 依赖于前面(第 5 步)产生的状态值,所以它要求在执行该语句的时候,第 5 步的 SQL 语句(将状态改为“已入列”)一定已经执行完了。如果将 5 和 6 颠倒(先入列,再改状态值),就有可能出现下图的执行顺序,导致消费者线程修改状态失败,进而执行不下去:

上图中,任务入列后立即被消费线程获取到并去修改数据库,而此时定时线程的 SQL 可能还没执行(可能网络延迟),这就出问题了。

定时线程先将状态改为“已入列”带来的问题是,如果改状态后(入列前)进程挂了,会导致任务一直处于已入列状态(但实际上未入列),所以还需要搭配其它的超时重试机制。

上图虚线框中那段逻辑在并发原语中有个专门名称叫“临界区”——我们要做的就是让多个操作者(进程、线程、协程)必须一个一个地(而不能一窝蜂地)去执行临界区内部的逻辑,手段就是加锁:

var lock = newLock()

// 加锁
lock.lock() // 执行临界区的逻辑 // 释放锁
lock.unlock()

所谓锁,就是多个参与者(进程、线程)争抢同一个共享资源(术语叫“信号量”),谁抢到了就有资格往下走,没抢到的只能乖乖地等(或者放弃)。锁的本质是两点:

  1. 它是一种共享资源,对于多方参与者来说,只有一个,就好比篮球场上只有一个篮球,所有人都抢这一个球;
  2. 对该资源的操作(加锁、解锁)是原子性的。虽然大家一窝蜂都去抢一个球,但最终这个球只会属于某一个人,不可能一半在张三手上,另一半在李四手上。只有抢到球的一方才可以执行后续流程(投篮),另一方只能继续抢;

在单个进程中,以上两点很容易实现:同一个进程中的线程之间天然是共享进程内存空间的;原子性也直接由 CPU 指令保证。所以单个进程中,我们直接用编程语言提供的锁即可。

进程之间呢?

进程之间的内存空间是独立的。两个进程(可能在两台不同的物理机上)创建的锁资源自然也是独立的——这就好比两个篮球场上的两个篮球之间毫不相干。

那怎样让两个篮球场上的两队人比赛呢?只能让他们去同一个地方抢同一个球——这在编程中叫“分布式锁”。

有很多实现分布式锁的方案(关系数据库、zookeeper、etcd、Redis 等),本篇单讲用 Redis 来实现分布式锁。

小试牛刀

之所以能用 Redis 实现分布式锁,依赖于其两个特性:

  1. Redis 作为独立的存储,其数据天然可以被多进程共享;
  2. Redis 的指令是单线程执行的,所以不会出现多个指令并发地读写同一块数据;
  3. Redis 指令是纯内存操作,速度是微妙级的(不考虑网络时延),性能足够高;

有些人一想到“单线程-高性能”就条件反射地回答 IO 多路复用,其实 Redis 高性能最主要就是纯内存操作。

Redis 分布式锁的大体调用框架是这样的:

多个进程的多个线程争抢同一把 Redis 锁。

说到 Redis 分布式锁,大部分人都会想到 setnx 指令:

// setnx 使用方式
SETNX key value

意思是:如果 key 不存在(Not eXists),则将 key 设置为 value 并返回 1,否则啥也不做并返回 0——也就是说, key 只会被设置一次,利用这个特性就可以实现锁(如果返回 1 表示加锁成功,0 则说明别人已经加锁了,本次加锁失败)。

我们写下伪代码:

// 获取 redis client 单例
var redis = NewRedisClient(redisConf); // 通过 SETNX 指令加锁
func lock(string lockKey) bool {
result = redis.setnx(lockKey, 1);
return bool(result);
} // 通过 DEL 指令解锁
func unlock(string lockKey) {
redis.del(lockKey);
}

上面的定时任务进程中这样使用:

var lockKey = "batch:task:list"

// 上锁
if (!lock(lockKey)) {
// 获取锁失败,直接返回
return false;
} try {
// 查询数据库获取待处理任务列表
// 更新任务状态
// 入列
} finally {
// 解锁
unlock(lockKey);
}

很简单!半小时搞定,上线!

第一次懵逼

上线没跑几天就出问题了:任务无缘无故地不执行了,消息队列中很长时间没接收到消息了。

分析了半天,我们发现 Redis 中一直存在 batch:task:list 这条记录,没人去删除它!

盯着代码我们突然发现问题所在:这个 key 压根没有过期时间!也就是说,如果程序不 DEL 它就永远存在。

估计某进程在执行 unlock 之前崩溃了(或者哪个愣头青执行了 kill -9 ?),或者 unlock 时发生了网络问题,或者 Redis 宕机了?总之 DEL 没执行,于是这个锁永远得不到释放!

好办,加上过期时间呗:

...

// 通过 SETNX 指令加锁
// 加上过期时间,单位毫秒
func lock(string lockKey, int ttl = 3000) bool {
// 加锁
result = redis.setnx(lockKey, 1);
// 设置过期时间(毫秒)
redis.pexpire(lockKey, ttl); return bool(result);
} ...

这段代码有什么问题呢?

这里通过两次网络请求执行了两条 Redis 指令:setnx 设置 KV,expire 设置超时时间——我们前面说锁操作必须具备原子性,但这两条操作谁也不能保证要么都成功要么都失败啊。假如第一条指令(setnx)执行成功了,但 expire 由于网络原因或者进程崩溃导致执行失败了呢?此时同样会出现上面那个懵逼的问题啊。

我们可以用 Lua 脚本实现 setnx 和 expire 操作的原子性,不过 Redis 2.6.12 版本后可以用 SET 指令搞定:

// 2.6.12 后的 SET 指令格式
// 现在的 SET 指令相当强大也相当复杂,可以替代 SETNX, SETEX, PSETEX, GETSET, 此处只写出跟分布式锁有关的
// 其中两个可选参数:
// -- NX 表示 Not eXists,就是 SETNX 的意思;
// -- PX 是 PEXPIRE 的意思,表示设置 key 的过期时间(毫秒); SET key value [NX] [PX milliseconds]

改下 Lock 代码:

// 加锁
func lock(string lockKey, int ttl = 3000) bool {
// Set 函数参数对应上面的命令格式
result = redis.set(lockKey, 1, "NX", "PX", ttl); return bool(result);
}

如此,加了过期时间防止锁无法释放,还保证了加锁操作的原子性,妥了,上线!

第二次懵逼

第二次上线没多久又出现了灵异事件:偶尔会出现一批任务重复入列——敢情这锁加了个寂寞?

各种打日志,终于发现了端倪:有个进程加锁 3.5 秒后才解锁,而且解锁成功了——但我们设置的锁超时时间是 3 秒啊!

也就是说,这个线程解的是别的线程的锁!

// 通过 DEL 指令解锁
// 这里直接调 Redis 的 DEL 指令删除 lockKey,并没有判断该 lockKey 的值是不是本进程设置的
// 所以在有 TTL 的情况下,删的可能是别的线程加的锁
func unlock(string lockKey) {
redis.del(lockKey);
}

和进程内的本地锁不同的是,Redis 分布式锁加入超时机制后,锁的释放就存在两种情况:

  1. 加锁者主动释放;
  2. 超时被动释放;

所以解锁(DEL)之前需要判断锁是不是自己加的,方法是在加锁的时候生成一个唯一标识。之前我们 SET key value 时 value 给的是固定值 1,现在我们换成一个随机值:

// Redis 分布式锁
// 封装成类
// 该类实例不具备线程安全性,不应跨线程使用
class Lock {
private redis;
private name;
private token;
private ttl;
private status; const ST_UNLOCK = 1;
const ST_LOCKED = 2;
const ST_RELEASED = 3; public function Lock(Redis redis, string name, int ttl = 3000) {
this.redis = redis;
this.name = name;
this.token = randStr(16);// 生成 16 字节随机字符串
this.ttl = ttl;
this.status = self::ST_UNLOCK;
} // 加锁
public function lock() bool {
if (this.status != self::ST_UNLOCK) {
return false;
} // 使用 SET 命令加锁
// value 不再传 1,而是设置成构造函数中生成的随机串
try {
result = redis.set(this.name, this.token, "NX", "PX", this.ttl);
if (bool(result)) {
this.status = self::ST_LOCKED;
return true;
}
} catch (Exception e) {
return false;
} return false;
} // 解锁
public function unlock() {
if (this.status != self::ST_LOCKED) {
return;
} // 执行 DEL 之前需要用 GET 命令判断 KEY 的值是不是当前的 token
// 由于需要执行 GET 和 DEL 两条指令,而锁操作必须保证原子性,需要用 Lua 脚本
// 脚本中通过 redis.call() 执行 Redis 命令
// 注意 Lua 脚本数组下标从 1 开始
// 这段脚本的意思是:
// 如果 key 的值是 token,则 DEL key,否则啥也不做
var lua = "
if (redis.call('get', KEYS[1]) == ARGV[1]) then
redis.call('del', KEYS[1]);
end
return 1;
"; // 调 Redis 的 EVEL 指令执行 Lua 脚本
// EVAL 指令格式:
// EVEL script numkeys key1,key2,arg1,arg2...
// -- script: Lua 脚本
// -- numkeys: 说明后面的参数中,有几个是 key,这些 key 后面的都是参数
// 比如:EVAL "redis.call('set', KEYS[1], ARGV[1])" 1 mykey hello
// 等价于命令 SET mykey hello
// 参见:https://redis.io/commands/eval/
redis.eval(lua, 1, this.name, this.token);
this.status = self::ST_RELEASED;
}
}

业务调用:

lock = new Lock(redis, "batch:task:list");

try {
if (!lock.lock()) {
return false;
} // 加锁成功,执行业务
} finally {
lock.unlock();
}

上面这段代码实现了:

  1. 加锁的时候设置了过期时间,防止进程崩溃而导致锁无法释放;
  2. 解锁的时候判断了当前的锁是不是自己加的,防止释放别人的锁;
  3. 加锁和解锁操作都具备原子性;

这段代码已经是生产可用了,第三次上线。

不过,还是有些优化需要做的。

优化一:锁等待

上面的 lock() 方法中,如果获取锁失败则直接返回 false,结束执行流,这可能不能满足某些业务场景。

在本地锁场景中,如果获取锁失败,线程会进入阻塞等待状态——我们希望分布式锁也能提供该功能。

我们在加锁失败时增加重试功能:

class Lock {
// 重试间隔:1 秒
const RETRY_INTERVAL = 1000;
// ...
// 重试次数(包括首次)
private retryNum; // retryNum: 默认只执行一次(不重试)
public function Lock(Redis redis, string name, int ttl = 3000, int retryNum = 1) {
...
// 做下防御
if (retryNum < 0 || retryNum > 20) {
retryNum = 1;
}
this.retryNum = retryNum;
} // 加锁
public function lock() bool {
if (this.status != self::ST_UNLOCK) {
return false;
} // 使用 SET 命令加锁
// 加入重试机制
for (i = 0; i < this.retryNum; i++) {
try {
result = redis.set(this.name, this.token, "NX", "PX", this.ttl);
if (bool(result)) {
// 加锁成功,返回
this.status = self::ST_LOCKED;
return true;
}
} catch (Exception e) {
} // 加锁失败了,等待一定的时间后重试
// 当前线程/协程进入休眠
sleep(self::RETRY_INTERVAL);
} return false;
}
}

优化二:锁超时

我们再回头看看上面的加锁逻辑,其核心代码如下:

public function lock() bool {
// ...
result = redis.set(this.name, this.token, "NX", "PX", this.ttl);
if (bool(result)) {
// 加锁成功,返回
this.status = self::ST_LOCKED;
return true;
}
//...
}

这段代码有没有什么问题呢?

想象如下的加锁场景:

// 锁超时时间是 2 秒
var lock = new Lock(redis, name, 2000); if (lock.lock()) {
// 加锁成功,加锁用时 2.5 秒
try {
// 执行业务逻辑
} finally {
// 解锁
lock.unlock();
}
}

如上,我们创建一个有效期 2 秒的锁,然后调 Redis 命令加锁,该过程花了 2.5 秒(可能网络抖动)。

对于本线程来说,得到加锁成功的返回值,继续往下执行。

但此时该 lockKey 在 Redis 那边可能已经过期了,如果此时另一个线程去拿锁,也会成功拿到锁——如此锁的作用便失效了。

所以,在 lock() 方法中,调 Redis 上锁成功后,需要判断上锁用时,如果时间超过了锁的有效期,则应视为上锁无效,如果有重试机制,则重试:

class Lock {
// 加锁
public function lock() bool {
if (this.status != self::ST_UNLOCK) {
return false;
} for (i = 0; i < this.retryNum; i++) {
try {
// 上锁之前,保存当前毫秒数
var startTime = getMillisecond();
// 上锁
result = redis.set(this.name, this.token, "NX", "PX", this.ttl);
// 上锁后,计算使用的时间
var useTime = getMillisecond() - startTime; // 加锁成功条件:Redis 上锁成功,且所用的时间小于锁有效期
if (bool(result) && useTime < this.ttl) {
// 加锁成功,返回
this.status = self::ST_LOCKED;
return true;
}
} catch (Exception e) {} // 加锁失败了,等待一定的时间后重试
// 当前线程/协程进入休眠
sleep(self::RETRY_INTERVAL);
} return false;
}
}

如上,在判断条件中增加了加锁用时的判断。

这段代码还有问题吗?

有的。

我们用 Redis 的 SET NX 命令加锁,该命令如果发现 key 已经存在,则直接返回 0,加锁失败。

在上面的失败重试逻辑中,如果是因为加锁用时超限导致的失败(锁有效期是 2 秒,结果加锁操作用了 2.5 秒),此时我们并不能切确知道在 Redis 那边该 key 是否真的已经失效了,如果没有失效(比如来去网络用时各 1.24 秒,此时该 key 并没有失效),那么下一次的重试会因 SET NX 的机制而失败。

所以我们不能用 SET NX 加锁,只能用普通的 SET + Lua 脚本来实现:

class Lock {
// 加锁
public function lock() bool {
if (this.status != self::ST_UNLOCK) {
return false;
} // 加锁的 Lua 脚本
// 注意 Lua 中的注释不是用 // 或者 /**/,而是用 --
// 参数说明:
// KEYS[1]: lockKey
// ARGV[1]: token
// ARGV[2]: ttl 毫秒
var lua = "
local val = redis.call('get', KEYS[1]);
if (not val) then
-- 没有设置,则直接设置
return redis.call('set', KEYS[1], ARGV[1], 'PX', ARGV[2]);
else
-- 存在,则比较 val 是否等于 token
if (val == ARGV[1] ) then
-- 该 key 就是当前线程设置的
-- 延长其 TTL
return redis.call('pexpire', KEYS[1], ARGV[2]);
else
-- 其他线程上的锁
return 0;
end
end
"; for (i = 0; i < this.retryNum; i++) {
// 加锁逻辑同上
} return false;
}
}

如此,便解决了加锁超时导致的竞态问题——但只解决了一半。

设想这样的场景:

进程 A 加了一个有效期 5 秒的锁,加锁成功后执行业务逻辑,业务逻辑执行耗时 10 秒——就是说,在业务逻辑执行到差不多一半的时候锁就失效了,此时别的进程就可以抢到锁了,这就会导致竞态问题。

有两种解决方案:

  1. 设置个较长的过期时间。这是最简单的(而且也很有效)。比如我们预估 99% 的处理时间不超过 2 秒,则将锁有效期设置为 10 秒。该方案最大的缺点是一旦进程崩溃导致无法主动释放锁,就会导致其他进程在很长一段时间内(如 10 秒)无法获得锁,这在某些场景下可能是非常严重的。
  2. 搞个定时任务线程,定时延长锁的有效期。

方案二伪代码如下:

// 带 Refresh 版本的分布式锁
class Lock {
private redis;
private name;
private ttl;
private token;
private retryNum;
private status;
// 定时器
private timer; // 锁状态:1 未加锁;2 已加锁;3 已释放
const ST_UNLOCK = 1;
const ST_LOCKED = 2;
const ST_RELEASED = 3; // 刷新状态:
// 4 刷新成功;
// 5 非法(key 不存在或者不是本线程加的锁)
// 6 刷新失败(Redis 不可用)
const RF_SUC = 4;
const RF_INVALID = 5;
const RF_FAIL = 6; // 构造函数
public function Lock(Redis redis, string name, int ttl = 2000, int retryNum = 1) {
...
} // 加锁
// 加锁成功后启动定时器
public function lock() bool {
if (this.status != self::ST_UNLOCK) {
return false;
} // 加锁的 Lua 脚本,同前面的
lua = "..."; for (i = 0; i < this.retryNum; i++) {
var startTime = getMillisecond();
try {
// 执行 Lua 脚本上锁
result = this.redis.eval(lua, 1, this.name, this.token, this.ttl);
var useTime = getMillisecond() - startTime; if (bool(result) && useTime < this.ttl) {
// 加锁成功
this.status = self::ST_LOCKED;
// 启动定时器
this.tick(); return true;
}
} catch (Exception e) {
// Redis 不可用
} // 失败重试
sleep(RETRY_INTERVAL);
} return false;
} // 启动定时器,定时刷新过期时间
private function tick() {
this.timer = startTimerInterval(
this.ttl / 3,
function () {
result = this.refresh();
if (result == self::RF_INVALID) {
// key 不存在,或者该锁被其他线程占用
// 停掉定时器
this.timer.stop();
}
}
);
} // 释放锁
// 需要停掉定时器
public function unlock() {
if (this.status != self::ST_LOCKED) {
return;
} // 释放锁的 Lua 脚本,同前
var lua = "..."; try {
this.redis.eval(lua, 1, this.name, this.token);
} catch (Exception e) {} finally {
this.status = self::ST_RELEASED;
// 停掉定时器
this.timer.stop();
}
} // 刷新锁过期时间
private function refresh() int {
if (this.status != self::ST_LOCKED) {
return self::RF_INVALID;
} var lua = "
-- key 存在而且其值等于 token 才刷新过期时间
if (redis.call("get", KEYS[1]) == ARGV[1]) then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
"; try {
result = this.redis.eval(lua, 1, this.name, this.token, this.ttl);
if (result == 0) {
// key 不存在或者是别人加的锁
return self::RF_INVALID;
} else {
// 刷新成功
return self::RF_SUC;
}
} catch (Exception e) {
// Redis 不可用
return self::RF_FAIL;
}
}
}

如上,加锁成功后创建一个单独的定时器(独立的线程/协程)刷新锁的 TTL,只要锁没被主动释放(而且进程没有崩溃),就会不停地续命,保证不会过期。此时,我们就能在加锁时选择一个比较小的过期时间(比如 2 秒),一旦进程崩溃,其他进程也能较快获得锁。

上面定时器时间为何选择 ttl/3 呢?

假设锁过期时间(ttl)为 6 秒,由上面 lock() 函数逻辑可知,加锁耗时不可能超过 6 秒(超过就会判定为加锁失败)。我们假设某次加锁耗时比 6 秒小那么一丢丢(也就是近似 6 秒),接下来什么时候发起第一次刷新才能保证 Redis 那边的 key 不过期呢?极端情况下必须立即刷新(如果考虑刷新时的网络时延,就算立即刷新也不一定能保证)。

不过我们考虑的是一般情况。我们可以认为 6 秒耗时都花在网络上(Redis 本身执行时间可以忽略不计),然后再近似认为这 6 秒被来去均摊,各花 3 秒,因而当我们接收到 Redis 的响应时,该 key 在 Redis 那边的 TTL 已经用掉了一半,所以定时间隔必须小于 ttl/2,再将刷新时的网络时延考虑进去,取 ttl/3 或者 ttl/4 比较合适。

就算有了 refresh 机制,也不能说是万无一失了。

考虑 Redis 宕机或者网络不通的情况。

假设线程 A 加锁(ttl=2s)后不久 Redis 就宕机了(或者该业务服所在网络发生分区导致网络不通),宕机期间 refresh 会失败。2s 后 Redis 重启恢复正常,此时线程 A 设置的那个 key 已经过期了,其他线程就能够获取锁,如果线程 A 的执行时间超过 2s,就和其他线程产生竞态。

refresh 机制解决不了该问题,要用其他手段来保证 Redis 和锁的高可用性,如 Redis 集群、官方提供的 Redlock 方案等。

可重入性

一些语言(如 java)内置可重入锁,一些语言(如 go)则不支持。

我们通过代码说下可重入锁是什么:

var lock = newLock();

// 在同一个线程中, foo() 调 bar()
// 函数 foo() 和 bar() 都在竞争同一把锁 function foo() {
lock.lock();
...
bar();
...
lock.unlock();
} function bar() {
lock.lock();
// do something
lock.unlock();
}

如上,同一个线程中 foo() 调 bar(),由于 foo() 调 bar() 之前加了锁,因而 bar() 中再竞争该锁时就会一直等待,导致 bar() 函数执行不下去,进而导致 foo() 函数无法解锁,于是造成死锁。

如果上面的 lock 是一把可重入锁,bar() 就会加锁成功。

实现原理是:加锁的 lock() 方法中会判断当前这把锁被哪个线程持有,如果持有锁的线程和现在抢锁的线程是同一个线程,则视为抢锁成功(这锁本来就是被它持有的嘛,抢啥呢)。

由于 foo() 和 bar() 是在同一个线程中调用的,所以他俩都会加锁成功。

锁是加成功了,解锁呢?bar() 中的 unlock() 要怎么处理呢?直接把锁释放掉?不行啊,foo() 中的 unlock() 还没执行呢,bar() 虽然用完锁了,但 foo() 还没用完啊,你 bar() 三下五除二把锁给释放了,其他线程拿到锁,不就和 foo() 中代码构成竞态了吗?

所以可重入锁采用信号量的思想,在内部维持了两个属性:threadid 表示哪个线程持有锁;lockNum 表示持有线程加了几次锁。同一个线程,每 lock() 一次 lockNum 加 1,每 unlock() 一次 lockNum 减 1,只有 lockNum 变成 0 了才表示这把锁真正释放了,其他线程才能用。

原理讲完了,但你不觉得上面的代码很怪吗?

既然 foo() 已经加锁了,bar() 为何还要加同一把锁呢?

在某些情况下这样做可能是有原因的,但大多数情况下,这个问题可以从设计上解决,而不是非要引入可重入锁。

比如我们可以将 bar() 声明为非线程安全的,将加锁工作交给调用者,同时限制 bar() 的可见域,防止其被滥用。

go 语言不支持可重入锁的理由就是:当你的代码需要用可重入锁了,你首先要做的是审视你的设计是否有问题。

可重入锁的便捷性可能会带来代码设计上的问题。

所以本篇并不打算去实现可重入能力——虽然实现起来并不难,无非是将上面讲的原理在 Redis 上用 Lua 脚本实现一遍而已。

不是银弹

有了锤子,全世界都是钉子。

分布式锁看似是颗银弹,但有些问题用其他方案会比分布式锁要好。

我们看看秒杀扣库存的例子。

网上很多讲分布式锁的文章都拿秒杀扣库存来举例。

秒杀场景为了应对高并发,一般会将秒杀商品库存提前写入到 Redis 中,我们假设就用字符串类型存商品库存:

// Redis 命令,设置商品 id=1234 的库存 100 件
set seckill.stock.1234 100

另外一个用户只能参加一次秒杀,所以扣库存前需要判断该用户是否已经参加了(防止羊毛党薅羊毛)。

扣库存逻辑是这样的:

var stockKey = "seckill.stock.1234";
var userKey = "seckill.ordered.users";
var lock = new Lock(redis, "seckill"); // 此处省略活动时间的判断 try {
// 加分布式锁
lock.lock(); // 判断库存
var stockNum = redis.get(stockKey);
if (stockNum <= 0) {
// 库存不足
return false;
} // 判断用户是否已经参加过
if (redis.sismember(userKey, userId)) {
return false;
} // 扣库存
if (redis.decr(stockKey) >= 0) {
// 下单
...
} else {
return false;
} // 将用户加入到已参加集合中
redis.sadd(userKey, userId);
return true;
} catch (Exception e) {
// 异常
} finally {
// 解锁
lock.unlock();
}

以上逻辑为何要用分布式锁呢?

假设不用分布式锁,羊毛党同时发了十个请求(同一个用户),由于 redis.sismember(userKey, userId) 判断都会返回 0,于是都能扣库存下单,羊毛薅了一地。

但该场景有没有更优的解决方案呢?

我们使用分布式锁是为了保证临界区代码(lock 保护的区域)执行的原子性——不过 Redis 的原子性还可以通过 Lua 脚本来实现吧。

上面代码一共进行了 6 次 Redis 交互,假设每次用时 50ms,光 Redis 交互这块就用了 0.3s 的时间。

如果我们将这些逻辑封装成 Lua 脚本,只需要一次 Redis 交互就能保证原子性:

var lua = "
-- 参数说明:
-- KEYS[1]: actKey
-- KEYS[2]: userKey
-- KEYS[3]: stockKey
-- ARGV[1]: userId -- 判断活动时间
-- (事先将活动的关键信息保存到 Redis hash 中)
-- 取活动的开始和结束时间
local act = redis.call('hmget', KEYS[1], 'start', 'end');
local now = redis.call('time')[1];
if (not act[1] or now < act[1] or now >= act[2])
then
return 0;
end -- 判断库存
local stock = redis.call('get', KEYS[3]);
if (not stock or tonumber(stock) <= 0)
then
return 0;
end -- 判断用户是否已经参与过
if (redis.call('sismember', KEYS[2], ARGV[1]) == 1)
then
return 0;
end -- 扣库存
if (redis.call('decr', KEYS[3]) >= 0)
then
-- 加入用户
return redis.call('sadd', KEYS[2], ARGV[1]);
else
return 0;
end
"; var actKey = "seckill.act."+actId;
var userKey = actKey + ".users";
var stockKey = actKey + ".stock." + goodsId; if (redis.eval(lua, 3, actKey, userKey, stockKey, userId)) {
// 扣库存成功,下单
...
}

上面的脚本还可以先缓存到 Redis 服务器中,然后用 evalsha 命令执行,这样客户端就不用每次都传这么一大坨代码,进一步提升传输性能。

总结

本篇我们从 setnx 命令开始实现了一个最简单的分布式锁,而后通过实际使用发现其存在各种缺陷并逐步增强其实现,主要涉及到以下几个方面:

  1. 被动释放。进程崩溃后,进程本地锁自然会销毁,但 Redis 锁不会。所以要加 TTL 机制,防止因加锁者崩溃而导致锁无法释放;
  2. 属主。线程不能释放别的线程的锁;
  3. 锁等待。加锁失败时可以等待一段时间并重试,而不是立即返回;
  4. 保活。通过定时刷新锁的 TTL 防止被动释放;

不难发现,分布式锁比进程内本地锁要复杂得多,也重得多(本地锁操作是纳秒级别,分布式锁操作是毫秒级别),现实中,在使用分布式锁之前我们要思考下有没有其它更优方案,比如乐观锁、Lua 脚本等。

另外需要注意的是,分布式锁只能解决多进程之间的并发问题,并不能实现数据操作的幂等性。一个例子是增减积分的操作。

增加积分的例子:

// 给用户增加积分
// sourceType、sourceId:积分来源标识,如消费赠送积分场景的 orderCode
// 幂等性:同样的 userId-sourceType-sourceId 不能重复加积分
function addBonus(userId, sourceType, sourceId, bonus) {
// 加分布式锁
var lock = new Lock(...); try {
if (!lock.lock()) {
return false;
} // 检查是否重复
if (isRepeat(userId, sourceType, sourceId)) {
return false;
} // 加积分
add(userId, sourceType, sourceId, bonus);
} finally {
lock.unlock();
}
}

上面分布式锁的作用是防止并发请求(调用端 bug?薅羊毛?),而该操作的幂等性是由 isRepeat() 保证的(查数据库)。

保障幂等性一般有悲观锁和乐观锁两种模式。

上面这种属于悲观锁模式(把整个操作锁起来),另一种乐观锁实现方式是给 userId-sourceType-sourceId 加上组合唯一键约束,此时就不需要加分布式锁,也不需要 isRepeat() 检测,直接 add(userId, sourceType, sourceId, bonus) 就能搞定。

最后说下文中为啥使用伪代码(而不是用具体某一门编程语言实现)。

用伪代码的最主要目的是省去语言特定的实现细节,将关注点放在逻辑本身。

比如 redis 客户端,不同语言有不同的使用方式,就算同一门语言的不同类库用法也不同,有些语言的类库用起来又臭又长,影响心情。

伪代码不受特定语言约束,用起来自由自在,本文中 redis 客户端的使用方式和 Redis 官方的原始命令格式完全一致,没有额外的心智负担。

再比如生成 token 的随机字符串函数 randStr(),go 语言要这样写:

func randStr(size int) (string, error) {
sl := make([]byte, size)
if _, err := io.ReadFull(rand.Reader, sl); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(sl), nil
}

代码虽然不多,但没玩过 go 的小伙伴看到这儿心里是不是要起伏那么两三下?但这玩意怎么实现跟本文的主题没半毛钱关系。

相反,本文的 lua 脚本都是货真价实的,测试通过的——因为这是本文的核心啊。

伪代码的缺点是它不能“拎包入住”,但本文的重点并不是要写个源码库——我们没必要真的自己写一个,直接用 redission 或者其他什么库不香吗?

本文的重点在于分析 Redis 分布式锁的原理,分布式锁面临哪些问题?解决思路是什么?使用时要注意什么?知其然知其所以然。

当你不知其所以然时,很多东西显得特高大上,什么“看门狗”,搞得神乎其神,当搞明白其原理和目的时,也就那么回事。

循序渐进 Redis 分布式锁(以及何时不用它)的更多相关文章

  1. redis分布式锁和消息队列

    最近博主在看redis的时候发现了两种redis使用方式,与之前redis作为缓存不同,利用的是redis可设置key的有效时间和redis的BRPOP命令. 分布式锁 由于目前一些编程语言,如PHP ...

  2. spring boot redis分布式锁

    随着现在分布式架构越来越盛行,在很多场景下需要使用到分布式锁.分布式锁的实现有很多种,比如基于数据库. zookeeper 等,本文主要介绍使用 Redis 做分布式锁的方式,并封装成spring b ...

  3. Redis分布式锁的try-with-resources实现

    Redis分布式锁的try-with-resources实现 一.简介 在当今这个时代,单体应用(standalone)已经很少了,java提供的synchronized已经不能满足需求,大家自然 而 ...

  4. Redlock(redis分布式锁)原理分析

    Redlock:全名叫做 Redis Distributed Lock;即使用redis实现的分布式锁: 使用场景:多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击) ...

  5. SpringBoot集成Redis分布式锁以及Redis缓存

    https://blog.csdn.net/qq_26525215/article/details/79182687 集成Redis 首先在pom.xml中加入需要的redis依赖和缓存依赖 < ...

  6. Redis全方位详解--数据类型使用场景和redis分布式锁的正确姿势

    一.Redis数据类型 1.string string是Redis的最基本数据类型,一个key对应一个value,每个value最大可存储512M.string一半用来存图片或者序列化的数据. 2.h ...

  7. 白话Redis分布式锁

    redis分布式 简单来说就是,操作redis实例时,不是常规(单机)操作一个实例,而是操作两台或多台,也就是基于分布式集群: 而且redis内部是单进程.单线程,是数据安全的(只有自己的线程在操作数 ...

  8. spring boot redis分布式锁 (转)

    一. Redis 分布式锁的实现以及存在的问题 锁是针对某个资源,保证其访问的互斥性,在实际使用当中,这个资源一般是一个字符串.使用 Redis 实现锁,主要是将资源放到 Redis 当中,利用其原子 ...

  9. 单实例redis分布式锁的简单实现

    redis分布式锁的基本功能包括, 同一刻只能有一个人占有锁, 当锁被其他人占用时, 获取者可以等待他人释放锁, 此外锁本身必须能超时自动释放. 直接上java代码, 如下: package com. ...

随机推荐

  1. 如何调试手机上的网页以及基于Cordova/Phonegap的Hybrid应用

    开发手机页面以及Hybird应用时,调试曾经是个老大难问题,不时需要用写log等方式曲线救国. 实际上,Chrome和Android(需要4.4+版本)已经提供了不亚于电脑版本的调试功能,只是看样子还 ...

  2. XGBoost文本分类,多分类、二分类、10-Fold(K-Fold)

    做机器学习的时候经常用到XGB,简单记录一下 K折交叉验证也是模型常用的优化方法.一起记录... K折交叉验证:类似三个臭皮匠,顶个诸葛亮.我的理解是,就是用民主投票的方式,选取票数最高的那个当结果. ...

  3. 技术管理进阶——一线Leader与大Leader的差异是什么?

    原创不易,求分享.求一键三连 ​ 前几年,因为下属的一些骚操作,Leader对我产生了误会,这导致了一些"不信任",这种不信任在短时间会让人丧失安全感,很多人会因此患得患失. 这种 ...

  4. [AcWing 53] 最小的 k 个数

    堆排序 点击查看代码 class Solution { public: vector<int> getLeastNumbers_Solution(vector<int> inp ...

  5. npm install xxxx --legacy-peer-deps命令是什么?

    摘要:带着好奇心,研究学习了一番npm install xxxx --legacy-peer-deps命令是什么?为什么可以解决下载时候产生的依赖冲突呢? 本文分享自华为云社区<npm inst ...

  6. Jenkins Build step 'Execute shell' marked build as failure

    问题出现: Jenkins一直都构建成功,今天突然报错:Jenkins Build step 'Execute shell' marked build as failure 问题原因: By defa ...

  7. sql索引优化思路

    [开发]SQL优化思路(以oracle为例) powered by wanglifeng https://www.cnblogs.com/wanglifeng717 单表查询的优化思路 单表查询是最简 ...

  8. 程序员不得不知道的 API 接口常识

    说实话,我非常希望两年前刚准备找实习的自己能看到本篇文章,那个时候懵懵懂懂,跟着网上的免费教程做了一个购物商城就屁颠屁颠往简历上写. 至今我仍清晰地记得,那个电商教程是怎么定义接口的: 管它是增加.修 ...

  9. Java 统计新客户

    上周做了一个订单数据统计的任务,统计的是订单的新客户数量,本文做一个解题过程的记录和整理. 新客户的定义 新客户指的是选取时间段有订单,时间段之前没有订单. 比如下面的订单数据: 时间段 2月1日之前 ...

  10. Vue2手写源码---响应式数据的变化

    响应式数据变化 数据发生变化后,我们可以监听到这个数据的变化 (每一步后面的括号是表示在那个模块进行的操作) 手写简单的响应式数据的实现(对象属性劫持.深度属性劫持.数组函数劫持).模板转成 ast ...