关于锁
先简单归纳下对锁的理解:
- "锁" 是资源分配的需求产物, 无论是现实生活中用的锁还是代码架构上的锁.
- "锁" 既是一个工具, 也是一种机制, 在代码或者架构逻辑中意味着同步化.
- "锁" 本质是内存中的一个标识, 同时更是维护这个标识的一整套逻辑.
- JDK 提供的锁是 JVM 维度的锁, 只能作用于同一个 JVM 内的不同线程.
- 为了同步不同 JVM 间的线程进入临界区, 分布式锁应运而生.
基于 Redis 的分布式锁
0. 预设一个场景
就拿用例用到烂的秒杀系统来说吧, 毕竟经典场景.
简要逻辑如下图, 服务部署在不同的机器上:
graph TB
C(请求) --访问--> B[服务] --操作--> A[(库存信息)]
E(请求) --访问--> D[服务] --操作--> A
G(请求) --访问--> F[服务] --操作--> A
短时间内大量的秒杀请求打在不同的服务上, 服务对库存信息的写入必定要同步化.
那么从设计的角度如何设计这些服务呢, 比较理智一点的做法是引入只能被一个服务持有的第三方变量, 只有持有这个变量的服务才能访问库存信息.
这个变量和这套规则便是分布式锁.
graph TB
C(请求) --访问--> B[服务] --获取--> L{{"独立于服务外的变量 x (锁标志, 同一时间只有一个线程能获取到)"}} --操作--> A[(库存信息)]
E(请求) --访问--> D[服务] --获取--> L
G(请求) --访问--> F[服务] --获取--> L
接下来逐步实现.
1. 最简单的业务逻辑
假设库存信息已经存好在 Redis 服务里, 键是 "stock", 值为整数(暂无业务意义, 只为了标识有这个键值对).
最简单的业务逻辑可以写成这样:
@RestController
@RequestMapping("/step1")
public class Step1Controller {
private final BoundValueOperations<String, String> stockOps;
public Step1Controller(RedisTemplate<String, String> redisTemplate) {
this.stockOps = redisTemplate.boundValueOps("stock");
}
@PostMapping("/product")
public synchronized Object submitOrder() {
try {
// 拿
long stock = Long.parseLong(Objects.requireNonNull(stockOps.get()));
// 改
if (stock > 0) {
stock--;
}
// 存
stockOps.set(String.valueOf(stock));
} catch (Exception e) {
return "秒杀失败";
}
return "秒杀成功";
}
}
直接对库存进行操作, 问题很明显, 只加了 synchronized , 这样的逻辑只适合在单机应用中.
2. 带超时的简单分布式锁
将分布式锁的锁标志存在 Redis 内, 加上加锁/解锁逻辑, 便是一个最简单的分布式锁.
这个逻辑中还特地加上了锁自动过期的逻辑, 有效防止了程序在释放锁前崩溃导致其它线程永远无法获取锁, 从而导致死锁.
@RestController
@RequestMapping("/step2")
public class Step2Controller {
private final RedisTemplate<String, String> redisTemplate;
private final BoundValueOperations<String, String> stockOps;
private final BoundValueOperations<String, String> lockOps;
public Step2Controller(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.stockOps = redisTemplate.boundValueOps("stock");
this.lockOps = redisTemplate.boundValueOps("lock");
}
@PostMapping("/product")
public Object submitOrder() {
try {
// 加锁 设置超时锁自动释放
boolean locked = Boolean.TRUE == lockOps.setIfAbsent("1", 15, TimeUnit.SECONDS);
if (locked) {
// 拿 改 存 的业务逻辑
}
return "秒杀成功";
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
if (lockOps.get() != null) {
redisTemplate.delete("lock");
}
}
return "秒杀失败";
}
}
锁操作的具体逻辑不应耦合在业务代码中, 可以抽离成 RedisUtils :
@Component
public class RedisUtils {
public RedisTemplate<String, String> getRedisTemplate() {
return this.redisTemplate;
}
private final RedisTemplate<String, String> redisTemplate;
private final BoundValueOperations<String, String> lockOps;
private static final String LOCK = "lock";
public RedisUtils(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.lockOps = redisTemplate.boundValueOps(LOCK);
}
/**
* 加锁逻辑
* @return boolean
*/
public boolean tryLock() {
return Boolean.TRUE == lockOps.setIfAbsent("1", 15, TimeUnit.SECONDS);
}
/**
* 释放锁逻辑
*/
public void unlock() {
if (lockOps.get() != null) {
redisTemplate.delete(LOCK);
}
}
}
然后业务代码就可以写成这样了:
@PostMapping("/product")
public synchronized Object submitOrder() {
try {
// 获取锁
if (redisUtils.tryLock()) {
// 拿 改 存 的业务逻辑
return "秒杀成功";
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
redisUtils.unlock();
}
return "秒杀失败";
}
不过这样还存在严重的问题: 谁都能闲着没事来解一下锁!
这就有可能导致, 别的程序员在某个地方直接调用 redisUtils.unlock(), 本来有锁的直接被解了.
所以正常情况下, 分布式锁的加锁和解锁的动作必须由同一个线程来完成.
要达到这个要求也不难, 如下.
3. 带调用闭环逻辑的简单分布式锁
"调用闭环" 指的是一个线程调用了这个分布式锁的加锁, 那么解锁也必须由这个线程完成.
实现: 引入全局 ID 区分线程, 使用 threadLocal 存储.
RedisUtils 只需稍作修改:
@Component
public class RedisUtils {
public RedisTemplate<String, String> getRedisTemplate() {
return this.redisTemplate;
}
private final RedisTemplate<String, String> redisTemplate;
private final BoundValueOperations<String, String> lockOps;
private static final String LOCK = "lock";
private final ThreadLocal<String> threadLocal =
ThreadLocal.withInitial(() -> UUID.randomUUID().toString());
public RedisUtils(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.lockOps = redisTemplate.boundValueOps(LOCK);
}
/**
* 加锁逻辑
* @return boolean
*/
public boolean tryLock() {
return Boolean.TRUE == lockOps.setIfAbsent(threadLocal.get(), 15, TimeUnit.SECONDS);
}
/**
* 释放锁逻辑
*/
public void unlock() {
if (threadLocal.get().equals(lockOps.get())) {
redisTemplate.delete(LOCK);
}
}
}
上述代码即可解决闭环问题.
不过这个分布式锁仍然存在有问题的地方.
如下图, 当同一个线程的方法间调用发生时(即 方法 A 调用 方法 B), 而两个方法都有访问临界资源的情况时, 在方法 B 中无论是加锁还是解锁都是不可接受的.
graph TB
X((start)) --> A
C --> Y((end))
subgraph 方法A
A(1.加锁) --> B(2.逻辑处理) --> G(6.另一些逻辑处理) --> C(7.解锁)
end
subgraph 方法B
D(3.加锁) --> E(4.逻辑处理) --> F(5.解锁)
end
B --> D
F --> G
方法 B 里的第 3 步加锁不会成功, 而且有多余的 IO 成本.
而解锁更严重, 一般解锁逻辑是放在 finally 块里, 大概率是会执行的, 一执行就是提前释放锁, 会导致线程回到 A 方法时无锁操作临界资源, 进而导致程序错误.
所以这就涉及到一个问题: 锁的可重入性.
要解决也不难, 参考 JVM 中的锁设计即可.
4. 带可重入性的分布式锁
只需要加多一个变量存重入次数就行了, 每加一次锁, 变量+1, 每解一次锁变量-1.
由于这个变量是单个 JVM 线程内生效的, 所以无需存到 Redis, 只需本线程内部维护即可.
用一个键值对来保存线程唯一标志和重入次数, 修改 RedisUtils 如下:
@Component
public class RedisUtils {
public RedisTemplate<String, String> getRedisTemplate() {
return this.redisTemplate;
}
private final RedisTemplate<String, String> redisTemplate;
private final BoundValueOperations<String, String> lockOps;
private static final String LOCK = "lock";
private final ThreadLocal<lockEntry> threadLocal =
ThreadLocal.withInitial(() -> new lockEntry(UUID.randomUUID().toString(), 1));
public RedisUtils(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.lockOps = redisTemplate.boundValueOps(LOCK);
}
/**
* 加锁逻辑
* @return boolean
*/
public boolean tryLock() {
// 加锁 设置超时
boolean locked = Boolean.TRUE == lockOps.setIfAbsent(threadLocal.get().uuid, 15, TimeUnit.SECONDS);
// 可重入
if (!locked && threadLocal.get().uuid.equals(lockOps.get())) {
threadLocal.get().count++;
}
return locked;
}
/**
* 释放锁逻辑
*/
public void unlock() {
if (!threadLocal.get().uuid.equals(lockOps.get())) {
return;
}
// 可重入
if (threadLocal.get().count > 0) {
threadLocal.get().count--;
}
// 释放锁
if (threadLocal.get().count == 0L) {
redisTemplate.delete(LOCK);
}
}
@Data
@AllArgsConstructor
static class lockEntry {
private String uuid;
private Integer count;
}
}
至此为止这个分布式锁就相对能用了.
当然这个版本的分布式锁也存在不完善的地方, 到底是哪里不完善呢? 你能发现吗?