首页 > 基础资料 博客日记
Java分布式锁
2024-09-30 00:00:07基础资料围观116次
文章目录
多线程并发时,如果是同一个服务实例,或者说同一个JVM下,想保证一个代码块在同一时间只能由一个线程访问,可以使用synchronized等Java自身提供的锁
但一般集群下,一个微服务有多个实例,或者说多个pod,也就有多个JVM,此时,想保证不同实例里的线程同步执行,靠synchronized这种JVM级别的锁就不能实现了,而要通过分布式锁,一种独立于JVM之外的锁。关于分布式锁这种思想的落地,可选:
- MemCached提供的add命令,add失败,说明key存在,这个key比如是订单ID+流水ID的拼接,即抢锁失败
add <key> <exptime> <bytes>
// 把一个键为mykey、过期时间为3600秒(1小时)、值大小为4字节的项添加到缓存中
// add mykey 0 3600 4
// add操作是原子的,add失败,说明key存在
- Redis的setnx命令,key同样常常和业务ID挂钩
//获取锁,NX是互斥,EX是设置超时时间
SET key value NX EX 10
//释放锁,删除即可
DEL key
- Zookeeper分布式锁
一、场景1:防止多次重复点击
翻到项目中一个支付需求,支付接口里用到了分布式锁来防止重复支付(重复点击),具体以订单编号字符串拼接支付结算ID当作key,以随机的一个UUID为value,然后SETNX获取分布式锁。获取锁和释放锁的实现:
@Component
@Slf4j
public class RedisService {
@Autowired
public RedisTemplate redisTemplate;
/**
* 尝试获取锁 如果锁可用 立即返回true,否则返回false
*
* @param lockKey 锁的 key
* @param requestId requestId – 同一应用内的唯一ID, 自己的锁只有自己能解
* 使用IdUtils.fastSimpleUUID()方法生成
* @param expireTime 超时时间
* @param timeUnit 超时时间单位
* @return
*/
public boolean tryLock(String lockKey, String requestId, long expireTime, TimeUnit timeUnit) {
try{
boolean flag = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId);
if (flag) {
return redisTemplate.expire(lockKey, expireTime, timeUnit);
}else{
return false;
}
}catch (Exception e) {
log.error("RedisService -> tryLock fail, key={}", lockKey, e);
return false;
}
}
/**
* 释放锁
*
* @param lockFlag 锁状态
* @param lockKey 锁的 key
* @param requestId requestId – 同一应用内的唯一ID, 自己的锁只有自己能解
*/
public void unLock(boolean lockFlag, String lockKey, String requestId) {
if (!lockFlag) {
return;
}
String lockValue = (String)redisTemplate.opsForValue().get(lockKey);
if (StringUtils.isBlank(lockValue)) {
return;
}
if (!lockValue.equals(requestId)) {
log.info("RedisLockUtil -> unLock end 释放锁失败, 非自己加的锁, 不可释放, lockKey={}, requestId={}, lockValue={}", lockKey, requestId, lockValue);
return;
}
log.info("RedisLockUtil -> unLock start 开始释放分布式锁, lockKey={}, requestId={}", lockKey, requestId);
redisTemplate.opsForValue().getOperations().delete(lockKey);
}
}
使用时,拼接相关业务ID,做为这条数据唯一的分布式锁的key,如此,实现防重复点击。UUID作为value,用于后面释放锁时校验,实现自己加的锁,自己释放。
最后finally中去释放锁:
类似的场景,分布式锁用在去保证分布式系统中,某个操作的幂等性,幂等性,即多次执行同一个操作,结果都相同且不会产生副作用。 而分布式系统中,多个节点同时处理一个请求可能导致数据不一致或者重复操作,使用分布式锁则可以解决这个问题。
分布式系统下,某个节点需要执行某个操作时,让先去获取分布式锁,获取到就执行,获取锁失败就等一会儿后重新获取,只有拿到分布式锁才能执行操作。
setnx时,key,value,过期时间 三个参数的补充:
- key:常和业务ID或者流水ID之类的业务字段关联,保证的就是唯一性
- value:常为同一应用内的唯一ID, 以保证自己的锁只有自己能解
- 过期时间:设置它,倒不是怕出异常了释放锁失败, 因为一般释放锁都在try-finally语句块中, 而是怕实例宕机, 这下finally也就没用了, 所以要加过期时间
二、上面获取分布式锁代码存在的问题
上面获取分布式锁的方法tryLock的伪代码即:
2.1 原子性的保证
这样写有问题,Redis单线程,所以单独一条指令的执行是原子性的,但你先setnx再expire就是非原子的。
由此:如果一个线程setnx成功后,还没有来得及expire,服务实例就宕机,重启后,就会有一个删不掉的key,别说可以delete,一般来说,释放分布式锁,要校验value,即上面提到的"同一应用内的唯一ID, 以保证自己的锁只有自己能解"。
关于这个问题,Redis 2.6.12以上版本的set可以替代setnx + expire,解决了原子性的问题:
// Redis 2.6.12以上版本的set可以替代setnx + expire
set(key,1,30,NX)
2.2 锁的续期
set时,超时时间如何控制,根据业务时间估算是不靠谱的,考虑开个守护线程:如果业务还在执行的话,就去给锁续期 ⇒ 关于这个思想的落地:redission的看门狗线程给锁续命
2.3 锁误删
前面提到的,set时key和业务挂钩,对于防多次点击来说,这很合理。但如果是多个人抢唯一的一张券呢,此时,key只有一个,如果线程A到了过期时间后,仍然没有执行结束,此时,key被删,线程B就也能抢这个锁了。
等线程A执行完代码,finally语句中释放分布式锁时,删掉的key其实已经是B线程的锁了。关于这个问题的解决,就可以把set时的key-value的value用起来了,比如value时一个UUID或者线程ID,释放分布式锁时,get到key-value后,判断value是否是你记录的value,不是则不能释放。
这一点,上面获取分布式锁代码已经做了。
三、场景2:多人抢一张券
用Redission写了这里,引入其starter依赖+配置文件略:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class TicketService {
private static final String LOCK_KEY = "ticketLock";
private static final int TICKET_AMOUNT = 1; // 设定总票数
private static int remainingTickets = TICKET_AMOUNT; // 剩余票数,这里是Demo,直接用一个常量存了,生产环境中,remainingTickets 应存储在数据库中,以确保数据持久性和一致性
@Autowired
private RedissonClient redissonClient;
public boolean attemptToGetTicket() {
RLock lock = redissonClient.getLock(LOCK_KEY);
boolean isLocked = false;
try {
// 尝试获取分布式锁,最多等待 10 秒
isLocked = lock.tryLock(10, TimeUnit.SECONDS);
if (isLocked) {
// 处理抢票逻辑
if (remainingTickets > 0) {
remainingTickets--;
log.info("抢票成功,当前余量: " + remainingTickets);
return true;
} else {
log.info("获取分布式锁成功,但抢票失败,余量不足");
return false;
}
} else {
log.error("获取分布式锁超时");
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (isLocked) {
// 释放分布式锁
lock.unlock();
}
}
}
}
四、场景3:多人抢多张券,允许每人抢多张
每人可以抢多张,如果是每个人可以直接输入一次性抢的总张数:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class TicketService {
private static final String LOCK_KEY = "ticketLock";
private static final int TICKET_AMOUNT = 100; // 总票数
private static int remainingTickets = TICKET_AMOUNT; // 剩余票数
@Autowired
private RedissonClient redissonClient;
/**
* 尝试获取票务
* @param userId 用户ID
* @param ticketCount 请求的票数数量
* @return 是否成功获取票
*/
public boolean attemptToGetTickets(String userId, int ticketCount) {
RLock lock = redissonClient.getLock(LOCK_KEY);
boolean isLocked = false;
try {
// 尝试获取锁,最多等待 10 秒
isLocked = lock.tryLock(10, TimeUnit.SECONDS);
if (isLocked) {
// 处理抢票逻辑
if (remainingTickets >= ticketCount) {
remainingTickets -= ticketCount;
log.info("用户 " + userId + " 抢票成功,数量: " + ticketCount + " 余票数量: " + remainingTickets);
return true;
} else {
log.info("用户 " + userId + " 需要票:" + ticketCount + " 张,但余票数量为: " + remainingTickets + " 余票数量不足,抢票失败");
return false;
}
} else {
log.error("获取分布式锁超时");
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (isLocked) {
lock.unlock();
}
}
}
}
如果是,一个人可以抢多张,但一次只能抢一张,不能一次性输入需要抢的总张数:那就和场景3代码一样了,不同的是,待售的总票数不再是1,而是100
五、场景4:多人抢多张券,且每人至多抢一张券
使用Redis存储用户的抢票记录,可以使用 RedisTemplate 来实现:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;
@Service
public class TicketService {
private static final String LOCK_KEY = "ticketLock";
// 存用户抢票数量的key前缀
private static final String USER_TICKET_KEY_PREFIX = "userTicket:";
private static int remainingTickets = 100; // 总票数,实际应用中应存储在数据库中
@Autowired
private RedissonClient redissonClient;
@Autowired
private RedisTemplate<String, Boolean> redisTemplate;
/**
* 尝试获取一张票
* @param userId 用户ID
* @return 是否成功获取票
*/
public boolean attemptToGetTicket(String userId) {
RLock lock = redissonClient.getLock(LOCK_KEY);
boolean isLocked = false;
try {
// 尝试获取锁,最多等待 10 秒
isLocked = lock.tryLock(10, TimeUnit.SECONDS);
if (isLocked) {
// 先检查用户是否已经抢到票
if (redisTemplate.hasKey(USER_TICKET_KEY_PREFIX + userId)) {
log.info("用户 " + userId + " 已经抢过一张票了,抢票失败");
return false;
}
// 处理抢票逻辑
if (remainingTickets > 0) {
remainingTickets--;
// 记录用户已抢到票,记录一下
redisTemplate.opsForValue().set(USER_TICKET_KEY_PREFIX + userId, true);
log.info("用户 " + userId + " 抢票成功,当前余票数量:" + remainingTickets);
return true;
} else {
log.info("用户 " + userId + " 抢票失败,余票数量不足");
return false;
}
} else {
log.error("获取分布式锁超时");
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
if (isLocked) {
lock.unlock();
}
}
}
}
六、场景5:多人抢多张券,且每人至多抢两张券
和场景4类似,不同的是,场景4记录用户抢票记录用的key-value,value是布尔类型的,这里就用int就行,抢到分布式锁以后,先根据userID查当前已抢的票数,大于2就返回抢票失败。
七、待优化补充
以几个场景的抢票代码,都使用同一个分布式锁的键,锁的竞争冲突太频繁,会造成性能瓶颈。
部分示意图来源:https://mp.weixin.qq.com/s/8fdBKAyHZrfHmSajXT_dnA
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!
标签: