RedisTemplate的opsForHash()方法好用,但别依赖它解决所有并发问题,分布式锁+数据库乐观锁+消息队列异步处理才是高并发系统的标配。
果然是高手在民间! 虽然说可能有些厉害的程序员可能已经掌握着这种技术,不过我想真正掌握了的可能也并不多,所以就策划着写一下相关技术的介绍和应用,让大家有更全面和深刻的认识。
为了让大家能更好的去理解,更系统化的去学习,我将这段需求文字拆成四个步骤:
-
分布式锁,主要介绍Redis 分布式锁
-
数据库乐观锁,主要通过MYSQL来实际讲述乐观锁
-
消息队列,主要通过Kafka实战来讲述消息队列及异步操作
- 综合技术应用,通过模拟案例来讲述这个技术如何相互配合,轻松应对高并发问题
以这四个主题为主线,中间也可能会穿插讲解一些其他的技术,敬请关注!
咱们今天进行第一步,了解Redis分布式锁。
Redis 分布式锁是什么?
在深入探讨 Redis 分布式锁之前,我们先来理解一下什么是分布式锁。在分布式系统中,多个节点可能同时访问和修改共享资源,这就可能导致数据不一致或其他并发问题。分布式锁就是一种用于控制分布式系统中多个节点对共享资源进行同步访问的机制 ,它确保在同一时刻只有一个节点能够访问被锁定的资源。
对比一下我们熟悉的 Java 本地锁。在单机应用中,Java 的本地锁(如synchronized关键字、ReentrantLock等)能够很好地控制多个线程对共享资源的访问,保证同一时刻只有一个线程能获取到锁并执行临界区代码。但在分布式场景下,不同的服务实例可能部署在不同的机器上,它们拥有各自独立的 JVM,此时 Java 本地锁就无法在多个实例之间实现对共享资源的同步控制了。
而 Redis 分布式锁正是为了解决分布式环境下的这种问题而诞生的。简单来说,Redis 分布式锁是利用 Redis 的特性来实现的一种分布式锁机制。通过在 Redis 中设置一个特定的键值对来表示锁的状态,多个节点通过操作这个键值对来获取和释放锁,从而达到对共享资源的同步访问控制。
Redis 分布式锁的技术特性
了解了 Redis 分布式锁的概念后,我们再深入探讨一下它的几个关键技术特性 。
互斥性
这是分布式锁最基本的特性。在任何时刻,对于同一个锁资源,只能有一个客户端能够获取到锁 。Redis 分布式锁利用 Redis 的单线程特性以及相关命令(如SETNX,即SET if Not eXists,如果不存在则设置)来实现互斥性。当一个客户端使用SETNX命令尝试设置一个锁的键值对时,如果该键不存在,说明没有其他客户端持有锁,此客户端设置成功,即获取到锁;如果该键已存在,说明已有其他客户端持有锁,当前客户端设置失败,无法获取锁。例如:
Jedis jedis = new Jedis("localhost", 6379);
String lockKey = "product:1:lock";
String requestId = UUID.randomUUID().toString();
// 使用SETNX命令尝试获取锁
Boolean lockAcquired = jedis.setnx(lockKey, requestId) == 1;
if (lockAcquired) {
// 获取到锁,执行业务逻辑
try {
// 业务操作
} finally {
// 释放锁
jedis.del(lockKey);
}
} else {
// 未获取到锁,处理相应逻辑
}
高可用性
在分布式场景下,必须保证锁服务的高可用性,即使部分节点出现故障,也不能影响锁的正常获取和释放 。为了实现这一点,通常会采用集群部署的方式来部署 Redis。通过 Redis 集群,当某个节点发生故障时,其他节点可以继续提供服务,确保锁的操作不受影响。比如,使用 Redis Cluster 模式,数据会分布在多个节点上,即使个别节点宕机,只要集群中大部分节点正常运行,就可以正常进行锁的相关操作 。
防止锁超时
在分布式系统中,可能会出现客户端获取锁后,由于某些原因(如网络故障、程序崩溃等)未能及时释放锁的情况,这就会导致其他客户端永远无法获取到该锁,从而产生死锁 。为了避免这种情况,Redis 分布式锁需要设置一个合理的过期时间,当锁的持有时间超过这个过期时间后,Redis 会自动释放锁,这样其他客户端就有机会获取锁。例如,在使用SET命令设置锁时,可以同时指定过期时间:
// 使用SET命令设置锁,同时指定过期时间为10秒
Boolean lockAcquired = jedis.set(lockKey, requestId, "NX", "EX", 10)!= null;
独占性
只有加锁的客户端才能解锁,不能出现一个客户端加锁,另一个客户端却能解锁的情况 。为了保证这一特性,在加锁时,每个客户端会生成一个唯一的标识(如 UUID),并将其作为锁的值存储在 Redis 中。在解锁时,首先会检查当前锁的值是否与自己加锁时的唯一标识一致,如果一致,则说明是自己加的锁,才执行解锁操作。通常会借助 Lua 脚本来保证解锁操作的原子性,避免在检查和删除锁的过程中出现并发问题 。以下是一个使用 Lua 脚本解锁的示例:
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), requestId);
if ((Long) result == 1) {
// 解锁成功
} else {
// 解锁失败
}
秒杀商品场景中的应用
加锁操作
在秒杀场景中,使用 Redis 的SET命令来实现加锁是非常常见的方式。下面是一个使用 Java 和 Jedis 客户端进行加锁操作的代码示例:
import redis.clients.jedis.Jedis;
import java.util.UUID;
public class Seckill {
private static final String LOCK_KEY = "seckill:product:1:lock";
private static final String REDIS_HOST = "localhost";
private static final int REDIS_PORT = 6379;
private static final int LOCK_EXPIRE = 10; // 锁的过期时间,单位秒
public boolean tryLock() {
Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
String requestId = UUID.randomUUID().toString();
// 使用SET key value NX EX seconds命令尝试获取锁
// NX表示只有当键不存在时才设置键值对
// EX seconds表示设置键的过期时间为seconds秒
boolean lockAcquired = "OK".equals(jedis.set(LOCK_KEY, requestId, "NX", "EX", LOCK_EXPIRE));
jedis.close();
return lockAcquired;
}
}
在上述代码中,我们首先创建了一个 Jedis 实例来连接 Redis 服务器。然后生成一个唯一的requestId,这个requestId用于标识当前加锁的客户端。接着使用jedis.set(LOCK_KEY, requestId, "NX", "EX", LOCK_EXPIRE)命令来尝试获取锁。如果该命令执行成功,返回OK,说明锁被成功获取,lockAcquired为true;否则,说明锁已被其他客户端持有,获取锁失败,lockAcquired为false。
业务逻辑处理
当成功获取到锁后,就可以进行商品库存检查、扣减等业务操作了。以下是相关代码示例:
public String seckillProduct() {
if (tryLock()) {
try {
Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
String productStockKey = "seckill:product:1:stock";
// 获取商品库存
int stock = Integer.parseInt(jedis.get(productStockKey));
if (stock > 0) {
// 扣减库存
jedis.set(productStockKey, String.valueOf(stock - 1));
jedis.close();
return "秒杀成功";
} else {
jedis.close();
return "商品已售罄";
}
} catch (Exception e) {
e.printStackTrace();
return "秒杀失败";
} finally {
// 解锁操作将在后续介绍
}
} else {
return "抢购人数过多,请稍后重试";
}
}
在这段代码中,首先调用tryLock方法尝试获取锁。如果获取到锁,就从 Redis 中获取商品的库存数量。判断库存数量是否大于 0,如果大于 0,则将库存数量减 1,并更新到 Redis 中,返回 “秒杀成功”;如果库存数量不大于 0,则返回 “商品已售罄”。在整个业务逻辑处理过程中,由于加了锁,所以不会出现多个请求同时扣减库存导致超卖的情况 。
解锁操作
解锁操作同样重要,如果不妥善处理,可能会导致锁无法释放,从而影响后续的业务流程。为了确保解锁操作的安全性和原子性,通常会借助 Lua 脚本。下面是使用 Lua 脚本进行解锁的代码示例:
public void unlock() {
Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
String requestId = UUID.randomUUID().toString();
Object result = jedis.eval(script, 1, LOCK_KEY, requestId);
if ((Long) result == 1) {
System.out.println("解锁成功");
} else {
System.out.println("解锁失败");
}
jedis.close();
}
在上述代码中,定义了一个 Lua 脚本,该脚本首先通过redis.call('get', KEYS[1])获取锁的当前值,并与加锁时的requestId(即ARGV[1])进行比较。如果两者相等,说明当前客户端是加锁的客户端,执行redis.call('del', KEYS[1])删除锁,返回 1 表示解锁成功;否则,返回 0 表示解锁失败 。通过这种方式,保证了只有加锁的客户端才能解锁,避免了误解锁的情况 。
Redis 分布式锁用法
核心要点回顾
在使用 Redis 分布式锁时,加锁操作要确保原子性,例如使用SET key value NX EX seconds命令,通过NX选项保证只有在锁不存在时才能设置成功,实现互斥性;同时利用EX选项设置合理的过期时间,防止因程序异常导致死锁 。在业务逻辑处理完成后,解锁操作同样关键,使用 Lua 脚本可以保证解锁的原子性,先判断锁的持有者是否为当前客户端,只有在确认的情况下才执行删除锁的操作 。
最佳实践建议
合理设置锁超时时间非常重要,应根据业务处理的实际时间来预估,既要保证业务有足够的时间完成,又不能设置过长导致资源长时间被占用。可以在预估业务处理时间的基础上,适当增加一定的缓冲时间。同时,选择合适的锁实现方式,如使用 Lua 脚本实现原子性操作,或者考虑使用更高级的分布式锁框架(如 Redisson),它提供了更丰富的功能和更便捷的使用方式 。此外,在实际应用中,还需要考虑锁的重试机制,当获取锁失败时,根据业务需求进行合理的重试,以提高系统的可用性 。
明天我们将对Redisson进行深入探讨,欢迎大家来评论区聊聊你迫切想要解决的问题,或者是迫切想要学习的知识,我们会尽可能满足大家的求知欲。