这是我参与 8 月更文挑战的第 17 天,活动详情查看: 8月更文挑战
如非必要切勿用锁。一方面锁会将并行逻辑转成串行严重影响性能,另一方面还要考虑锁的容错,处理不当可能导致死锁。
分布式锁的替代方案
Set化后的MQ替代分布式锁
可以按用户ID做Set(用户ID % Set数)进而分成多个组,为不同的组创建不同的MQ队列,这样一个用户同一时间只在一个队列中,一个队列的处理是串行化的,实现了锁的功能,同时又有多个Set来完成并行化,在性能上会好于分布式锁。
使用乐观锁
创建一个更新版本字段(update_version),每次更新时版本加1,更新的条件是版本号要等于传入版本号:
var (balance,currentVersion) = db.account.getBalanceAndVersion(id)
if(balance < amount){
return error("余额少于扣款金额")
}
// 此操作对应的SQL: UPDATE account SET balance = balance - <amount> , update_verison = update_verison + 1 WHERE id = <id> AND update_version = <currentVersion>
if(db.account.updateBalance(id, -amount, currentVersion) == 0){
return error("扣款失败") // 或递归执行此代码进行重试
}
使用分布式锁需要注意的问题
锁释放与超时
在单机情况下,我们只需要在使用完锁后在finally代码中将其释放掉,但在分布式环境下由于网络是不可靠的,节点可能宕机,会导致锁无法释放,所以我们必须要设置超时时间。超时时间设置过长会导致服务异常后无法及时获取新的锁,过短又有可能在业务没有执行完锁提前释放了。优雅但复杂的方法是使用心跳超时设置,即与占有锁的服务保持心跳,在心跳超时后再释放锁。
性能及高可用
出于性能考虑,一般分布式锁都是非公平锁,如果要保证加锁顺序而选用公平锁时要注意对性能的影响,加解锁操作本身要保证性能及可用性,避免单点,锁信息要持久化,慎用自旋避免CPU浪费。
在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程 A 持有一个锁,并且线程 B 请求这个锁。由于这个锁已被线程 A 持有,因此 B 将被挂起。当 A 释放锁时,B 将被唤醒,因此会再次尝试获取锁。与此同时如果 C 也请求这个锁,那么 C 很可能会在 B 被完全唤醒之前获得、使用以及释放这个锁。这样 的情况是一种“双赢”的局面:B 获得锁的时刻并没有推迟,C 更早地获得了锁,并且吞吐量也获得了高。
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么应该使用公平锁。在这些情况下,”插队”带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)则可能不会出现
—— 《Java并发编程实战》
数据一致性
分布式锁要合理设置锁标记以区分是哪个实例、哪个线程的操作,可重入锁要做好计数及相应的unlock次数,同时必须保证数据的一致,这要求我们只能选择CP特性的服务作为分布式锁的中间件。
主流的分布式锁
关系型数据库
由关系型数据库的某些特性来实现,比如使用主键唯一性约束及数据一致来确保同一时间只有一个请求能获得锁,这一方案实现简单,但对高并发场景或可重入时存在比较大的性能瓶颈。
Redis
可使用Redis单线程、原子化操作(setnx)来实现,这一方案也很简单,但因为没有原子化的值比较方法,无法原子化确认占用锁的是否是当前实例的当前线程导致比较难实现重入锁,另外Redis单节点有高可用问题,多节点引入RedLock也存在比较大的争议。当然在绝大多数情况下大家还是可以放心使用的。
Zookeeper
可使用Zookeeper的持久节点(PERSISTENT)、临时节点(EPHEMERAL),时序节点(SEQUENTIAL )的特性组合及watcher接口实现,这一方案可保证最为严格的数据一致性、在性能及高可用也有着比较好的表现,推荐对一致性高要求极高、并发量大的场景使用。