• [技术干货] centos7部署redis以及多实例
    安装redis下载wget http://download.redis.io/releases/redis-5.0.5.tar.gz解压到安装目录mkdir /usr/local/redis tar -zxvf redis-5.0.5.tar.gz -C /usr/local/redis/ 进入redis解压目录cd /usr/local/redis/redis-5.0.5编译make进入redis/src下cd /usr/local/redis/redis-5.0.5/src执行安装make install修改配置文件(后台启动,设置密码,外部访问)修改主配置文件 (1) 注释掉 bind 127.0.0.1 这一行(解决只能特定网段连接的限制) (2) 将 protected-mode 属性改为 no (关闭保护模式,不然会阻止远程访问) (3) 将 daemonize 属性改为 yes (这样启动时就在后台启动) (4) 设置密码 搜索 requirepass foobared 添加 requirepass 你设置的密码然后开放6379端口启动,停止和重启redis-server /usr/local/redis/redis-5.0.5/redis.conf连接redisredis-cliauth 密码redis多实例部署一台服务器上部署多个redis使用。首先找到redis文件夹下,将redis.conf复制到自己找到的目录下,这边我是直接创建了个bin目录。如果有几个实例那么久复制几份其次就改配置文件,具体要修改的地方是==1、bind==在默认情况下,bind监听的地址为127.0.0.1,因此,我们在新的配置文件中,必须要将bind监听的地址修改为本机的IP地址。==2、daemonize==在Redis多实例场景下,我们需要Redis的启动命令而不是启动脚本来启动新的Redis实例,因此,我们必须要将该参数改为yes,使得Redis后台启动。==3、port==在计算机中,不可能存在多个进程共同监听同一个端口,否则会出现端口已被占用的错误,因此,我们必须修改新的Redis实例的监听端口。==4、pidfile==pidfile也必须进行修改,否则会与原来的实例的pid文件名称相同,造成错误。==5、logfile==与pidfile类似,我们也必须修改logfile,即Redis的日志文件。==6、dir==同样的,我们也必须修改Redis的持久化存储目录。:warning:这里注意,如果配置了多实例那么启动的时候要带配置文件去启动redis-server /usr/local/redis/redis-5.0.5/bin/redis6381.conf 关闭的时候要杀死进程netstat -lntp或者还有一个操作ps -aux|grep redis进入服务端的命令redis-cli -p 6381 -a 你的密码
  • [技术干货] springboot整合redis过期key监听实现订单过期操作
    业务场景说明对于订单问题,那些下单了但是没有去支付的(占单情况),不管对于支付宝还是微信都有订单的过期时间设置,但是对于我们自己维护的订单呢。两种方案:被动修改,主动修改。这里仅仅说明对于主动修改的监听实现修改redis的配置文件redis.conf(好像不改也可以)K:keyspace事件,事件以__keyspace@<db>__为前缀进行发布; E:keyevent事件,事件以__keyevent@<db>__为前缀进行发布; g:一般性的,非特定类型的命令,比如del,expire,rename等; $:字符串特定命令; l:列表特定命令; s:集合特定命令; h:哈希特定命令; z:有序集合特定命令; x:过期事件,当某个键过期并删除时会产生该事件; e:驱逐事件,当某个键因maxmemore策略而被删除时,产生该事件; A:g$lshzxe的别名,因此”AKE”意味着所有事件pom依赖坐标的引入<!--版本号说明。这里我使用的是<jedis.version>2.9.3</jedis.version>,<spring.boot.version>2.3.0.RELEASE</spring.boot.version>--> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>${jedis.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>${spring.boot.version}</version> </dependency> 这里可能会存在的依赖冲突问题是与io.nettyRedisConfig的配置package test.bo.work.config.redis; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; import test.bo.work.util.StringUtil; /** * @author xiaobo */ @Configuration @EnableAutoConfiguration public class JedisConfig { private static final Logger LOGGER = LoggerFactory.getLogger(JedisConfig.class); @Value("${spring.redis.host}") private String host; @Value("${spring.redis.port}") private int port; @Value("${spring.redis.password}") private String password; @Value("${spring.redis.timeout}") private int timeout; @Value("${spring.redis.jedis.pool.max-active}") private int maxActive; @Value("${spring.redis.jedis.pool.max-wait}") private int maxWait; @Value("${spring.redis.jedis.pool.max-idle}") private int maxIdle; @Value("${spring.redis.jedis.pool.min-idle}") private int minIdle; @Bean public JedisPool redisPoolFactory() { try { JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); jedisPoolConfig.setMaxIdle(maxIdle); jedisPoolConfig.setMaxWaitMillis(maxWait); jedisPoolConfig.setMaxTotal(maxActive); jedisPoolConfig.setMinIdle(minIdle); // JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout, password); String pwd = StringUtil.isBlank(password) ? null : password; JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout, pwd,6); LOGGER.info("初始化Redis连接池JedisPool成功!地址: " + host + ":" + port); return jedisPool; } catch (Exception e) { LOGGER.error("初始化Redis连接池JedisPool异常:" + e.getMessage()); } return null; } @Bean public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); return container; } } 监听器实现package test.bo.work.config.redis; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.listener.KeyExpirationEventMessageListener; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; /** * @author xiaobo */ @Component @Slf4j public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener { public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) { super(listenerContainer); log.info("Redis Key Expiration Listener has been initialized."); } @Override public void onMessage(Message message, byte[] pattern) { String expiredKey = message.toString(); log.error(expiredKey); // 判断是否是想要监听的过期key if (expiredKey.startsWith(KuoCaiConstants.ORDER_REDIS_PREFIX)) { // 根据过期redisKey获取订单号 String transactionOrderId = expiredKey.substring(KuoCaiConstants.ORDER_REDIS_PREFIX.length()); transactionOrderService.updateTransactionOrderStatusByRedis(Long.valueOf(transactionOrderId)); } } } 【注意】可能存在的问题(监听事件失效)自己自定义了库,如下代码 @Override protected void doRegister(RedisMessageListenerContainer listenerContainer) { // 针对db6进行监听 listenerContainer.addMessageListener(this, new PatternTopic("__keyevent@6__:expired")); } 这种情况,如果你存入的key不在db6,那么你就看不到监听触发事件(==这里并不是失效,只是说可能出现的你认为失效的情况==)使用了默认的连接工厂,但是配置文件中又没有相关定义首先解释一下,默认情况下,Spring Boot使用Jedis作为Redis客户端,并且会自动根据application.properties或application.yml配置文件中的spring.redis属性来创建连接工厂。如果没有指定这些属性,则会使用默认的localhost:6379作为Redis服务器地址。如果你想要连接到其他的Redis服务器,可以在配置文件中设置spring.redis.host和spring.redis.port属性来指定Redis服务器的地址和端口号。对于以上情况可以自定义工厂然后注入即可(上面的JedisConfig.java文件)@Bean public JedisConnectionFactory jedisConnectionFactory() { RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); redisStandaloneConfiguration.setHostName(host); redisStandaloneConfiguration.setDatabase(db); redisStandaloneConfiguration.setPassword(RedisPassword.of(password)); redisStandaloneConfiguration.setPort(port); return new JedisConnectionFactory(redisStandaloneConfiguration); } @Bean public RedisMessageListenerContainer container(JedisConnectionFactory jedisConnectionFactory) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(jedisConnectionFactory); return container; } 【警告】存在的问题Redis的Key事件通知机制默认是异步的,即Redis会在Key过期时发送事件通知给所有监听者,但是不能保证监听者一定会及时接收到通知。如果您的应用程序需要在Key过期后立即处理相关操作,可能需要使用其他方式来实现。在Redis中,Key的过期时间只是一个近似的时间,它并不是精确的,因此不能保证过期时间到达时就一定会立即过期。如果您需要在Key过期后立即处理相关操作,建议您使用其他方式来实现,例如使用定时任务或轮询方式检查过期Key。在Redis中,Key的过期时间不能被取消或重置。如果您在设计时考虑到Key的过期时间可能需要修改,建议您使用其他方式来实现。当Redis中的Key被持久化到磁盘上时,过期时间可能会受到影响,因为过期时间的计算是基于系统时间的,如果系统时间发生变化,过期时间可能会出现不准确的情况。因此,建议您使用其他方式来处理需要精确过期时间的场景。
  • [技术干货] springboot整合redis及lua脚本实现接口限流
    接口限流说明接口限流是指在某些场景下,对某个接口的请求进行限制,以避免因请求过多而导致的系统负载过高、资源耗尽等问题。通常情况下,接口限流可以通过一定的算法来实现,比如令牌桶算法、漏桶算法、计数器算法等。这些算法可以根据接口的不同特点和业务需求,对请求进行限制和平滑处理,以达到系统资源的最优化利用。令牌桶算法令牌桶算法(Token Bucket Algorithm):令牌桶算法可以通过限制请求的速率,来保护系统免受突发流量的冲击。该算法将请求和令牌都存放在一个桶中,每个请求需要从桶中取出一个令牌,如果桶中没有令牌,则请求将被拒绝。该算法能够平滑限制请求的速率,避免系统被突发流量打垮。优点:能够平滑限制请求的速率,适合对流量进行平滑的限制。对于短时间内的流量突发,可以处理突发请求,保护系统不被打垮。缺点:实现相对较为复杂,需要维护令牌桶的状态。无法应对突发的大量请求。适用场景:对于流量比较稳定的系统,需要对请求进行平滑限制的场景。对于需要对请求进行按照一定速率限制的场景。漏桶算法漏桶算法(Leaky Bucket Algorithm):漏桶算法与令牌桶算法相似,也是通过限制请求的速率来保护系统免受突发流量的冲击。该算法将请求放入一个漏桶中,每个请求都需要占据一定的空间,如果漏桶已满,则请求将被拒绝。该算法能够平滑限制请求的速率,但无法应对突发流量。优点:能够平滑限制请求的速率,适合对流量进行平滑的限制。对于流量突发的情况,能够防止系统被过载。缺点:无法应对突发的大量请求。实现相对较为复杂,需要维护漏桶的状态。适用场景:对于需要对请求进行按照一定速率限制的场景。计数器算法计数器算法(Counting Algorithm):计数器算法是最简单的限流算法,通过对每个接口的请求数进行计数,并对其进行限制,来保护系统。该算法能够很好地限制请求的数量,但无法平滑限制请求的速率。优点:实现简单,易于实现。缺点:无法平滑限制请求的速率。无法应对突发的大量请求。适用场景:对于需要对请求进行简单计数的场景。对于不需要进行流量平滑限制的场景。滑动窗口算法滑动窗口算法(Sliding Window Algorithm):滑动窗口算法可以通过限制请求的速率,来保护系统免受突发流量的冲击。该算法将请求按照时间顺序放入一个固定大小的窗口中,如果窗口已满,则新的请求将被拒绝。该算法能够平滑限制请求的速率,但无法应对突发流量。优点:能够平滑限制请求的速率,适合对流量进行平滑的限制。对于短时间内的流量突发,可以处理突发请求,保护系统不被打垮。缺点:实现相对较为复杂,需要维护窗口的状态。无法应对长时间的流量突发。适用场景:对于流量比较稳定的系统,需要对请求进行平滑限制的场景。对于需要对请求进行按照一定速率限制的场景。实现基于令牌桶+redis进行接口限流这里我是基于令牌桶算法进行了变种,也就是针对不同的用户以及不同的方法在不同的时刻进行了限制,当然这个仅仅看个人业务:one::引入maven坐标<!--lua脚本--> <dependency> <groupId>org.luaj</groupId> <artifactId>luaj-jse</artifactId> <version>3.0.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> :two::定义接口限制注解package test.bo.work.redislimit.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @author xiaobo * @date 2023/3/13 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { // 限制名称 String key(); // 次数 int limit(); // 秒 int seconds(); } :three::lua脚本实现local current = tonumber(redis.call('get', KEYS[1]) or '0') -- 判断是否还有令牌可用 if current + 1 > tonumber(ARGV[1]) then return 0 else redis.call('incrby', KEYS[1], 1) redis.call('expire', KEYS[1], ARGV[2]) return 1 end:four::引入lua脚本package test.bo.work.redislimit; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Component; import java.io.IOException; import java.nio.file.Files; /** * @author xiaobo * @date 2023/3/13 */ @Component public class RedisLuaScripts { @Value("classpath:/lua/ratelimit.lua") private Resource rateLimitScriptResource; public RedisScript<Long> getRateLimitScript() throws IOException { String script = new String(Files.readAllBytes(rateLimitScriptResource.getFile().toPath())); return new DefaultRedisScript<>(script, Long.class); } } :five:: 定义一个接口限制的AOPpackage test.bo.work.redislimit.assept; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import test.bo.work.config.exception.BusinessException; import test.bo.work.redislimit.RedisLuaScripts; import test.bo.work.redislimit.annotation.RateLimit; import javax.servlet.http.HttpServletRequest; import java.time.LocalDate; import java.util.Arrays; import java.util.Collections; import java.util.List; /** * @author xiaobo * @date 2023/3/13 */ @Aspect @Component public class RateLimitAspect { @Autowired private StringRedisTemplate redisTemplate; @Autowired private RedisLuaScripts redisLuaScripts; @Around("@annotation(rateLimit)") public Object checkRateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { // 获取请求头中的AREA_TOKEN // 获取RequestAttributes RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); // 从获取RequestAttributes中获取HttpServletRequest的信息 HttpServletRequest request = (HttpServletRequest) requestAttributes .resolveReference(RequestAttributes.REFERENCE_REQUEST); String areaToken = request.getHeader("AREA_TOKEN"); // 获取注解上的参数 String key = rateLimit.key(); int limit = rateLimit.limit(); int seconds = rateLimit.seconds(); String redisKey = key + areaToken + "-" + LocalDate.now(); RedisScript<Long> script = redisLuaScripts.getRateLimitScript(); List<String> keys = Collections.singletonList(redisKey); List<String> args = Arrays.asList(String.valueOf(limit), String.valueOf(seconds)); Long result = redisTemplate.execute(script, keys, args.toArray()); if (result != null && result == 1) { return joinPoint.proceed(); } else { throw new BusinessException(500,"Rate limit exceeded for " + key); } } } :end::接口实现package test.bo.work.redislimit.controller; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import test.bo.work.entity.OpResult; import test.bo.work.redislimit.annotation.RateLimit; /** * @author xiaobo * @date 2023/3/13 */ @RestController @Slf4j public class RateLimitController { @PostMapping("rateLimit") @RateLimit(key = "rateLimit", limit = 10, seconds = 1) public OpResult testRateLimit() { return OpResult.Ok("success"); } } 说明基于令牌桶算法的变种在正常情况下可以很好地限制接口的访问速率,但在短时间内突然出现大量请求的情况下,该算法可能会出现较大的误差。原因是在突发请求的情况下,桶内已经有很多令牌,但这些令牌并不能很快地被消耗,导致一些请求得到了允许,而另一些请求被拒绝。而漏桶算法则不会出现这个问题,因为它是基于固定速率漏水的方式进行限流,无论突发请求多少,都不会超出限制速率。:warning::经过jmeter测试确实出现了这样的情况如果是需要对大量用户进行限流,建议使用更高效的限流算法,比如漏桶算法,或基于漏桶算法的Token Bucket算法
  • [交流吐槽] 【话题交流】AI自动调优SQL靠谱吗?实际用过的来说说体验!
    现在很多平台,比如MCP集成的AI模块,支持SQL分析和自动调优。理论上它能减少人工优化成本,但实际效果到底如何?你有没有尝试过AI帮你优化慢SQL?准确率怎么样?有没有踩过坑?欢迎一起来聊聊“AI调优”这件事值不值得信!
  • [技术干货] Redis-Cluster 与 Redis 集群的技术大比拼
    前言在分布式数据库的世界中,Redis-Cluster 和传统 Redis 集群像两位拥有独特技能的战士,各自展现出强大的战斗力。今天,我们将一起踏上 Redis 分布式战场,解密 Redis-Cluster 与传统 Redis 集群的技术奥秘,揭开它们之间的差异之幕。概念与原理对比Redis-Cluster:基于哈希槽的分布式解决方案Redis-Cluster采用了一种先进的分布式数据分片方式,即通过哈希槽(Hash Slot)将整个数据集划分为16384个槽。每个节点负责一部分槽,通过哈希算法将数据映射到相应的槽上。这样,数据的分布在集群中更为均匀,同时提供了更高的可扩展性。在Redis-Cluster中,节点之间通过Gossip协议进行通信,实现了自动发现和节点管理。优势:数据分布均匀:通过哈希槽的方式,实现了数据的均匀分布,避免了热点问题。高可扩展性:方便地增加或减少节点,实现集群的动态扩缩容。自动发现与管理:节点之间通过Gossip协议进行通信,实现了自动发现和管理。传统 Redis 集群:主从架构下的数据分片方式传统的Redis集群采用主从架构,其中包括若干个主节点和它们的从节点。每个主节点负责一部分数据,而它的从节点则负责复制主节点的数据。这种方式下,数据的分片是通过主节点进行的,而从节点则用于提高系统的可用性和容错能力。优势:简单可靠:传统集群采用主从结构,相对简单可靠,容易理解和维护。数据备份:主从架构下,每个主节点都有对应的从节点,实现了数据的备份。不足:数据分布可能不均匀:如果某个主节点的数据集较大,可能导致该节点成为瓶颈,造成性能问题。不利于动态扩缩容:传统集群的扩缩容相对繁琐,需要手动处理节点的添加和移除。通过对比这两种分布式方案的原理,可以根据项目需求和特点选择更适合的方案。如果需要更高的可扩展性和自动化管理,Redis-Cluster是一个更为先进的选择。如果对于简单可靠的要求更为关键,传统Redis集群仍然是一个可靠的解决方案。搭建与配置的异同Redis-Cluster 搭建:哈希槽分配、节点配置等步骤在搭建Redis-Cluster时,需要进行以下步骤:哈希槽分配: 将整个数据集划分为16384个槽,每个槽由一个唯一的整数标识。这些槽会被均匀分配到集群中的各个节点上。节点配置: 配置每个节点的信息,包括节点的IP地址、端口号等。每个节点需要知道集群中其他节点的信息以便进行通信。节点启动: 启动各个节点,并使它们加入到集群中。节点之间通过Gossip协议进行通信,实现自动发现和管理。优势:动态扩缩容: 通过哈希槽的方式,实现了集群的动态扩缩容,方便增加或减少节点。传统 Redis 集群搭建:主从配置、数据分片策略等设置在搭建传统Redis集群时,主要涉及以下步骤:主从配置: 确定主节点和从节点,配置主从关系。每个主节点有对应的一个或多个从节点用于数据备份。数据分片策略: 制定数据分片策略,确定哪些数据由哪个主节点负责,以及从节点用于备份的数据。节点启动: 启动各个节点,使其形成集群。主节点负责处理读写请求,从节点用于数据备份和提高系统的可用性。不足:动态扩缩容相对繁琐: 传统Redis集群的扩缩容相对繁琐,需要手动处理节点的添加和移除。通过对比这两种搭建方式,可以看出Redis-Cluster在动态扩缩容方面更为灵活,而传统Redis集群则相对简单可靠。选择哪种方式要根据具体项目需求和团队的运维能力做出权衡。管理与维护的不同之处故障处理:Redis-Cluster 的故障转移机制与传统 Redis 集群的对比Redis-Cluster 故障处理:在Redis-Cluster中,当一个主节点发生故障时,会通过Raft协议或 Sentinel 哨兵机制自动进行故障转移。集群中的其他节点会选举一个新的主节点,然后自动更新槽的分配信息。这样,整个集群的状态得以恢复,而不需要人工干预。传统 Redis 集群故障处理:在传统Redis集群中,当一个主节点发生故障时,由其对应的一个从节点接管主节点的工作。其他从节点会选择一个新的主节点,然后进行数据同步。这个过程需要一定的时间,而且可能导致一小段时间内的服务不可用。动态扩缩容:Redis-Cluster 如何动态添加或移除节点,与传统集群的对比Redis-Cluster 动态扩缩容:在Redis-Cluster中,可以通过向集群添加新节点或从集群中移除节点来实现动态扩缩容。添加新节点时,集群会自动将哈希槽进行重新分配,保持数据的均匀分布。移除节点时,集群同样会重新分配哈希槽,确保数据不会丢失。传统 Redis 集群动态扩缩容:在传统Redis集群中,动态扩缩容相对繁琐。需要手动配置新的主节点和从节点,并确保数据的平衡。移除节点同样需要手动进行,并确保数据的备份和同步。通过对比这两方面的差异,可以看出Redis-Cluster在故障处理和动态扩缩容方面更为灵活和自动化,减轻了运维的负担。传统Redis集群则相对简单可靠,但需要更多手动操作。选择哪种方式要根据具体的项目需求和运维团队的技术水平做出权衡。性能优化的异同数据分布算法:Redis-Cluster 中的哈希槽算法与传统集群的数据分片对比Redis-Cluster 数据分布算法:在Redis-Cluster中,数据的分布是通过哈希槽(Hash Slot)算法实现的。每个槽有一个唯一的整数标识,整个数据集被划分为16384个槽。通过哈希算法将数据映射到相应的槽上,然后分配到集群中的各个节点。这种方式保证了数据在集群中的均匀分布,避免了热点问题。传统 Redis 集群数据分布算法:在传统Redis集群中,数据的分布是由主节点负责的。每个主节点负责一部分数据,并有对应的从节点进行数据备份。数据的分布由主节点的分片策略决定,通常是通过对 key 进行 hash 得到的哈希值来决定数据属于哪个分片。数据一致性:不同集群方案下的数据一致性保障Redis-Cluster 数据一致性:在Redis-Cluster中,由于采用了哈希槽算法,当节点发生故障时,只会影响部分槽的数据。通过Raft协议或 Sentinel 哨兵机制,集群可以自动进行故障转移,确保整个集群的数据一致性。在正常情况下,读写请求会路由到负责相应槽的节点,保证了数据的一致性。传统 Redis 集群数据一致性:在传统Redis集群中,当主节点发生故障时,会由从节点接管主节点的工作。这个过程需要一定的时间,而且可能导致一小段时间内的服务不可用。在这个过程中,数据的一致性可能会受到一定影响。通过对比这两方面的差异,可以看出Redis-Cluster在数据分布算法和数据一致性方面更为先进和可靠。传统Redis集群虽然简单可靠,但在一些大规模和高并发的场景下可能需要更多的优化和手动干预。选择哪种方式要根据具体的项目需求和对一致性的要求做出权衡。
  • [技术干货] 【MyBatis保姆级教程上】近万字从零开始手把手教你玩转数据库操作!配置+CRUD+日志+参数传递全解析-转载
    1.前言哈喽大家好吖,今天我们开始学习MyBatis,今天我们先学习如何用注解的方式完成代码的编写,下一篇博文在完成如何使用XML文件实现MyBatis的编程。无论你是想10分钟快速上手,还是彻底搞懂MyBatis的底层逻辑,无需任何MyBatis基础,我将从环境搭建、日志打印、参数绑定,一路带你手撕增删改查核心操作。2.正文官网:MyBatis中文网(广告有点多看着心刺挠~)2.1MyBatis与JDBCMyBatis 和 JDBC 是 Java 中操作数据库的两个不同层级的工具,它们之间既有继承关系,也有显著的差异:基础关系:JDBC(Java Database Connectivity)是 Java 提供的标准数据库访问接口,定义了操作数据库的通用 API(如 Connection、Statement、ResultSet),但需要开发者手动编写所有数据库操作代码。MyBatis 是基于 JDBC 的持久层框架,对 JDBC 进行了高级封装,简化了数据库操作流程,同时保留了直接编写 SQL 的灵活性。关系总结:MyBatis 底层依赖 JDBC 驱动与数据库交互,但通过封装和扩展,隐藏了 JDBC 的复杂性。特性 JDBC MyBatis代码复杂度 需要手动管理连接、语句、结果集,代码冗余。 自动管理资源(如连接、事务),减少样板代码。SQL 与代码耦合 SQL 嵌入 Java 代码中,难以维护。 SQL 与 Java 代码解耦,通过 XML 或注解配置。结果集映射 需手动遍历 ResultSet 并映射到对象。 自动将结果集映射到 Java 对象(ORM 特性)。动态 SQL 需手动拼接字符串,易出错且不安全。 支持动态 SQL 标签(如 <if>、<foreach>)。事务管理 需手动提交/回滚事务。 支持声明式事务管理(整合 Spring 时更灵活)。缓存机制 无内置缓存。 提供一级缓存(SqlSession 级别)和二级缓存。扩展性 直接控制底层,灵活性高但开发效率低。 通过插件机制扩展功能(如分页、日志)。2.2MyBatis入门2.2.1准备工作配置项目用到的jar包:sql测试代码:-- 创建数据库DROP DATABASE IF EXISTS mybatis_test; CREATE DATABASE mybatis_test DEFAULT CHARACTER SET utf8mb4; -- 使用数据数据USE mybatis_test; -- 创建表[用户表]DROP TABLE IF EXISTS user_info;CREATE TABLE `user_info` (        `id` INT ( 11 ) NOT NULL AUTO_INCREMENT,        `username` VARCHAR ( 127 ) NOT NULL,        `password` VARCHAR ( 127 ) NOT NULL,        `age` TINYINT ( 4 ) NOT NULL,        `gender` TINYINT ( 4 ) DEFAULT '0' COMMENT '1-男 2-女 0-默认',        `phone` VARCHAR ( 15 ) DEFAULT NULL,        `delete_flag` TINYINT ( 4 ) DEFAULT 0 COMMENT '0-正常, 1-删除',        `create_time` DATETIME DEFAULT now(),        `update_time` DATETIME DEFAULT now() ON UPDATE now(),        PRIMARY KEY ( `id` ) ) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;  -- 添加用户信息INSERT INTO mybatis_test.user_info( username, `password`, age, gender, phone )VALUES ( 'admin', 'admin', 18, 1, '18612340001' );INSERT INTO mybatis_test.user_info( username, `password`, age, gender, phone )VALUES ( 'zhangsan', 'zhangsan', 18, 1, '18612340002' );INSERT INTO mybatis_test.user_info( username, `password`, age, gender, phone )VALUES ( 'lisi', 'lisi', 18, 1, '18612340003' );INSERT INTO mybatis_test.user_info( username, `password`, age, gender, phone )VALUES ( 'wangwu', 'wangwu', 18, 1, '18612340004' );  -- 创建文章表DROP TABLE IF EXISTS article_info;        CREATE TABLE article_info (        id INT PRIMARY KEY auto_increment,        title VARCHAR ( 100 ) NOT NULL,        content TEXT NOT NULL,        uid INT NOT NULL,        delete_flag TINYINT ( 4 ) DEFAULT 0 COMMENT '0-正常, 1-删除',        create_time DATETIME DEFAULT now(),        update_time DATETIME DEFAULT now() ) DEFAULT charset 'utf8mb4'; -- 插入测试数据INSERT INTO article_info ( title, content, uid ) VALUES ( 'Java', 'Java正文', 1 );AI写代码sql2.2.2配置数据库配置yml文件:spring:  application:    name: mybatis-demo  # 数据库配置  datasource:    url: jdbc:mysql://127.0.0.1:3306/mybatis_test?characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true    username: root    password: root    driver-class-name: com.mysql.cj.jdbc.DriverAI写代码sql2.2.3持久层代码编写代码规范:数据库字段:全部小写,单词之间使用_分割Java规范:属性使用小驼峰来表示 model包(采用注解的方式实现):@Mapperpublic interface UserInfoMapper {     @Select("select * from user_info")    List<UserInfo> selectAll();}AI写代码java运行mapper包:@Datapublic class UserInfo {    private Integer id;    private String username;    private String password;    private Integer age;    private Integer gender;    private String phone;    private Integer deleteFlag;    private Date createTime;    private Date updateTime;}AI写代码java运行2.2.4测试第一种测试方法(在Test文件夹写测试代码):在test文件下创建mapper包,再在mapper包中创建Test用于测试数据库数据:@SpringBootTestclass UserInfoMapperTest {     @Autowired    private UserInfoMapper userInfoMapper;     @Test    void selectAll() {        System.out.println(userInfoMapper.selectAll());    }}AI写代码java运行测试结果:  或者直接在test文件夹下原有的Tests文件进行测试:@SpringBootTestclass MybatisDemoApplicationTests { @Autowiredprivate ApplicationContext context;//在测试代码中直接注入 @Testvoid contextLoads() {UserInfoMapper bean = context.getBean(UserInfoMapper.class);bean.selectAll().stream().forEach(x-> System.out.println(x));} }AI写代码java运行也可以正常输出结果。 这里讲解两个新的注解:@Test 注解:作用:标记一个方法为单元测试方法,用于验证单个代码单元的逻辑(如一个方法、一个类)。特点:不依赖 Spring 上下文:测试方法独立运行,不启动 Spring 容器。轻量快速:适合测试纯 Java 代码逻辑,无需加载 Spring 相关组件。需手动处理依赖:如果被测试的类依赖其他组件(如 Service、Repository),需要手动 Mock(例如用 Mockito)。@SpringBootTest 注解:作用:标记一个测试类为集成测试,启动完整的 Spring 应用上下文,模拟真实运行环境。特点:加载完整 Spring 上下文:自动扫描配置、Bean、数据库、外部服务等。支持依赖注入:可以直接使用 @Autowired 注入 Bean。配置灵活:通过 webEnvironment 指定 Web 环境(如模拟 Servlet 环境、真实端口等)。较慢:因为加载整个应用,适合测试组件交互、API 接口等。特性 @Test @SpringBootTest测试类型 单元测试 集成测试Spring 上下文 不加载 加载完整 Spring 上下文依赖注入 不支持(需手动 Mock) 支持(通过 @Autowired)执行速度 快 较慢(因加载上下文)适用场景 验证纯 Java 逻辑 测试组件交互、数据库操作、API 接口第二种测试方法(通过Controller调用service中方法): 代码结构:userController:@RequestMapping("/user")@RestControllerpublic class UserInfoController {     @Autowired    private UserService userService;     @RequestMapping("/getAllUser")    public List<UserInfo> getAllUser(){        return userService.getAllUser();    }}AI写代码java运行userService:@Servicepublic class UserService {    @Autowired    private UserInfoMapper userInfoMapper;     public List<UserInfo> getAllUser() {        return userInfoMapper.selectAll();    }}AI写代码java运行运行DemoApplication,并访问网页:同样测试成功。此外,我们会还有第三种方法来写测试用例,即让编译器为我们生成:右键generate选择test出现:解释@Before与@After:一个测试方法前实现,一个测试方法后实现将二者勾选上后覆盖:@SpringBootTestclass UserInfoMapperTest {     @Autowired    private UserInfoMapper userInfoMapper;     @Test    void selectAll() {        System.out.println(userInfoMapper.selectAll());    }     @BeforeEach    void setUp() {        System.out.println("before.......");    }     @AfterEach    void tearDown() {        System.out.println("after......");    }}AI写代码java运行输出结果如下:至此,测试流程已讲解完毕,接下来来讲解MyBatis的基础操作。 2.3MyBatis基础操作2.3.1打印日志为了方便学习基础操作,很显然打印日志必不可少,所以我们先配置以下日志的输出格式:mybatis:  # 配置 mybatis xml 的文件路径,在 resources/mapper 创建所有表的 xml 文件  mapper-locations: classpath:mapper/**Mapper.xml  configuration:     log-impl: org.apache.ibatis.logging.stdout.StdOutImpl    map-underscore-to-camel-case: true #配置驼峰自动转换AI写代码java运行输出测试日志:在日志中间部分:==>:表示向数据库输入的<==:表示数据库返回的2.3.2参数传递MyBatis 的参数传递规则围绕 方法参数名与 SQL 占位符的绑定,分为以下几种场景:1. 无参数方法@Select("SELECT * FROM user_info")List<UserInfo> selectAll();AI写代码java运行场景:无需传递参数。要点:直接执行 SQL,无需处理参数绑定。 2. 单参数方法案例 1:selectAllById1@Select("select * from user_info where id = #{id}")    UserInfo selectAllById1(Integer id);AI写代码java运行案例 2:selectAllById2@Select("select * from user_info where id = #{id}")    List<UserInfo> selectAllById2(Integer id);AI写代码java运行参数规则:单参数时:占位符 #{xxx} 的 xxx 可以任意命名(如 #{id}、#{value}、#{abc} 均可)。MyBatis 默认按参数顺序绑定,与占位符名称无关。但为了可读性,建议占位符名称与参数名一致。返回值差异:selectAllById1:返回单个对象(查询结果最多一条时)。selectAllById2:返回列表(即使结果只有一条)。 3. 多参数方法@Select("select * from user_info where username = #{username} and password = #{password}")    List<UserInfo> selectByNameAndPassword(String username, String password);AI写代码java运行参数规则:默认行为(无 @Param 注解):MyBatis 会将多个参数封装为 Map,键为 param1, param2, ... 或 arg0, arg1, ...。此时 SQL 占位符必须使用 {param1}、#{param2} 或 #{arg0}、#{arg1}:直接使用参数名(如 #{username})会报错,因为默认无法解析参数名。推荐做法(使用 @Param 注解):@Select("SELECT * FROM user_info WHERE username = #{username} AND password = #{password}")List<UserInfo> selectByNameAndPassword(    @Param("username") String username,     @Param("password") String password);AI写代码java运行通过 @Param 显式指定参数名,占位符 #{xxx} 必须与注解值一致。本质:MyBatis 会将参数封装为 Map,键为 @Param 指定的名称。MyBatis 的参数绑定最终是 基于键值对的 Map 结构:单参数:键可以是任意名称,或通过 @Param 指定。多参数:必须通过 @Param 指定键,否则只能用 param1/arg0 等默认键。 这里最后对比下不同的参数传递:场景 占位符写法示例 是否需要 @Param 注意事项无参数 无 否 直接执行 SQL单参数 #{任意名称} 否(建议与参数名一致) 占位符名称无约束,但需保持语义清晰多参数(默认) #{param1}, #{arg0} 否 可读性差,易出错多参数(推荐) #{自定义名称} 是 需通过 @Param 指定名称2.3.3增测试代码:    @Options(useGeneratedKeys = true, keyProperty = "id")    @Insert("insert into user_info(username, password, age) values (#{username}, #{password}, #{age})")    Integer insertUser(UserInfo userInfo);AI写代码java运行1. 核心功能作用:向 user_info 表插入一条用户记录,并回填数据库生成的自增主键到 UserInfo 对象的 id 属性。返回值:返回插入操作影响的行数(通常为 1)。2. 注解解析(1) @Insert 注解定义 SQL:指定插入语句,使用 #{属性名} 占位符绑定参数。#{username}、#{password}、#{age} 会调用 UserInfo 对象的 getUsername()、getPassword()、getAge() 方法获取值。要求:UserInfo 类必须有对应的 getter 方法。(2) @Options 注解用途:控制 SQL 执行的附加选项。关键参数:useGeneratedKeys = true:启用数据库自增主键回填。keyProperty = "id":将生成的主键值回填到 UserInfo 对象的 id 属性中。要求:数据库表的主键字段必须支持自增(如 MySQL 的 AUTO_INCREMENT)。UserInfo 类必须有 id 属性的 setter 方法(即 setId)。2.3.4删(简)delete from user_info where id=6AI写代码sql把SQL中的常量替换为动态的参数Mapper接口:@Delete("delete from user_info where id = #{id}")void delete(Integer id);AI写代码java运行2.3.5改(简)update user_info set username="zhaoliu" where id=5AI写代码sql把SQL中的常量替换为动态的参数Mapper接口:@Update("update user_info set username=#{username} where id=#{id}")void update(UserInfo userInfo);AI写代码java运行2.3.6查我们前文讲解参数传递的时候牵扯到一些查询操作,但难免会遇到比如代码中的参数名与表中的参数名不一致的情况接下来我们介绍三种解决该问题的方式。2.3.6.1起别名第一个方案这里我们就需要用到别名来进行操作了:@Select("select id, username,`password`, age, gender,  phone, " +            "delete_flag as deleteFlag, create_time as createTime, update_time as updateTime" +            " from user_info")     List<UserInfo> selectAll();AI写代码java运行2.3.6.2结果映射 当然,上面那个代码显得十分臃肿,这时又有注解来给咱们减轻压力了:只要告诉注解,哪个参数和哪个参数是一一对应即可。@Results(id = "BaseMap", value = {            @Result(column = "delete_flag", property = "deleteFlag"),            @Result(column = "create_time", property = "createTime"),            @Result(column = "update_time", property = "updateTime")    })    @Select("select * from user_info")    List<UserInfo> selectAll();AI写代码java运行正常运行~核心思路解析:@Results 注解的作用与使用:1. @Results 注解的作用定义结果映射规则:将查询结果中的数据库字段(column)映射到 Java 对象的属性(property)。解决命名差异:当数据库字段名与 Java 属性名不一致时(如蛇形命名 delete_flag vs 驼峰命名 deleteFlag),需手动指定映射关系。复用映射配置:通过 id = "BaseMap" 标记此映射规则,可在其他方法中通过 @ResultMap("BaseMap") 复用。2. 关键注解解析注解/属性 说明@Results 定义一组结果映射规则,包含多个 @Result。id = "BaseMap" 为此映射规则命名,方便其他方法通过 @ResultMap 引用。@Result 定义单个字段的映射关系。column = "delete_flag" 数据库字段名(实际查询结果中的列名)。property = "deleteFlag" Java 实体类属性名(UserInfo 类中的属性)。3. 执行流程执行查询:调用 selectAll() 方法,MyBatis 执行 SQL 查询。结果映射:根据 @Results 定义的规则,将结果集中的 delete_flag 映射到 UserInfo.deleteFlag。同理处理 create_time → createTime、update_time → updateTime。返回对象:将映射后的 UserInfo 对象装入列表返回。2.3.6.3开启驼峰命名 当然我们可以在yml文件中配置使代码更加便捷:mybatis:  # 配置 mybatis xml 的文件路径,在 resources/mapper 创建所有表的 xml 文件  configuration: # 配置打印 MyBatis日志    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl    map-underscore-to-camel-case: true #配置驼峰自动转换AI写代码java运行当然想使用这个配置,你的变量的命名必须严格按照小驼峰命名法(代码就很简单啦)。@Select("select * from user_info")    List<UserInfo> selectAll();AI写代码java运行正常输出:3.小结今天的分享到这里就结束了,喜欢的小伙伴点点赞点点关注,需要所有的源代码可以去我的gitee上就可以啦~你的支持就是对我最大的鼓励,大家加油!————————————————                            版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。                        原文链接:https://blog.csdn.net/2301_81073317/article/details/148234380
  • [技术干货] 【技术合集】2025年5月数据库技术合集
    这一组文章聚焦于数据库智能化与 Redis 高可用实践,展现了从智能体自动生成 SQL 到 Redis 高并发问题应对的全流程能力。文章《从此,SQL 不写一行,智能体全帮我搞定!》介绍了基于 AI 智能体实现自动 SQL 生成与数据操作的技术趋势,展望了无代码或低代码数据库操作的未来场景。接下来的几篇 Redis 相关技术干货依次揭示了事务机制的工作原理与使用场景(如 MULTI、EXEC)、缓存雪崩产生的原因与系统保护策略(例如设置合理过期时间、加锁等)、以及如何防止缓存击穿,例如使用互斥锁和热点数据预加载等方式,为高并发业务场景下的缓存架构设计提供了详尽参考。这些内容不仅提升了开发者在数据库优化和架构防护方面的认知,也为实际项目中的高可用设计提供了理论支持与落地方案。⸻📚 标题与链接列表:1. 标题: 从此,SQL 不写一行,智能体全帮我搞定!链接: https://bbs.huaweicloud.com/forum/thread-0259183184570939155-1-1.html2. 标题: Redis事务悄然而至:命令的背后故事链接: https://bbs.huaweicloud.com/forum/thread-0275183567368800179-1-1.html3. 标题: Redis缓存雪崩:预防、应对和解决方案链接: https://bbs.huaweicloud.com/forum/thread-0261183567445363103-1-1.html4. 标题: Redis缓存保卫战:拒绝缓存击穿的进攻链接: https://bbs.huaweicloud.com/forum/thread-0278183567506595166-1-1.html
  • Redisson 实现的分布式锁相对于 SETNX 的核心优势对比
    Redisson 实现的分布式锁相对于 SETNX 的核心优势在于​​原子性保障、功能扩展性、可靠性及开发友好性​​。以下是具体对比分析:​​一、核心优势对比​​​​特性​​​​SETNX 实现​​​​Redisson 实现​​​​优势说明​​​​原子性操作​​需组合 SETNX + EXPIRE 命令,非原子性操作通过 Lua 脚本实现加锁、续期、释放锁的原子性避免竞态条件(如锁超时后业务未完成导致锁失效)^1,6​​自动续期(看门狗)​​需手动实现后台线程续期内置看门狗机制,自动延长锁有效期(默认 30 秒续期至 30 秒)防止长事务因锁超时导致的并发问题^3,4​​可重入性​​需自行维护线程标识和计数器默认支持可重入锁,通过 Hash 结构记录线程 ID 和重入次数支持递归调用或嵌套锁场景^2,5​​锁释放安全性​​需通过 Lua 脚本校验锁标识,否则可能误删其他线程的锁自动校验锁持有者身份,仅允许持有者释放锁避免误删锁引发的并发问题^6,8​​高可用性​​单节点 Redis 存在单点故障风险支持 RedLock 算法(多节点集群)和哨兵模式提升锁服务的容灾能力(@ref)​​开发复杂度​​需手动实现锁续期、可重入、异常处理等逻辑提供 RLock 接口和丰富 API(如 tryLock、lockInterruptibly)简化代码,降低维护成本^3,7​​二、Redisson 的核心优势解析​​​​1. 原子性保障​​​​Lua 脚本封装​​:Redisson 将加锁、续期、释放锁等操作封装为原子性 Lua 脚本,避免多命令组合的非原子风险。-- 加锁 Lua 脚本(简化版)if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then redis.call('hincrby', KEYS[1], ARGV[1], 1) redis.call('pexpire', KEYS[1], ARGV[2]) return nilelse redis.call('pexpire', KEYS[1], ARGV[2]) return 0end该脚本确保​​判断锁状态、更新计数器、设置过期时间​​的原子性^6,8。​​2. 自动续期机制​​​​看门狗线程​​:后台线程定期检查锁的剩余存活时间,若小于阈值(如 10 秒),则通过 EXPIRE 命令续期。​​动态调整​​:续期间隔根据锁的剩余时间动态计算,避免频繁续期带来的性能损耗^3,4。​​3. 可重入性支持​​​​数据结构​​:使用 Redis Hash 存储锁标识(如 lockName: { "threadId": 重入次数 })。​​重入逻辑​​:同一线程再次获取锁时,直接增加计数器,无需重新竞争锁^2,5。​​4. 高可用方案​​​​RedLock 算法​​:通过多个独立 Redis 实例实现分布式锁,需超过半数节点成功加锁。​​哨兵模式​​:自动切换故障节点,保障锁服务的高可用性(@ref)。​​5. 丰富的锁类型​​​​公平锁​​:按请求顺序分配锁,避免饥饿现象。​​联锁(MultiLock)​​:同时锁定多个锁,保证原子性。​​红锁(RedLock)​​:基于多节点集群的强一致性锁^7,9。​​三、适用场景对比​​​​场景​​​​SETNX 适用性​​​​Redisson 适用性​​简单库存扣减✅ 适合(短时操作,无需复杂功能)✅ 适合(但需自行处理续期)长事务处理(>30 秒)❌ 高风险(需手动续期)✅ 推荐(自动续期保障)递归调用或嵌套锁❌ 需自行实现可重入逻辑✅ 原生支持高并发读写锁分离❌ 需自行实现读写锁逻辑✅ 提供 RReadWriteLock多节点集群环境❌ 需自行实现 RedLock✅ 原生支持 RedLock 和哨兵模式​​四、性能对比​​​​单节点模式​​:SETNX 方案 TPS 约 ​​35,000 次/秒​​(轻量级操作)。Redisson 方案 TPS 约 ​​28,000 次/秒​​(因 Hash 操作和后台线程开销)(@ref)。​​集群模式​​:Redisson 的 RedLock 在高可用场景下性能更优,且支持动态扩缩容。​​五、总结:为何选择 Redisson?​​​​开发效率​​:提供完整 API 和自动续期、可重入等高级功能,减少编码复杂度。​​可靠性​​:通过原子性操作和看门狗机制规避单点故障和锁超时风险。​​扩展性​​:支持公平锁、联锁、红锁等复杂场景,适应企业级需求。​​社区支持​​:Redis 官方推荐方案,生态完善,问题修复及时。​​适用建议​​:若业务简单且对性能敏感(如秒杀库存),可优化 SETNX 实现。若涉及长事务、可重入性或高可用需求,优先选择 Redisson。
  • RedLock分布式锁算法
    ​​1. 什么是 RedLock?​​RedLock 是 Redis 官方提出的一种​​分布式锁算法​​,由 Redis 作者 Salvatore Sanfilippo(Antirez)设计,旨在解决单点 Redis 实例作为分布式锁时的​​单点故障问题​​。其核心思想是通过​​多节点投票机制​​实现锁的强一致性:​​多节点加锁​​:客户端需在多个独立 Redis 实例(通常为奇数个,如 5 个)上同时尝试获取锁。​​多数派确认​​:只有当超过半数节点(如 5 个节点中的 3 个)成功加锁时,才认为锁获取成功。​​容错性​​:即使部分节点故障,只要多数节点存活,锁服务仍可用。​​2. RedLock 解决了什么问题?​​RedLock 主要解决以下问题:​​单点故障​​:传统单节点 Redis 锁在主节点宕机时会导致锁失效,而 RedLock 通过多节点冗余避免这一问题。​​主从同步延迟​​:在 Redis 主从架构中,主节点加锁后若未同步到从节点即宕机,从节点晋升为主节点可能导致锁丢失。RedLock 通过多节点确认机制规避此风险。​​高可用性​​:即使部分节点故障,锁服务仍能继续运行。​​3. Redisson 中为何废弃 RedLock?​​尽管 RedLock 设计初衷良好,但存在以下关键问题,导致 Redisson 官方废弃:​​性能瓶颈​​​​网络延迟​​:需多次与多节点交互,加锁耗时增加,尤其在高延迟网络中表现更差。​​竞争开销​​:多数派机制要求等待多数节点响应,加锁成功率受网络和节点负载影响。​​并发安全性争议​​​​时钟依赖​​:RedLock 依赖系统时钟判断锁超时,若时钟发生跳跃(如 NTP 同步),可能导致锁提前释放。​​GC 停顿风险​​:客户端垃圾回收(GC)停顿可能导致锁超时,其他客户端获取锁后,原客户端误判锁仍有效,引发并发问题。​​实现复杂度高​​​​节点管理​​:需手动维护多个独立 Redis 实例,且需确保密钥分散在不同节点,增加运维成本。​​争议性设计​​:社区对 RedLock 的安全性存在分歧(如 Martin Kleppmann 与 Antirez 的争论),且无完美解决方案。​​替代方案更优​​​​ZooKeeper/etcd​​:基于强一致性的原生分布式锁实现,更适合高并发场景。​​Redis 集群 + 看门狗​​:通过 Redis 集群和自动续期机制(如 Redisson 的 Watch Dog)提升可靠性,简化实现。​​总结​​​​RedLock 核心价值​​:通过多节点投票机制解决单点故障问题,提升锁的可用性。​​废弃原因​​:性能、安全性争议及复杂度高,且已有更优替代方案(如 ZooKeeper、Redis 集群)。​​Redisson 的替代方案​​:推荐使用 RLock(基于单节点 + 看门狗)或集群化 Redis 实现分布式锁。
  • 什么是Redisson 的看门狗机
    Redisson 的​​看门狗机制(Watch Dog)​​是其分布式锁(如 RLock)的核心特性之一,用于解决​​锁自动过期导致业务未完成锁失效​​的问题。它通过后台线程动态延长锁的持有时间,确保业务逻辑执行期间锁不会意外释放。​​一、为什么需要看门狗机制?​​在分布式系统中,如果客户端获取锁后,业务逻辑执行时间超过了锁的预设过期时间(如 30 秒),锁会自动释放。此时其他客户端可能获取到锁,导致​​并发安全问题​​(如数据覆盖)。​​看门狗的作用​​:在锁即将过期时,自动续期(延长锁的过期时间),确保业务逻辑执行完成前锁始终有效。​​二、看门狗机制的核心原理​​​​1. 锁的初始设置​​当客户端通过 lock.lock() 获取锁时,Redisson 会执行以下 Redis 命令:SET lock_key unique_value NX EX 30NX:仅当键不存在时设置锁。EX 30:锁的初始过期时间为 30 秒。unique_value:唯一标识(如 UUID + 线程 ID),用于确保只有锁持有者能释放锁。​​2. 看门狗的触发​​​​续期条件​​:当锁的剩余存活时间小于等于 ​​看门狗的默认阈值(如 1/3 锁过期时间)​​ 时,触发续期。例如,锁初始过期时间为 30 秒,当剩余时间 ≤ 10 秒时,看门狗开始工作。​​续期频率​​:默认每隔 ​​1/3 锁过期时间​​ 续期一次(如 30 秒锁每隔 10 秒续期)。​​3. 续期操作​​看门狗会通过 Redis 的 EXPIRE 命令,将锁的过期时间​​重置为初始值​​(如再次设置为 30 秒)。EXPIRE lock_key 30​​续期次数无限制​​:只要锁未被释放,看门狗会持续续期。​​4. 锁释放​​当调用 lock.unlock() 时,Redisson 会通过 Lua 脚本验证 unique_value,确保只有锁持有者能释放锁:if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then return redis.call('del', KEYS[1])else return 0end释放锁后,看门狗线程终止。​​三、看门狗的实现细节​​​​1. 看门狗线程​​Redisson 内部维护一个 ScheduledExecutorService 线程池,用于执行锁续期任务。每个锁对应一个独立的续期任务。​​2. 动态调整续期间隔​​续期间隔根据锁的初始过期时间动态计算。例如:初始过期时间 leaseTime = 30,000 ms。看门狗触发间隔为 leaseTime / 3(约 10 秒)。​​3. 续期逻辑伪代码​​class WatchDog extends Thread { private final RLock lock; private long leaseTime; // 初始过期时间(如30秒) private long lastRenewTime; public void run() { while (isLocked()) { long currentTime = System.currentTimeMillis(); // 检查是否需要续期(剩余时间 ≤ leaseTime / 3) if (currentTime - lastRenewTime >= leaseTime / 3) { renewLock(); // 调用 EXPIRE 命令续期 lastRenewTime = currentTime; } Thread.sleep(100); // 适当休眠避免频繁检查 } } private void renewLock() { // 发送 EXPIRE 命令重置锁过期时间 redis.expire(lockKey, leaseTime); }}​​四、看门狗的优缺点​​​​优点​​​​避免业务未完成锁失效​​:确保长耗时任务执行期间锁始终有效。​​无感知续期​​:开发者无需手动管理锁续期。​​高可靠性​​:通过唯一标识(unique_value)和 Lua 脚本保证原子性。​​缺点​​​​潜在性能开销​​:续期操作会增加 Redis 请求频率。​​客户端崩溃风险​​:如果客户端崩溃且未释放锁,看门狗会持续续期,导致锁永久占用(需依赖超时机制或手动干预)。​​五、配置与调优​​​​1. 自定义锁过期时间​​可以通过 lock.lock(leaseTime, timeUnit) 指定锁的初始过期时间:// 设置锁过期时间为 60 秒lock.lock(60, TimeUnit.SECONDS);​​2. 关闭看门狗​​不推荐关闭,但可通过设置 leaseTime 为 -1 禁用自动续期:// 锁永不自动续期(需手动释放)lock.lock(-1, TimeUnit.SECONDS);​​3. 调整续期间隔​​通过修改 Redisson 配置调整续期频率(需源码定制)。​​六、典型应用场景​​​​长事务处理​​:如订单支付、批量数据同步。​​微服务分布式锁​​:确保跨服务资源一致性。​​定时任务防并发​​:避免多个节点同时执行同一任务。​​七、总结​​Redisson 的看门狗机制通过​​动态续期​​解决了分布式锁因业务耗时过长导致的失效问题,其核心是:基于 Redis 的 EXPIRE 命令实现锁续期。通过后台线程监控锁状态并自动续期。结合唯一标识和 Lua 脚本保证安全性。​​使用建议​​:默认开启看门狗,合理设置锁的初始过期时间。在业务逻辑中确保及时释放锁(调用 unlock())。对于极高并发场景,可结合 Redisson 的看门狗日志监控性能开销。
  • 走进Redis的渐进式Rehash
    Redis 的渐进式 Rehash 是一种避免一次性大规模数据迁移导致服务阻塞的优化机制,主要用于哈希表(Hash Table)的扩容(rehash)和缩容操作。它的核心思想是将耗时的 rehash 过程分散到多次请求中逐步完成,从而保证 Redis 服务的响应性。​​一、为什么需要渐进式 Rehash?​​​​背景问题​​Redis 的哈希表(如字典 dict)是核心数据结构,当元素数量增加时,需要扩容(rehash)以减少哈希冲突;当元素减少时,需要缩容以节省内存。​​一次性 Rehash 的问题​​:如果哈希表非常大(例如百万级键值),一次性迁移所有键值会阻塞主线程,导致 Redis 服务暂停响应。​​渐进式 Rehash 的优势​​​​非阻塞​​:将 Rehash 分散到多次操作中,每次只迁移少量数据,避免长时间阻塞。​​平滑过渡​​:在 Rehash 期间,Redis 仍能正常处理读写请求,保证高可用性。​​二、Rehash 触发条件​​Redis 的哈希表(dict)会在以下情况触发 Rehash:​​扩容​​:当哈希表的负载因子(元素数量 / 哈希桶数量)超过 1(默认阈值)时,触发扩容(通常是翻倍)。​​缩容​​:当负载因子低于 0.1 时,触发缩容(通常是减半)。​​三、渐进式 Rehash 的过程​​​​1. 初始化阶段​​当需要 Rehash 时,Redis 会创建一个新的哈希表(ht[1]),大小为原哈希表(ht[0])的两倍(扩容)或一半(缩容)。设置 rehashidx = 0,表示从 ht[0] 的第一个桶开始迁移。​​2. 渐进式迁移​​​​每次操作附带迁移​​:在 Redis 执行命令(如 GET、SET、HGET 等)时,会顺带迁移 ht[0] 中的一部分数据到 ht[1]。​​迁移步骤​​:每次迁移一个哈希桶(bucket)中的所有键值对。例如,首次迁移 ht[0] 的第 0 号桶,第二次迁移第 1 号桶,依此类推。​​更新 rehashidx​​:每迁移完一个桶,rehashidx 递增,直到所有桶迁移完成(rehashidx == ht[0].size)。​​3. 完成 Rehash​​当所有桶迁移完成后,Redis 将 ht[1] 设为新的主哈希表(ht[0] = ht[1]),并释放旧的 ht[0]。此时,Rehash 结束。​​四、Rehash 期间的数据访问​​在渐进式 Rehash 过程中,Redis 需要同时处理旧表(ht[0])和新表(ht[1])的读写操作:​​查找操作​​:先在 ht[0] 的当前 rehashidx 范围内查找,如果未找到,则到 ht[1] 中查找。// 伪代码示例def get(key): if rehashing: idx = dict_rehashidx(d) bucket = &d->ht[0].table[idx] while (bucket->used > 0) { if (key matches) return value; bucket++; } // 如果旧表未找到,转向新表 return lookup_in_ht1(key); else: return lookup_in_ht0(key);​​写入操作​​:直接写入 ht[1],保证新数据不会丢失。​​删除操作​​:需同时在 ht[0] 和 ht[1] 中删除(如果存在)。​​五、关键细节​​​​Rehash 索引 (rehashidx)​​记录当前迁移进度,初始为 0,完成时为 ht[0].size。可通过 INFO keyspace 命令查看 rehashidx 的值(例如 dict_rehashidx:0 表示正在 Rehash)。​​强制触发 Rehash​​可以通过 SHUTDOWN SAVE 或 CONFIG SET active-defrag yes 强制触发 Rehash,但需谨慎使用。​​性能影响​​渐进式 Rehash 会略微增加每个请求的处理时间(因为需要同时处理迁移),但避免了阻塞。在极端情况下(如海量数据),Rehash 可能持续较长时间,但整体影响可控。​​六、示例流程​​假设 ht[0] 有 4 个桶,需要扩容到 8 个桶:初始化 ht[1](8 个桶),设置 rehashidx = 0。处理第一个请求时,迁移 ht[0] 的第 0 号桶。处理第二个请求时,迁移 ht[0] 的第 1 号桶。重复上述步骤,直到 rehashidx = 4,表示迁移完成。释放 ht[0],ht[1] 成为主表。​​七、总结​​​​渐进式 Rehash​​ 是 Redis 为避免大规模数据迁移导致阻塞而设计的优化机制。​​核心过程​​:分批次迁移哈希桶,每次操作附带迁移一部分数据。​​优点​​:保证服务不中断,适用于高并发场景。​​代价​​:迁移期间每个操作略微变慢,但整体性能影响可接受。通过这种设计,Redis 在保证高性能的同时,能够安全地处理哈希表的动态扩缩容。
  • 基于Redission实现一个延迟队列的实践
    基于 Redisson 实现延迟队列可以利用其内置的 RDelayedQueue 组件。以下是详细实现步骤和代码示例:​​1. 核心原理​​Redisson 的延迟队列基于 Redis 的有序集合(Sorted Set)和发布订阅(Pub/Sub)机制实现:​​有序集合​​:存储延迟元素,以到期时间作为分数(score)。​​后台线程​​:定期轮询有序集合,将到期元素转移到普通队列。​​消费者​​:从普通队列中获取到期的消息。​​2. 实现步骤​​​​2.1 添加依赖​​在 Maven 项目中添加 Redisson 依赖:<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.21.0</version> <!-- 使用最新版本 --></dependency>​​2.2 配置 Redisson 客户端​​import org.redisson.Redisson;import org.redisson.config.Config;public class RedissonConfig { public static RedissonClient getClient() { Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); return Redisson.create(config); }}​​2.3 创建延迟队列​​import org.redisson.api.RBlockingQueue;import org.redisson.api.RDelayedQueue;import org.redisson.api.RedissonClient;public class DelayQueueExample { public static void main(String[] args) { RedissonClient redisson = RedissonConfig.getClient(); // 普通阻塞队列(用于存放到期消息) RBlockingQueue<String> destinationQueue = redisson.getBlockingQueue("delayedQueue"); // 延迟队列(绑定普通队列和延迟时间) RDelayedQueue<String> delayedQueue = new RDelayedQueue<>(redisson.getQueue("delayedQueue"), destinationQueue, 0, TimeUnit.SECONDS); // 生产者:发送延迟消息 new Thread(() -> { try { delayedQueue.offer("Order123", 10, TimeUnit.SECONDS); // 10秒后到期 System.out.println("Message sent with 10s delay"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); // 消费者:从目标队列获取到期消息 new Thread(() -> { while (true) { try { String message = destinationQueue.take(); System.out.println("Received: " + message); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }).start(); }}​​3. 关键参数说明​​​​offer(element, delay, timeUnit)​​将元素加入延迟队列,delay 表示延迟时间,timeUnit 是时间单位(如秒、毫秒)。​​take()​​阻塞式获取到期消息,若队列为空则等待。​​4. 高级用法​​​​4.1 自定义消息对象​​public class OrderMessage implements Serializable { private String orderId; private long expireTime; // getters/setters}// 生产者发送RDelayedQueue<OrderMessage> delayedQueue = ...;delayedQueue.offer(new OrderMessage("Order123", System.currentTimeMillis() + 10_000), 0, TimeUnit.SECONDS);// 消费者解析OrderMessage msg = destinationQueue.take();​​4.2 多消费者并发处理​​// 使用线程池消费ExecutorService executor = Executors.newFixedThreadPool(4);for (int i = 0; i < 4; i++) { executor.submit(() -> { while (true) { String message = destinationQueue.take(); processMessage(message); } });}​​5. 注意事项​​​​可靠性保证​​Redisson 内部通过定时任务轮询有序集合,确保消息到期后转移到目标队列。若 Redis 宕机,需结合持久化机制(如 RDB/AOF)保证数据不丢失。​​性能优化​​避免在消费者中使用阻塞操作,防止线程耗尽。对于海量消息,建议使用 RPriorityQueue 或结合 RocketMQ 等专业消息队列。​​超时时间精度​​Redisson 默认的轮询间隔是 5 秒,因此延迟时间精度为 ±5 秒。可通过修改配置调整:Config config = new Config();config.useSingleServer().setAddress("redis://127.0.0.1:6379");config.setScanInterval(2000); // 轮询间隔设为2秒(默认5秒)​​6. 完整流程图​​生产者调用 delayedQueue.offer(message, delay) → 元素存入 Redis Sorted Set(以到期时间作为 score) → Redisson 后台线程定期扫描 Sorted Set → 到期元素被移动到普通队列(destinationQueue) → 消费者通过 destinationQueue.take() 获取消息通过以上步骤,你可以快速实现一个高可用的延迟队列。如果需要更复杂的调度(如动态调整延迟时间),可以结合 Lua 脚本或 Redis 的 ZREMRANGEBYSCORE 命令自行扩展。
  • Redis集群的脑裂
    Redis 集群的脑裂(Split-Brain)是指由于网络分区、节点故障或配置问题,导致集群分裂为多个孤立的子集,每个子集内的节点认为自己是独立的“主节点”(Master),从而引发数据不一致、写入冲突等严重问题。以下是其核心要点:​​一、脑裂的本质与触发场景​​​​定义​​脑裂的本质是分布式系统中的一致性失效,表现为多个主节点同时存在,各自处理写请求,导致数据冲突或丢失。例如:网络分区将集群分为两部分,每部分选举出独立的主节点。主节点假故障(如短暂网络抖动)触发哨兵(Sentinel)或集群(Cluster)的故障转移,但原主节点恢复后与新主节点并存。​​触发场景​​​​网络分区​​:节点间通信中断,子集群独立运行。​​哨兵误判​​:部分哨兵因网络延迟误判主节点宕机,提前选举新主节点。​​主从切换异常​​:旧主节点恢复后未正确降级为从节点,导致新旧主节点并存。​​集群分裂​​:Redis Cluster 因网络问题分裂为多个子集群,各自选举主节点。​​二、脑裂的危害​​​​数据不一致​​多个主节点同时接收写请求,导致相同键值对在不同子集中存在不同版本,最终无法合并。​​数据丢失​​主从切换后,旧主节点被降级为从节点,其数据会被新主节点的全量同步覆盖。脑裂期间原主节点写入的数据可能丢失(如新主节点未同步完成即被覆盖)。​​客户端请求异常​​客户端可能连接到不同的主节点,导致读取旧数据或写入冲突。​​服务不可用​​部分子集群因配置错误或资源竞争无法正常响应请求。​​三、避免脑裂的解决方案​​​​1. 配置参数优化​​​​min-replicas-to-write + min-replicas-max-lag​​主库需满足至少有 N 个从库连接,且从库数据同步延迟不超过 T 秒,否则拒绝写请求。例如:min-replicas-to-write 1min-replicas-max-lag 10此配置可限制假故障主库的写入能力,避免脑裂期间数据不一致。​​cluster-require-full-coverage​​设置为 no,允许部分节点故障时集群仍提供服务,避免因单点故障触发大规模切换。​​WAIT 命令​​写入时强制等待数据同步到指定数量的节点,确保强一致性(需权衡性能)。​​2. 哨兵(Sentinel)机制优化​​​​Quorum 机制​​设置哨兵投票阈值(quorum),只有多数哨兵同意才触发故障转移,减少误判。sentinel monitor mymaster 127.0.0.1 6379 2 # 需2/3哨兵同意​​超时参数调整​​增大 down-after-milliseconds,避免因短暂网络抖动误判主节点故障。​​3. 集群架构设计​​​​多数派原则​​Redis Cluster 要求故障转移需多数主节点同意,避免少数派子集群独立选举主节点。​​客户端重定向​​客户端通过 MOVED 和 ASK 重定向机制自动更新节点拓扑,避免访问孤立主节点。​​4. 网络与监控​​​​网络冗余​​部署多路径网络(如双网卡、冗余交换机),减少网络分区风险。​​实时监控与告警​​监控节点状态、网络延迟、哨兵日志,及时发现异常。​​5. 业务层容错​​​​分布式锁​​使用 Redlock 等算法确保关键操作的原子性,避免并发写入冲突。​​最终一致性​​接受短暂不一致,通过异步补偿或数据校验修复冲突。​​四、总结​​Redis 脑裂的核心风险在于 ​​数据不一致​​ 和 ​​服务不可用​​,其本质是分布式一致性协议与故障恢复机制的局限性。通过 ​​合理配置参数​​(如 min-replicas-to-write)、​​优化哨兵策略​​(如 Quorum 机制)、​​增强网络容错​​ 以及 ​​业务层补偿​​,可显著降低脑裂概率。然而,Redis 本身无法完全避免脑裂,需结合业务需求权衡一致性与可用性。
  • [问题求助] redis集群的脑裂是代表什么?有什么危害,以及如何避免?
    redis集群的脑裂是代表什么?有什么危害,以及如何避免?
  • [技术干货] Redis缓存保卫战:拒绝缓存击穿的进攻
    前言你是否曾经遇到过系统在高并发情况下出现严重性能问题?Redis缓存击穿可能是罪魁祸首。缓存击穿是一种极具挑战性的问题,可能导致系统性能急剧下降,甚至发生数据不一致的情况。在这篇博客中,我们将引领你进入Redis缓存的神秘世界,一探击穿的来龙去脉,并提供解决方案,让你的系统在面对高并发时依然屹立不倒。缓存击穿的定义和原理定义: Redis缓存击穿是指一个非常热门的缓存键在缓存中过期或不存在的情况下,大量请求同时访问该键所对应的数据,导致这些请求直接绕过缓存,直接访问底层的存储系统。原理:热门数据失效:缓存中的某个键对应的数据过期或不存在。大量请求访问:由于该键对应的数据是热门的,大量请求同时访问这个缓存键。绕过缓存:因为缓存中没有对应的数据,这些请求直接绕过缓存,直接访问底层的存储系统(通常是数据库)。存储系统压力增加:大量请求同时访问存储系统,导致存储系统的负载增加,可能引起性能问题。为何会发生缓存击穿缓存击穿通常发生在以下情况下,涉及到缓存失效和大量并发请求两个关键因素:缓存失效: 当一个热门的缓存键对应的数据在缓存中过期或者不存在时,如果此时有大量请求访问这个缓存键,就会导致缓存击穿。缓存失效可能是由于缓存策略设置的过期时间到期,或者手动删除缓存数据引起的。大量并发请求: 缓存击穿通常不是由单一请求引起的,而是由大量并发请求集中在某个特定的热门数据上。这可能是由于系统设计的瓶颈、缓存数据的热度高、某个功能或数据点引起了极大的用户兴趣等原因。当大量请求同时访问一个缓存失效或者不存在的热门数据时,它们都会绕过缓存,直接访问底层存储系统。综合来说,缓存击穿的发生主要是因为缓存中的数据失效,而且失效的数据非常热门,吸引了大量的并发请求。这样一来,大量请求都无法从缓存中获取数据,直接访问底层存储系统,导致存储系统的压力骤增。缓存击穿的危害缓存击穿可能带来一系列严重后果,对系统的稳定性和性能造成负面影响。以下是缓存击穿可能引发的一些危害:系统性能下降: 缓存击穿导致大量请求绕过缓存,直接访问底层存储系统。这会导致存储系统负载骤增,处理大量请求的同时,存储系统的响应时间可能会急剧上升,从而引起整体系统性能的下降。数据库压力激增: 缓存击穿会导致大量请求直接访问数据库,使数据库承受了非常大的压力。数据库可能需要同时处理大量读请求,而这些请求是同时发生的,可能引起数据库连接池耗尽、数据库查询效率下降等问题,最终影响系统的整体性能。服务不可用: 在极端情况下,如果大量请求同时穿透缓存,直接访问存储系统,可能导致存储系统的宕机或响应时间极长,进而影响到整个服务的可用性,使服务对用户不可用。资源浪费: 缓存击穿意味着大量请求对同一资源进行重复的、相似的查询。这不仅导致存储系统的压力,还浪费了系统资源,包括网络带宽、计算资源等。用户体验下降: 由于缓存击穿可能导致系统性能下降和服务不可用,用户在访问该热门数据时可能会面临延迟和失败。这对用户体验产生负面影响,尤其是对于需要实时响应的应用场景。防范缓存击穿为了防范缓存击穿问题,可以采取多种策略和技术手段:热点数据预加载: 在数据即将过期之前,提前异步加载新的数据到缓存中。通过定期或异步地预加载热门数据,可以避免缓存失效时大量请求同时访问。互斥锁机制: 在获取缓存数据之前,先尝试获取锁,只有一个线程能够从底层存储系统中加载数据,其他线程需要等待锁释放。这样可以避免多个线程同时访问存储系统,减轻了缓存击穿的可能性。设置合理的缓存失效时间: 缓存的过期时间应该设置得既不会导致数据过于陈旧,也不会过于频繁地触发缓存失效。合理的过期时间有助于平衡缓存的新鲜度和系统性能。使用缓存穿透保护机制: 在缓存中存储空对象或者特殊标记,当缓存中的值是空时,不再继续访问底层存储系统,而是直接返回空结果,从而防止大量请求穿透到存储系统。分布式锁: 在分布式系统中,使用分布式锁可以确保在集群环境中只有一个节点能够执行缓存失效时的数据加载操作,防止多个节点同时加载相同数据。缓存雪崩处理: 缓存雪崩是指缓存中大量的数据在同一时刻失效,导致大量请求直接访问底层存储系统。为了避免缓存雪崩,可以通过设置不同的过期时间、使用多级缓存等方式来分散缓存失效的时刻。监控和报警系统: 部署监控和报警系统,及时捕获系统中可能发生的缓存击穿情况,以便快速响应和修复。这些策略和技术手段的综合应用可以有效地防范缓存击穿问题,提高系统的稳定性和性能。根据具体应用场景和需求,可以选择合适的组合来应对缓存击穿的挑战。结语:通过深入了解Redis缓存击穿,我们可以更好地理解并解决在高并发环境下可能遇到的问题。合理而强大的缓存保护机制是确保系统高性能运行的关键一环,希望本文对你构建更健壮的系统提供有益的指导。
总条数:514 到第
上滑加载中