Springboot高并发乐观锁
Spring Boot分布式锁的主要缺点包括但不限于以下几点:
-
性能开销:使用分布式锁通常涉及到网络通信,这会引入额外的延迟和性能开销。例如,当使用Redis或Zookeeper实现分布式锁时,每次获取或释放锁都需要与这些服务进行交互。
-
单点故障风险:如果依赖于某个特定的服务(如Redis)来管理锁,那么该服务可能会成为单点故障。如果这个服务不可用,所有依赖它的锁机制都会失效,可能导致系统不稳定或者数据不一致的问题。
-
死锁风险:在某些情况下,如果没有正确处理异常情况或者客户端突然崩溃,可能会导致死锁现象。例如,如果一个持有锁的进程未能正确释放锁,则其他等待该锁的进程将永远处于等待状态。
-
复杂性增加:引入分布式锁增加了系统的复杂性。开发人员需要理解如何正确地使用锁,并且要考虑到各种边界条件,比如超时、重试逻辑等。此外,还需要考虑不同类型的锁(如公平锁、非公平锁)以及它们对应用行为的影响。
-
资源竞争:在高并发场景下,多个实例尝试同时获取同一把锁会导致大量的资源竞争,从而影响整体性能。特别是对于一些频繁读写的热点数据来说,这种竞争可能会成为一个瓶颈。
-
实现差异:不同的分布式锁实现之间存在差异,这意味着迁移到另一种解决方案可能需要更改代码甚至重新设计架构。而且,不是所有的实现都提供了相同的特性和保障。
-
租约管理和心跳检测:一些分布式锁实现依赖于租约(Lease)和心跳来确保锁的有效性。这要求客户端定期向锁服务发送心跳信号以保持其持有的锁。如果网络分区发生或客户端出现故障,可能会导致锁提前被释放,进而引发数据一致性问题。
-
不适合长时间持有锁:由于网络延迟和其他因素,长时间持有分布式锁不是一个好的实践,因为它可能会阻塞其他请求过久,尤其是在高并发环境中。
Redis与Lua
使用Redis与Lua脚本结合的方式虽然有很多优点,比如减少网络开销、提供原子性操作以及可复用等特性,但也存在一些缺点:
-
脚本大小和执行时间限制:
- Lua脚本的大小受到一定的限制,过大的脚本可能无法成功加载到Redis中。
- Redis对Lua脚本的执行时间也有一定限制,以防止单个脚本占用过多资源或导致服务器阻塞。如果脚本执行时间过长,可能会触发客户端配置的时间限制,进而中断脚本执行。
-
编写复杂度:
- 编写Lua脚本需要一定的编程经验,对于不熟悉Lua语言或者编程概念的开发者来说,可能存在较高的学习曲线。
- 如果Lua脚本逻辑复杂,调试和维护也会变得更加困难。
-
阻塞风险:
- 在Redis中,Lua脚本是按照顺序串行执行的,并且在执行期间会阻止其他命令的处理。因此,长时间运行的脚本可能会造成Redis服务器的阻塞,影响系统的响应速度和其他客户端的操作。
- 不应该在Lua脚本中使用阻塞命令(如BLPOP, BRPOP等),因为这会导致Redis服务器在执行脚本时被阻塞,无法处理其他请求。
-
错误处理机制有限:
- 如果Lua脚本在执行过程中出现错误,Redis不会回滚已经执行的部分,这可能导致数据处于不一致状态。
- 错误发生后,通常只能通过日志来追踪问题所在,缺乏更高级别的错误恢复机制。
-
内存消耗:
- Lua脚本一旦执行就会被缓存起来供后续调用使用,这可以提高性能但同时也增加了内存使用量。如果脚本数量庞大或每个脚本占用较多内存,可能会给Redis带来额外的压力。
-
版本兼容性:
- 随着Redis版本的更新,Lua解释器的版本也可能发生变化,这可能会导致旧版本脚本在新版本Redis上不能正常工作的问题。
-
安全性考虑:
- 使用Lua脚本时需要注意安全性,避免恶意用户利用脚本执行攻击。例如,应避免直接将用户输入作为脚本的一部分执行,以防代码注入风险。
package com.cokerlk.redisclientside;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.*;
@RestController
public class LuaController {
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final String LUA_SCRIPT = """
if tonumber(redis.call('exists', KEYS[1])) == 0 then
redis.call('set', KEYS[1],'10')
end
if tonumber(redis.call('exists', KEYS[2])) == 0 then
redis.call('sadd', KEYS[2],'-1')
end
if tonumber(redis.call('get', KEYS[1])) > 0 and tonumber(redis.call('sismember', KEYS[2] , ARGV[1])) == 0 then
redis.call('incrby', KEYS[1],'-1')
redis.call('sadd',KEYS[2],ARGV[1])
return 1
else
return 0
end
""";
@GetMapping("/sk")
public Map<String,Object> secKill(String pid){
Map<String,Object> resp = new HashMap<>();
String uid = String.valueOf(new Random().nextInt(100000000));
List<String> keys = new ArrayList<>();
keys.add("P" + pid); //P1010 String类型 用于保存产品库存量
keys.add("U" + pid);//U1010 SET类型 用于保存秒杀确权的UID
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(LUA_SCRIPT,Long.class);
Long result = stringRedisTemplate.execute(redisScript, keys,uid);
resp.put("uid", uid);
resp.put("result", result);
return resp;
}
}
Spring Retry + Redis Watch实现乐观锁
Spring Retry 和 Redis 的 WATCH
命令可以结合使用来实现乐观锁,尤其是在处理分布式环境下的并发控制时。这种组合可以有效地减少锁的开销,并提供一种非阻塞的方式来处理并发更新。
实现步骤
-
使用 WATCH 监视键: 在开始事务之前,使用
WATCH
命令监视一个或多个键。这告诉Redis在这些键上设置一个“观察点”,如果这些键在事务执行过程中被其他客户端修改,则当前事务将失败。 -
发起 MULTI 开始事务: 当所有需要监视的键都已确定后,使用
MULTI
命令开启一个事务。从这一刻起,所有后续命令都会被收集起来,直到EXEC
被调用。 -
尝试执行命令: 在事务中执行所需的命令(例如
GET
、SET
等),最后通过EXEC
提交事务。如果自WATCH
以来没有键被修改,那么事务将成功提交;否则,EXEC
将返回null
表示事务失败。 -
使用 Spring Retry 进行重试: 如果由于其他客户端修改了受监视的键而导致事务失败,可以通过 Spring Retry 来自动重试整个过程。这样,应用程序可以在不增加复杂性的情况下处理并发冲突。
-
定义重试逻辑: 需要为 Spring Retry 配置适当的重试策略,包括最大重试次数、等待间隔等参数。同时,应该考虑何时停止重试,比如当达到最大重试次数或者超过某个时间限制时。
添加依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- <dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.5</version>
</dependency>-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
业务逻辑
package com.cokerlk.redisclientside;
import jakarta.annotation.Resource;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Objects;
@Service
public class SampleService {
@Resource
private RedisTemplate<String,Object> redisTemplate;
@Retryable(retryFor = IllegalStateException.class, maxAttempts = 2)
@Transactional
public String saWatch(){
System.out.println("executing sa()");
List<Object> execute = redisTemplate.execute(new SessionCallback<>() {
public List<Object> execute(RedisOperations operations) throws DataAccessException {
redisTemplate.watch("sa001");
redisTemplate.multi();
redisTemplate.opsForValue().set("pri001", -100);
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
redisTemplate.opsForValue().set("sa001", 100);
return redisTemplate.exec();
}
});
if(Objects.isNull(execute)){
System.out.println("发现并发冲突:" + execute);
throw new IllegalStateException("Retry");
}else{
System.out.println("exec执行成功:" + execute);
}
return "success";
}
}
-
redisTemplate.execute(SessionCallback)
:- 使用
SessionCallback
来定义一个Redis会话,其中包含了一系列命令,这些命令将在一个单独的事务中执行。
- 使用
-
redisTemplate.watch("sa001")
:- 开始监视键
"sa001"
,确保在接下来的事务期间如果该键被其他客户端修改,则当前事务将失败。
- 开始监视键
-
redisTemplate.multi()
:- 启动一个Redis事务,之后的所有命令都会被收集起来,直到调用
exec()
。
- 启动一个Redis事务,之后的所有命令都会被收集起来,直到调用
控制器
package com.cokerlk.redisclientside;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SampleController {
@Resource
private RedisTemplate<String,Object> redisTemplate;
@Resource
private SampleService sampleService;
@GetMapping("/test")
public String testWatch(){
sampleService.saWatch();
return "success";
}
@GetMapping("/setSA")
public String setSA(){
redisTemplate.opsForValue().set("sa001",300);
return "success";
}
}
Application
package com.cokerlk.redisclientside;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.retry.annotation.EnableRetry;
@SpringBootApplication
@EnableRetry
public class RedisClientSideApplication {
public static void main(String[] args) {
SpringApplication.run(RedisClientSideApplication.class, args);
}
}
测试
###
GET http://localhost:8080/test
###
GET http://localhost:8080/setSA
executing sa()
exec执行成功:[true, true]
executing sa()
exec执行成功:[]