场景
假设我们有个批处理服务,实现逻辑大致是这样的:
- 用户在管理后台向批处理服务投递任务;
- 批处理服务将该任务写入数据库,立即返回;
- 批处理服务有启动单独线程定时从数据库获取一批未处理(或处理失败)的任务,投递到消息队列中;
- 批处理服务启动多个消费线程监听队列,从队列中拿到任务并处理;
- 消费线程处理完成(成功或者失败)后修改数据库中相应任务的状态;
流程如图:
现在我们单独看看上图中虚线框中的内容(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()
所谓锁,就是多个参与者(进程、线程)争抢同一个共享资源(术语叫“信号量”),谁抢到了就有资格往下走,没抢到的只能乖乖地等(或者放弃)。锁的本质是两点:
- 它是一种共享资源,对于多方参与者来说,只有一个,就好比篮球场上只有一个篮球,所有人都抢这一个球;
- 对该资源的操作(加锁、解锁)是原子性的。虽然大家一窝蜂都去抢一个球,但最终这个球只会属于某一个人,不可能一半在张三手上,另一半在李四手上。只有抢到球的一方才可以执行后续流程(投篮),另一方只能继续抢;
在单个进程中,以上两点很容易实现:同一个进程中的线程之间天然是共享进程内存空间的;原子性也直接由 CPU 指令保证。所以单个进程中,我们直接用编程语言提供的锁即可。
进程之间呢?
进程之间的内存空间是独立的。两个进程(可能在两台不同的物理机上)创建的锁资源自然也是独立的——这就好比两个篮球场上的两个篮球之间毫不相干。
那怎样让两个篮球场上的两队人比赛呢?只能让他们去同一个地方抢同一个球——这在编程中叫“分布式锁”。
有很多实现分布式锁的方案(关系数据库、zookeeper、etcd、Redis 等),本篇单讲用 Redis 来实现分布式锁。
小试牛刀
之所以能用 Redis 实现分布式锁,依赖于其三个特性:
- Redis 作为独立的存储,其数据天然可以被多进程共享;
- Redis 的指令是单线程执行的,所以不会出现多个指令并发地读写同一块数据;
- 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 之前崩溃