首页 > 基础资料 博客日记

Java分布式锁

2024-09-30 00:00:07基础资料围观116

Java资料网推荐Java分布式锁这篇文章给大家,欢迎收藏Java资料网享受知识的乐趣

📕 文章一:图解分布式锁

📕 文章二:分布式锁与Redisson

多线程并发时,如果是同一个服务实例,或者说同一个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,130NX

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


文章来源:https://blog.csdn.net/llg___/article/details/139128080
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云