当前位置: 首页 > news >正文

Java开发 - 缓存

 一、RedisUtil封装

package com.qj.redis.util;import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;/*** Redis工具类* 提供Redis常见的操作封装,简化Redis使用* 使用Spring的RedisTemplate进行底层操作*/
@Component
@Slf4j
public class RedisUtil {// 缓存key的分隔符private static final String CACHE_KEY_SEPARATOR = ".";// Redis操作模板,由Spring注入@Resourceprivate RedisTemplate redisTemplate;/*** 构建缓存key* 将多个字符串参数用分隔符连接起来形成统一的key格式** @param strObjs key的组成部分,可变参数* @return 拼接后的完整key*/public String buildKey(String... strObjs) {return Stream.of(strObjs).collect(Collectors.joining(CACHE_KEY_SEPARATOR));}/*** 判断key是否存在** @param key 要检查的key* @return true-存在,false-不存在*/public boolean exist(String key) {return redisTemplate.hasKey(key);}/*** 删除指定的key** @param key 要删除的key* @return true-删除成功,false-删除失败*/public boolean del(String key) {return redisTemplate.delete(key);}/*** 设置键值对** @param key 键* @param value 值*/public void set(String key, String value) {redisTemplate.opsForValue().set(key, value);}/*** 设置键值对(仅当key不存在时)** @param key 键* @param value 值* @param time 过期时间* @param timeUnit 时间单位* @return true-设置成功,false-设置失败(key已存在)*/public boolean setNx(String key, String value, Long time, TimeUnit timeUnit) {return redisTemplate.opsForValue().setIfAbsent(key, value, time, timeUnit);}/*** 获取指定key的值** @param key 键* @return key对应的值,如果key不存在则返回null*/public String get(String key) {return (String) redisTemplate.opsForValue().get(key);}/*** 向有序集合中添加元素** @param key 集合key* @param value 元素值* @param score 分数(用于排序)* @return true-添加成功,false-添加失败*/public Boolean zAdd(String key, String value, Long score) {return redisTemplate.opsForZSet().add(key, value, Double.valueOf(String.valueOf(score)));}/*** 获取有序集合的元素数量** @param key 集合key* @return 集合中的元素数量*/public Long countZset(String key) {return redisTemplate.opsForZSet().size(key);}/*** 获取有序集合中指定范围的元素** @param key 集合key* @param start 开始索引(从0开始)* @param end 结束索引(-1表示到最后)* @return 元素集合*/public Set<String> rangeZset(String key, long start, long end) {return redisTemplate.opsForZSet().range(key, start, end);}/*** 从有序集合中移除指定元素** @param key 集合key* @param value 要移除的元素值* @return 移除的元素数量*/public Long removeZset(String key, Object value) {return redisTemplate.opsForZSet().remove(key, value);}/*** 从有序集合中移除多个元素** @param key 集合key* @param value 要移除的元素集合*/public void removeZsetList(String key, Set<String> value) {value.stream().forEach((val) -> redisTemplate.opsForZSet().remove(key, val));}/*** 获取有序集合中指定元素的分数** @param key 集合key* @param value 元素值* @return 元素的分数*/public Double score(String key, Object value) {return redisTemplate.opsForZSet().score(key, value);}/*** 获取有序集合中指定分数范围的元素** @param key 集合key* @param start 开始分数* @param end 结束分数* @return 元素集合*/public Set<String> rangeByScore(String key, long start, long end) {return redisTemplate.opsForZSet().rangeByScore(key, Double.valueOf(String.valueOf(start)), Double.valueOf(String.valueOf(end)));}/*** 增加有序集合中指定元素的分数** @param key 集合key* @param obj 元素值* @param score 要增加的分数* @return 增加后的新分数*/public Object addScore(String key, Object obj, double score) {return redisTemplate.opsForZSet().incrementScore(key, obj, score);}/*** 获取有序集合中指定元素的排名(从小到大排序,排名从0开始)** @param key 集合key* @param obj 元素值* @return 元素的排名,如果元素不存在返回null*/public Object rank(String key, Object obj) {return redisTemplate.opsForZSet().rank(key, obj);}
}

二、Redis实现自动预热缓存

说明:当项目启动的时候,预热一部分的缓存的场景,要创建在项目启动时就加载缓存的模块!

1. 定义缓存的抽象类AbstractCache

package com.qj.redis.init;import org.springframework.stereotype.Component;/*** 缓存抽象基类* 定义缓存操作的基本骨架,提供缓存初始化、获取、清理和重载的通用接口* 使用Spring的@Component注解,便于被子类继承并纳入Spring容器管理*/
@Component
public abstract class AbstractCache {/*** 初始化缓存* 抽象方法,需要子类具体实现缓存初始化逻辑* 例如:从数据库加载数据到缓存中*/public abstract void initCache();/*** 获取缓存数据* 抽象方法,需要子类具体实现缓存获取逻辑* @param <T> 缓存数据类型泛型* @return 返回指定类型的缓存数据*/public abstract <T> T getCache();/*** 清理缓存* 抽象方法,需要子类具体实现缓存清理逻辑* 例如:删除Redis中的相关缓存键*/public abstract void clearCache();/*** 重新加载缓存* 默认实现:先清理现有缓存,然后重新初始化缓存* 提供了缓存刷新的标准流程,子类可按需重写*/public void reloadCache() {clearCache();initCache();}
}

2. 实现需要进行缓存的类

(1)SysUserCache

package com.qj.sys.cache;import com.qj.redis.init.AbstractCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;/*** 系统用户缓存实现类* 继承AbstractCache抽象类,提供系统用户相关的缓存操作具体实现* 使用Redis作为缓存存储介质,通过RedisTemplate进行操作*/
@Component // 声明为Spring组件,由Spring容器管理
public class SysUserCache extends AbstractCache {// 定义系统用户缓存在Redis中的键名private static final String SYS_USER_CACHE_KEY = "SYS_USER";// 注入Redis操作模板@Autowiredprivate RedisTemplate redisTemplate;/*** 初始化缓存* 实现抽象方法,将系统用户数据加载到Redis缓存中* 此处为示例,实际应用中可能需要从数据库查询数据后存入缓存*/@Overridepublic void initCache() {// 实际项目中,这里应该与数据库或其他数据源联动// 示例:将用户数据存入Redis,使用字符串类型存储redisTemplate.opsForValue().set(SYS_USER_CACHE_KEY, "qj1");}/*** 获取缓存数据* 实现抽象方法,从Redis中获取系统用户缓存数据* 如果缓存不存在,会自动重新加载缓存* @param <T> 返回数据类型泛型* @return 返回系统用户缓存数据*/@Overridepublic <T> T getCache() {// 检查缓存键是否存在,如果不存在则重新加载缓存if (!redisTemplate.hasKey(SYS_USER_CACHE_KEY).booleanValue()) {reloadCache(); // 调用父类的重新加载缓存方法}// 从Redis中获取缓存数据并返回return (T) redisTemplate.opsForValue().get(SYS_USER_CACHE_KEY);}/*** 清理缓存* 实现抽象方法,删除Redis中的系统用户缓存*/@Overridepublic void clearCache() {// 从Redis中删除系统用户缓存键redisTemplate.delete(SYS_USER_CACHE_KEY);}
}

(2)UserCache

package com.qj.sys.cache;import com.qj.redis.init.AbstractCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;/*** 用户缓存实现类* 继承AbstractCache抽象类,提供用户相关的缓存操作具体实现* 使用Redis作为缓存存储介质,通过RedisTemplate进行操作* 与SysUserCache类似但独立,可根据业务需求区分不同用户缓存*/
@Component // 声明为Spring组件,由Spring容器管理
public class UserCache extends AbstractCache {// 定义用户缓存在Redis中的键名// 注意:此键名与SysUserCache中的键名不同,避免缓存键冲突private static final String USER_CACHE_KEY = "USER";// 注入Redis操作模板,用于执行Redis命令@Autowiredprivate RedisTemplate redisTemplate;/*** 初始化缓存* 实现抽象方法,将用户数据加载到Redis缓存中* 此处为示例,实际应用中需要从数据库或其他数据源获取真实数据*/@Overridepublic void initCache() {// 实际项目中,这里应该与数据库或其他数据源联动// 示例:将用户数据存入Redis,使用字符串类型存储// 注意:此处仅为演示,实际应存储真实用户数据redisTemplate.opsForValue().set(USER_CACHE_KEY, "qj2");}/*** 获取缓存数据* 实现抽象方法,从Redis中获取用户缓存数据* 采用懒加载模式:如果缓存不存在,会自动重新加载缓存* @param <T> 返回数据类型泛型* @return 返回用户缓存数据,实际类型根据调用上下文确定*/@Overridepublic <T> T getCache() {// 检查缓存键是否存在,如果不存在则重新加载缓存// 这种设计确保调用getCache时总能获取到数据(即使缓存意外失效)if (!redisTemplate.hasKey(USER_CACHE_KEY).booleanValue()) {reloadCache(); // 调用父类的重新加载缓存方法}// 从Redis中获取缓存数据并返回,进行类型转换return (T) redisTemplate.opsForValue().get(USER_CACHE_KEY);}/*** 清理缓存* 实现抽象方法,删除Redis中的用户缓存* 通常在数据更新后调用,确保缓存与数据源的一致性*/@Overridepublic void clearCache() {// 从Redis中删除用户缓存键redisTemplate.delete(USER_CACHE_KEY);}
}

3. 定义类来获取ApplicationContext

说明:当一个类实现了ApplicationContextAware接口之后,这个类就可以方便的获得ApplicationContext对象(Spring上下文),Spring发现某个Bean实现了ApplicationContextAware接口,Spring容器会在创建该Bean之后,自动调用该Bean的setApplicationContext(参数)方法,调用该方法时,会将容器本身ApplicationContext对象作为参数传递给该方法。

package com.qj.redis.util;import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;/*** Spring上下文工具类* 实现ApplicationContextAware接口,用于获取Spring应用上下文* 提供静态方法方便在非Spring管理的类中获取Spring容器中的Bean*/
@Component // 声明为Spring组件,由Spring容器管理
public class SpringContextUtil implements ApplicationContextAware {// 静态变量保存Spring应用上下文private static ApplicationContext applicationCtxt;/*** 获取Spring应用上下文* @return 返回当前Spring应用上下文实例*/public static ApplicationContext getApplicationContext() {return applicationCtxt;}/*** 设置Spring应用上下文* 实现ApplicationContextAware接口的方法,Spring容器启动时自动调用* @param applicationContext Spring应用上下文* @throws BeansException 如果设置过程中出现异常*/@Overridepublic void setApplicationContext(ApplicationContext applicationContext) throws BeansException {// 将传入的应用上下文赋值给静态变量,使其可在静态方法中使用applicationCtxt = applicationContext;}/*** 根据类型获取Spring容器中的Bean实例* @param type Bean的Class类型* @param <T> Bean类型泛型* @return 返回指定类型的Bean实例*/public static <T> T getBean(Class<T> type) {return applicationCtxt.getBean(type);}
}

4. 启动并初始化缓存InitCache

说明:在使用SpringBoot构建项目时,我们通常有一些预先数据的加载。那么SpringBoot提供了CommandLineRunner方式来实现,CommandLineRunner是一个接口,我们需要时,只需实现该接口就行(如果存在多个加载的数据,我们也可以使用@Order注解来排序) 

package com.qj.redis.init;import com.qj.redis.util.SpringContextUtil;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;import java.util.Map;
import java.util.Map.Entry;/*** 缓存初始化启动器* 实现CommandLineRunner接口,在Spring Boot应用启动后自动执行缓存预热* 通过@ConditionalOnProperty条件注解控制是否启用缓存预热功能*/
@Component // 声明为Spring组件,由Spring容器管理
@ConditionalOnProperty(name = "init.cache.enable", havingValue = "true") // 条件注解:只有当配置文件中init.cache.enable=true时才启用该类
public class InitCache implements CommandLineRunner {/*** 项目启动时自动执行的方法* 实现CommandLineRunner接口的run方法,Spring Boot启动完成后会自动调用* @param args 命令行参数* @throws Exception 可能抛出的异常*/@Overridepublic void run(String... args) throws Exception {// 获取所有需要预热的缓存实例ApplicationContext applicationContext = SpringContextUtil.getApplicationContext();// 从Spring容器中获取所有AbstractCache类型的Bean// 这些Bean都是具体的缓存实现类(如SysUserCache、UserCache等)Map<String, AbstractCache> beanMap = applicationContext.getBeansOfType(AbstractCache.class);// 如果存在缓存Bean,则遍历并调用它们的初始化方法if (!beanMap.isEmpty()) {for (Entry<String, AbstractCache> entry : beanMap.entrySet()) {// 获取AbstractCache的具体实现类实例// 这里通过SpringContextUtil再次获取Bean是为了确保获取的是Spring管理的代理对象// 这样可以保证AOP等Spring特性正常工作AbstractCache abstractCache = (AbstractCache) SpringContextUtil.getBean(entry.getValue().getClass());// 调用缓存初始化方法,将数据加载到缓存中abstractCache.initCache();}}// 输出缓存预热完成日志System.out.println("缓存预热成功...");}
}

5. 配置文件开启

init:cahce:enable: true

6. 启动并测试

说明:可以看到在项目启动时,控制台顺利输出“缓存成功...”,说明项目成功运行!

注意:查看Redis集群也正常看到已被缓存的两个Key的数据!

三、分布式锁的实现

说明:此处不使用Redission来直接实现,完全手动实现一个抢占式的分布式锁

使用场景:

(1)任务调度(集群环境下,一个服务的多个实例的任务不想同一时间都进行执行)

(2)并发修改相关(操作同一个数据)

1. 依赖文件pom.xml

说明:添加commons-lang包,用来做字符串的校验!

Apache Commons Lang 是一个提供了许多Java语言核心类扩展功能的工具库,主要包括:

  1. 字符串处理:增强的字符串操作方法

  2. 数值处理:数字和数值类型的工具类

  3. 对象操作:对象比较、哈希码生成、toString方法等

  4. 异常处理:异常链和嵌套异常处理

  5. 系统属性:Java系统属性访问工具

  6. 随机数生成:更强大的随机数生成器

  7. 日期时间处理:日期和时间操作工具

<dependency><groupId>commons-lang</groupId><artifactId>commons-lang</artifactId><version>2.6</version>
</dependency>

2. 运行时异常类ShareLockException

/*** 共享锁异常类* 自定义运行时异常,用于处理共享锁操作中的异常情况* 继承自RuntimeException,属于非受检异常(unchecked exception)* * 使用场景:* - 当共享锁获取失败时抛出* - 当共享锁操作超时时抛出* - 当共享锁状态异常时抛出*/
public class ShareLockException extends RuntimeException {/*** 构造函数* @param message 异常详细信息,用于说明异常原因和上下文*/public ShareLockException(String message) {// 调用父类RuntimeException的构造函数,传入异常消息super(message);}}

3. RedisUtil中添加相关方法

package com.qj.redis.util;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;import java.util.concurrent.TimeUnit;/*** Redis操作工具类* 封装常用的Redis操作方法,提供简洁的API供业务代码调用* 使用Spring的RedisTemplate进行底层操作*/
@Component // 声明为Spring组件,由Spring容器管理
public class RedisUtil {// 注入Redis操作模板@Autowiredprivate RedisTemplate redisTemplate;// 这里可能有其他方法,用...表示/*** 设置键值对(仅当键不存在时)* 原子操作,常用于分布式锁的实现* * @param key 缓存的键* @param value 缓存的值* @param time 过期时间* @param timeUnit 时间单位* @return 设置成功返回true,键已存在返回false*/public boolean setNx(String key, String value, Long time, TimeUnit timeUnit) {return redisTemplate.opsForValue().setIfAbsent(key, value, time, timeUnit);}/*** 根据键获取缓存值* * @param key 缓存的键* @return 对应的值,如果键不存在返回null*/public String get(String key) {String value = (String) redisTemplate.opsForValue().get(key);return value;}/*** 删除指定的缓存键* * @param key 要删除的缓存键* @return 删除成功返回true,键不存在返回false*/public boolean del(String key) {return redisTemplate.delete(key);}
}

4. 实现:Redis分布式抢占锁RedisShareLockUtil

package com.qj.redis.util;import com.qj.redis.exception.ShareLockException;
import org.apache.commons.lang.StringUtils;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;/*** Redis分布式锁工具类* 基于Redis实现的分布式锁,支持加锁、解锁和尝试加锁操作* 使用请求标识(requestId)确保只有加锁者才能解锁,防止误删其他客户端的锁*/
@Component // 声明为Spring组件,由Spring容器管理
public class RedisShareLockUtil {// 注入Redis工具类@Resourceprivate RedisUtil redisUtil;// 加锁超时时间(毫秒),防止无限等待private Long TIME_OUT = 1000L;/*** 加锁方法(阻塞式)* 在指定时间内尝试获取分布式锁,如果获取不到会进行重试* * @param lockKey 锁的键名* @param requestId 请求标识(通常使用UUID),用于确保只有加锁者才能解锁* @param time 锁的持有时间(毫秒)* @return 加锁成功返回true,否则返回false* @throws ShareLockException 当参数异常时抛出*/public boolean lock(String lockKey, String requestId, Long time) {// 参数校验if (StringUtils.isBlank(lockKey) || StringUtils.isBlank(requestId) || time <= 0) {throw new ShareLockException("分布式锁-加锁参数异常");}long currentTime = System.currentTimeMillis();long outTime = currentTime + TIME_OUT; // 计算超时时间点Boolean result = false;// 在超时时间内循环尝试获取锁while (currentTime < outTime) {// 尝试获取锁result = redisUtil.setNx(lockKey, requestId, time, TimeUnit.MILLISECONDS);if (result) {// 获取锁成功,返回truereturn result;}// 获取锁失败,休眠100毫秒后重试try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}// 更新当前时间currentTime = System.currentTimeMillis();}// 超时后仍未获取到锁,返回falsereturn result;}/*** 解锁方法* 根据锁键和请求标识释放分布式锁,确保只有加锁者才能解锁* * @param key 锁的键名* @param requestId 请求标识,必须与加锁时使用的标识一致* @return 解锁成功返回true,否则返回false* @throws ShareLockException 当参数异常时抛出*/public boolean unLock(String key, String requestId) {// 参数校验if (StringUtils.isBlank(key) || StringUtils.isBlank(requestId)) {throw new ShareLockException("分布式锁-解锁参数异常");}try {// 获取锁当前的值String value = redisUtil.get(key);// 检查请求标识是否匹配,防止误删其他客户端的锁if (requestId.equals(value)) {// 只有加锁者才能解锁redisUtil.del(key);return true;}} catch (Exception e) {// 记录日志,这里应该使用日志框架记录异常信息// 补日志}// 解锁失败return false;}/*** 尝试加锁方法(非阻塞式)* 尝试获取分布式锁一次,无论成功与否都立即返回* * @param lockKey 锁的键名* @param requestId 请求标识* @param time 锁的持有时间(毫秒)* @return 加锁成功返回true,否则返回false* @throws ShareLockException 当参数异常时抛出*/public boolean tryLock(String lockKey, String requestId, Long time) {// 参数校验if (StringUtils.isBlank(lockKey) || StringUtils.isBlank(requestId) || time <= 0) {throw new ShareLockException("分布式锁-尝试加锁参数异常");}// 尝试获取锁一次return redisUtil.setNx(lockKey, requestId, time, TimeUnit.MILLISECONDS);}
}

5. 测试:调用分布式锁

package com.qj.sys.controller;import com.qj.redis.util.RedisShareLockUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;@RestController
@Slf4j
@RequestMapping("test")
public class TestController {@Autowiredprivate RedisShareLockUtil redisShareLockUtil;@GetMapping("/testRedisShareLock")public String testRedisShareLock() {boolean result = redisShareLockUtil.lock("qj", "123456", 10000L);log.info("分布式锁获取:{}", result);return String.valueOf(result);}}

控制台返回:(独占锁)

四、Redis实现延迟队列

说明:我们有一个任务的情况下,我们会期望这个任务在某个时间点去执行,那么就要使用延迟队列。一般延迟队列可以使用RabbitMQ或者RocketMQ来进行实现,另外一种常用的方式就是使用Redis来进行实现了!

实现方案:基于redis来进行实现,我们主要使用的是zset这个数据类型天生的具有score的特性。zset可以根据score放入,而且可以通过range进行排序获取,以及删除指定的值。从业务上,我们可以再新增任务的时候放入,再通过定时任务进行拉取,要注意的一点就是拉取的时候要有分布式锁,不要进行重复拉取,或者交由分布式任务调度来处理拉取,都是可以的。

使用场景:我们更加偏向于 定时群发,定时取消 等。就举一个发博客的例子吧,博客我们可以选择定时发布,那么就可以应用redis的延迟队列来进行实现。要注意的一个点就是小心大key的产生,要做好延迟队列的key的隔离。

1. 延迟任务的实体类

package com.qj.sys.delayQueue;import lombok.Data;import java.util.Date;@Data
public class MassMailTask {// 相关任务IDprivate Long taskId;// 延迟任务的开始时间private Date startTime;
}

2. 任务对延迟队列的推送方法和拉取的方法

实现思路:

入队:入队消息体一定要有时间的概念,把时间转换为毫秒,来作为我们zset的score;底层就是zset的add方法,由key,value以及score来组成。

出队:出队要基于rangeByScore来进行实现,指定我们的score的区间,也就是我们要拉取哪些的任务,拉取成功之后,我们先去执行业务逻辑,执行成功之后,我们再将其从消息队列进行删除。

package com.qj.sys.delayQueue;import com.alibaba.fastjson.JSON;
import com.qj.redis.util.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;import javax.annotation.Resource;
import java.util.Collections;
import java.util.Date;
import java.util.Set;
import java.util.stream.Collectors;/*** 群发邮件任务延时队列服务* 使用Redis的ZSet(有序集合)实现延时队列功能* 用于处理定时群发邮件任务的调度*/
@Service // 声明为Spring服务组件
@Slf4j // 使用Lombok的日志注解
public class MassMailTaskService {// Redis中ZSet结构的key,用于存储群发邮件任务private static final String MASS_MAIL_TASK_KEY = "massMailTask";// 注入Redis工具类@Resourceprivate RedisUtil redisUtil;/*** 将群发邮件任务推送到延时队列* 使用Redis的ZSet结构,以任务开始时间作为分数(score)* * @param massMailTask 群发邮件任务对象*/public void pushMassMailTaskQueue(MassMailTask massMailTask) {// 获取任务开始时间Date startTime = massMailTask.getStartTime();// 校验开始时间是否有效if (startTime == null) {return;}// 如果开始时间早于或等于当前时间,则不加入队列if (startTime.compareTo(new Date()) <= 0) {return;}// 记录日志log.info("定时群发任务加入延时队列,massMailTask:{}", JSON.toJSON(massMailTask));// 使用Redis的ZSet数据结构存储任务// key: MASS_MAIL_TASK_KEY// value: 任务ID的字符串形式// score: 任务开始时间的时间戳redisUtil.zAdd(MASS_MAIL_TASK_KEY, massMailTask.getTaskId().toString(), startTime.getTime());}/*** 从延时队列中拉取到期的群发邮件任务* 获取分数(时间戳)在0到当前时间之间的所有任务* 获取后会从队列中移除这些任务* * @return 返回到期任务的ID集合*/public Set<Long> poolMassMailTaskQueue() {// 获取当前时间之前的所有任务// 参数说明:key, minScore(0), maxScore(当前时间戳)Set<String> taskIdSet = redisUtil.rangeByScore(MASS_MAIL_TASK_KEY, 0, System.currentTimeMillis());// 记录日志log.info("获取延迟群发任务,taskIdSet:{}", JSON.toJSON(taskIdSet));// 如果任务集合为空,返回空集合if (CollectionUtils.isEmpty(taskIdSet)) {return Collections.emptySet();}// 从Redis中移除已获取的任务redisUtil.removeZsetList(MASS_MAIL_TASK_KEY, taskIdSet);// 将任务ID字符串集合转换为Long类型集合return taskIdSet.stream().map(n -> Long.parseLong(n)).collect(Collectors.toSet());}
}

3. 编写测试类

注意:由于可能是分布式服务,所以可能是定时循环拉取,在拉取的时候不能所有服务的拉取都去拉,而是只允许一个任务去拉取,要么使用xxljob来实现,要么就选择分布式锁,只允许一个服务能够拉取并执行!

package com.qj.sys;import com.alibaba.fastjson.JSON;
import com.qj.redis.util.RedisShareLockUtil;
import com.qj.sys.delayQueue.MassMailTask;
import com.qj.sys.delayQueue.MassMailTaskService;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.util.CollectionUtils;import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.Set;
import java.util.UUID;@SpringBootTest(classes = {SysApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@RunWith(SpringRunner.class)
@Slf4j
public class MassMailTaskTest {// 注入MassMailTaskService,用于操作延时任务队列@Resourceprivate MassMailTaskService massMailTaskService;// 注入RedisShareLockUtil,用于分布式锁的操作@Resourceprivate RedisShareLockUtil redisShareLockUtil;/*** 测试方法:推送邮件任务到延时队列*/@Testpublic void push() throws Exception {// 创建一个SimpleDateFormat对象,用于将时间字符串转换为Date对象SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");// 创建一个MassMailTask对象,表示一个邮件任务MassMailTask massMailTask = new MassMailTask();massMailTask.setTaskId(1L); // 设置任务IDmassMailTask.setStartTime(simpleDateFormat.parse("2023-01-08 23:59:00")); // 设置任务的开始时间// 将任务插入到延时队列中massMailTaskService.pushMassMailTaskQueue(massMailTask);// 输出日志,确认任务已插入log.info("定时任务已插入!");}/*** 测试方法:处理延时任务队列中的任务*/@Testpublic void deal() {// 定义锁的key值,用于Redis分布式锁String lockKey = "test.delay.task";// 创建一个唯一的请求ID,用于标识当前请求String requestId = UUID.randomUUID().toString();try {// 尝试获取分布式锁,锁定5秒钟boolean locked = redisShareLockUtil.lock(lockKey, requestId, 5L);// 如果获取锁失败,则直接返回if (!locked) {return;}// 从延时队列中获取一批任务IDSet<Long> taskIdSet = massMailTaskService.poolMassMailTaskQueue();// 打印获取到的任务ID集合log.info("DelayTaskTest.deal.taskIdSet:{}", JSON.toJSON(taskIdSet));// 如果任务集合为空,则返回if (CollectionUtils.isEmpty(taskIdSet)) {return;}// 处理获取到的任务,可以在此处添加具体的业务逻辑} catch (Exception e) {// 处理异常并输出日志log.error("延时任务拉取执行失败:{}", e.getMessage(), e);} finally {// 无论如何,释放分布式锁redisShareLockUtil.unLock(lockKey, requestId);}}
}

4. 运行测试

(1)第一步:任务推送延迟队列

(2)第二步:定时任务未到时间,直接进行拉取

(3)第三步:定时任务到时间了,然后进行拉取

说明:可以看到,到了时间之后,就可以正常拉取到定时任务!

(4)第四步:在延迟队列中插入两个延迟任务

说明:可以看到,到了时间之后,就可以正常拉取到所有定时任务,也是为什么需要使用ZSet来存储的原因!

五、使用Redis的Lua脚本实现CAS

说明:使用Lua脚本实现 CAS(比较并交换)的过程!

1. RedisLua工具类

package com.qj.sys.redislua;import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.ArrayList;/*** Redis Lua脚本执行组件* 封装了比较并设置(CAS)操作的Lua脚本执行逻辑* 提供原子性的比较并设置功能,确保在分布式环境下的数据一致性*/
@Component // 声明为Spring组件,由Spring容器管理
@Slf4j // Lombok日志注解
public class CompareAndSetLua {// 注入Redis操作模板@Resourceprivate RedisTemplate redisTemplate;// Redis脚本对象,用于执行Lua脚本private DefaultRedisScript<Boolean> casScript;/*** 初始化方法* 在Bean创建后自动执行,加载Lua脚本并配置脚本执行器*/@PostConstruct // 在依赖注入完成后自动执行public void init() {// 创建Redis脚本执行器casScript = new DefaultRedisScript<>();// 设置脚本返回类型为BooleancasScript.setResultType(Boolean.class);// 从类路径加载Lua脚本文件// 假设脚本文件位于resources目录下的compareAndSet.luacasScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("compareAndSet.lua")));}/*** 执行比较并设置操作* 调用Lua脚本实现原子性的比较并设置功能* * @param key Redis键名* @param oldValue 期望的旧值* @param newValue 要设置的新值* @return 操作成功返回true,失败返回false*/public boolean compareAndSet(String key, Long oldValue, Long newValue) {// 创建键列表,Lua脚本中通过KEYS[1]访问ArrayList<String> keys = new ArrayList<>();keys.add(key);// 执行Lua脚本// 参数说明:脚本对象、键列表、参数列表(旧值和新值)Boolean result = (Boolean) redisTemplate.execute(casScript, keys, oldValue, newValue);// 返回操作结果return result;}
}

2. 相关Lua脚本(compareAndSetLua.lua )

注意:在此key下,如果传入的oldValue和存在的值相同,则更新为newValue,否则不变!

-- Redis Lua脚本:原子性比较并设置(Compare and Set)操作
-- 实现类似Java中Atomic类的CAS(Compare and Swap)功能-- 从键参数列表中获取第一个键名
local key = KEYS[1]-- 从参数列表中获取期望的旧值和要设置的新值
local oldValue = ARGV[1]
local newValue = ARGV[2]-- 从Redis中获取指定键的当前值
local redisValue = redis.call('get', key)-- 判断条件:
-- 1. 如果键不存在(redisValue为false)
-- 2. 或者当前值等于期望的旧值(转换为数字比较)
if (redisValue == false or tonumber(redisValue) == tonumber(oldValue))
then-- 条件满足,设置新值redis.call('set', key, newValue)-- 返回操作成功return true
else-- 条件不满足,当前值与期望的旧值不匹配-- 返回操作失败return false
end

3. 运行测试

package com.qj.sys;import com.qj.sys.redislua.CompareAndSetLua;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.junit4.SpringRunner;import javax.annotation.Resource;/*** Redis Lua脚本测试类* 用于测试基于Lua脚本实现的原子性比较并设置(CAS)操作* 演示如何使用Lua脚本保证Redis操作的原子性*/
@SpringBootTest(classes = {SysApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // Spring Boot测试注解
@RunWith(SpringRunner.class) // 使用SpringRunner运行测试
@Slf4j // Lombok日志注解
public class RedisLuaTest {// 注入Redis操作模板@Resourceprivate RedisTemplate redisTemplate;// 注入自定义的Lua脚本执行组件@Resourceprivate CompareAndSetLua compareAndSetLua;/*** Redis Lua脚本测试方法* 演示如何使用Lua脚本实现原子性的比较并设置操作* 先设置一个初始值,然后使用Lua脚本进行原子性的比较和更新*/@Testpublic void redisLuaTest() {// 获取字符串值操作接口ValueOperations<String, Long> opsForValue = redisTemplate.opsForValue();// 在Redis中设置一个键值对,键为"qj",值为18opsForValue.set("qj", 18L);// 记录当前的值log.info("qj的值为:{}", opsForValue.get("qj"));// 使用Lua脚本执行比较并设置操作// 参数说明:key, 期望的旧值, 要设置的新值// 只有当当前值等于期望的旧值时,才会设置新值boolean result = compareAndSetLua.compareAndSet("qj", 18L, 19L);// 根据操作结果输出相应信息if (result) {log.info("修改成功!qj的值为:{}", opsForValue.get("qj"));} else {log.info("修改失败,当前值已不是期望的旧值");}}
}

运行效果:

六、Spring注解缓存实现

说明1:因为查询的时候,每次都走数据库会导致查询非常缓慢,所以Spring提供了一套缓存机制,在查询相同接口的时候会先查询缓存,再查询数据库,大大提高了接口响应速度!

说明2:Spring Boot会自动配置合适的CacheManager作为相关缓存的提供程序(此处配置了Redis的CacheManager)当你在配置类(@Configuration)上使用@EnableCaching注解时,会触发一个后处理器(post processor ),它检查每个Spring bean,查看是否已经存在注解对应的缓存;如果找到了,就会自动创建一个代理拦截方法调用,使用缓存的bean执行处理。

注意:在实际工作中基本不使用Spring注解缓存,因为无法为每个缓存单独设置过期时间(除非为每个缓存进行单独的配置),很可能导致整个业务产生缓存雪崩现象的出现!

1. 开启缓存

说明:需要在启动类上加上@EnableCaching注解!

2. 加上@Cacheable和@CacheEvict注解

说明:在业务接口上加上@Cacheable注解,并且为了保证数据一致性,需要配合@CacheEvict注解一起使用,用于在增删改的时候进行对缓存数据一致性的保障!

/*** 通过主键查询单条数据* 使用@Cacheable注解实现缓存功能,提高查询性能* 当多次查询相同id的数据时,只有第一次会真正调用方法,后续直接从缓存中返回结果* * @param id 用户主键ID* @return 包含用户数据的Result对象* * @Cacheable 注解说明:* - cacheNames: 指定缓存名称,用于区分不同的缓存区域* - key: 使用SpEL表达式生成缓存键,格式为'querySysUserById'+用户ID*   例如:当id=123时,缓存键为"querySysUserById123"*/
@GetMapping("/get/{id}")
@Cacheable(cacheNames = "SysUser", key = "'querySysUserById'+#id")
public Result<SysUserPo> queryById(@PathVariable("id") Long id) {// 调用服务层方法查询用户数据// 只有在缓存不存在时,才会执行此方法return Result.ok(this.sysUserService.queryById(id));
}/*** 编辑用户数据* 使用@CacheEvict注解清除缓存,确保数据更新后缓存的一致性* 当用户数据更新后,清除对应的缓存,迫使下次查询时重新从数据库加载最新数据* * @param sysUserReq 用户请求数据* @return 包含更新后用户数据的Result对象* * @CacheEvict 注解说明:* - cacheNames: 指定要清除的缓存名称,与@Cacheable中的cacheNames对应* - key: 使用SpEL表达式生成要清除的缓存键,格式为'querySysUserById'+用户ID*   注意:这里假设sysUserReq中包含id字段,否则需要调整SpEL表达式*/
@PutMapping("/edit")
@CacheEvict(cacheNames = "SysUser", key = "'querySysUserById'+#id")
public Result<SysUserPo> edit(@RequestBody SysUserReq sysUserReq) {// 将请求对象转换为数据传输对象SysUserDto sysUserDto = SysUserDtoConvert.INSTANCE.convertReqToDto(sysUserReq);// 调用服务层方法更新用户数据// 方法执行成功后,会自动清除指定的缓存return Result.ok(this.sysUserService.update(sysUserDto));
}

3. 错误测试1:Redis乱码和不过期问题

说明:可以看到存入的缓存数据是乱码,并且TTL时间为-1永不过期!

4. 解决方案:修改RedisCacheManager

说明:在Redis自动配置中,修改注入Bean容器的RedisCacheManager,修改其创建方式即可!

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;import java.time.Duration;/*** Redis配置类* 配置RedisTemplate和RedisCacheManager,自定义序列化方式和缓存策略*/
@Configuration // 声明为配置类,Spring启动时会自动加载
public class RedisConfig {/*** 配置RedisTemplate* 设置键和值的序列化方式,以及连接工厂* * @param redisConnectionFactory Redis连接工厂,由Spring自动注入* @return 配置好的RedisTemplate实例*/@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {// 创建RedisTemplate实例RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();// 创建字符串序列化器,用于键的序列化RedisSerializer<String> redisSerializer = new StringRedisSerializer();// 设置连接工厂redisTemplate.setConnectionFactory(redisConnectionFactory);// 设置键的序列化方式为字符串序列化redisTemplate.setKeySerializer(redisSerializer);// 设置哈希键的序列化方式为字符串序列化redisTemplate.setHashKeySerializer(redisSerializer);// 设置值的序列化方式为Jackson JSON序列化redisTemplate.setValueSerializer(jackson2JsonRedisSerializer());// 设置哈希值的序列化方式为Jackson JSON序列化redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer());return redisTemplate;}/*** 配置Redis缓存管理器* 设置缓存值的序列化方式和默认过期时间* * @param redisConnectionFactory Redis连接工厂,由Spring自动注入* @return 配置好的RedisCacheManager实例*/@Beanpublic RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {// 创建非阻塞的Redis缓存写入器RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);// 创建序列化对,使用Jackson JSON序列化器SerializationPair<Object> pair = SerializationPair.fromSerializer(jackson2JsonRedisSerializer());// 配置Redis缓存默认设置RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig() // 获取默认配置.serializeValuesWith(pair) // 设置值的序列化方式.entryTtl(Duration.ofSeconds(10)); // 设置缓存条目默认过期时间为10秒// 创建并返回Redis缓存管理器return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration);}/*** 创建Jackson JSON序列化器* 用于Redis值的序列化和反序列化* * @return 配置好的Jackson2JsonRedisSerializer实例*/private Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer() {// 创建Jackson JSON序列化器,支持任何Object类型Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);// 创建ObjectMapper实例,配置JSON序列化和反序列化规则ObjectMapper objectMapper = new ObjectMapper();// 设置所有属性(包括私有属性)都可见objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);// 配置反序列化时忽略未知属性,避免因JSON中包含未知属性而报错objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);// 将配置好的ObjectMapper设置到序列化器中jsonRedisSerializer.setObjectMapper(objectMapper);return jsonRedisSerializer;}
}

5. 错误测试2:获取缓存失败

说明:可以看到数据乱码问题解决,并且也实现了10s过期!

问题:但是第一次请求成功的情况下,第二次请求就会发生如下错误,意味着从Redis中获取的数据无法正确的进行类型转换!

6. 解决方案:修改Jackson序列化配置

注意:可以看到Redis缓存的数据中带上了类型!

7. 测试成功

说明:多次操作都能成功从缓存中获取数据,接口响应速度大幅度提高!

七、guava实现本地缓存工具

封装CacheUtil(Guava实现)

说明:利用guava本地缓存和函数式编程来实现一个本地缓存。

注意:因为之前缓存使用Redis来做,如果当所有的缓存都存储在Redis中的时候,一旦网络不稳定导致未及时相应,所有请求都可能被阻塞,导致内存和CPU被打满,从而引起重大问题!

1. 配置相关依赖

<guava.version>19.0</guava.version>
<fastjson.version>1.2.75</fastjson.version><!-- guava相关依赖 -->
<dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>${guava.version}</version>
</dependency>
<!-- fastjson相关依赖 -->
<dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>${fastjson.version}</version>
</dependency>

2. CacheUtil

说明:此处只有查询时添加缓存的操作,没有实现编辑时删除缓存的操作!

package com.qj.redis.util;import com.alibaba.fastjson.JSON;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;import java.util.*;
import java.util.Map.Entry;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;@Component
@Slf4j
public class CacheUtil<K, V> {// 意图从nacos的配置文件中获取,流量大的时候开启本地缓存,流量小的时候关闭本地缓存@Value("${guava.cache.switch}")public Boolean switchCache;// 初始化一个guava的Cacheprivate Cache<String, String> localCache = CacheBuilder.newBuilder().maximumSize(5000).expireAfterWrite(3, TimeUnit.SECONDS).build();public Map<K, V> getResult(List<K> skuIdList, String cachePrefix,Class<V> clazz, Function<List<K>, Map<K, V>> function) {if (CollectionUtils.isEmpty(skuIdList)) {return Collections.emptyMap();}Map<K, V> resultMap = new HashMap<>(16);// 1)本地缓存未开if (!switchCache) {// 从rpc接口查所有数据,返回结果集resultMap = function.apply(skuIdList);return resultMap;}// 2)默认开启本地缓存List<K> noCacheList = new ArrayList<>();// (2.1)查guava缓存for (K skuId : skuIdList) {String cacheKey = cachePrefix + "_" + skuId;String content = localCache.getIfPresent(cacheKey);if (StringUtils.isNotBlank(content)) {// 能查到的直接放进结果集中V v = JSON.parseObject(content, clazz);resultMap.put(skuId, v);} else {// 查不到的先放进noCacheList中,后面统一使用rpc查询noCacheList.add(skuId);}}// (2.2)如果没有查不到的,直接返回结果集if (CollectionUtils.isEmpty(noCacheList)) {return resultMap;}// (2.3)如果有查不到的,从rpc接口查guava中没有缓存的数据Map<K, V> noCacheResultMap = function.apply(noCacheList);// (2.4)如果rpc接口也没查到任何数据,直接返回结果集if (CollectionUtils.isEmpty(noCacheResultMap)) {return resultMap;}// (2.5)将从rpc中查出的结果,添加guava的本地缓存和结果集中for (Entry<K, V> entry : noCacheResultMap.entrySet()) {K skuId = entry.getKey();V content = entry.getValue();// 查询内容放进结果集resultMap.put(skuId, content);// 查询内容放进guava本地缓存String cacheKey = cachePrefix + "_" + skuId;localCache.put(cacheKey, JSON.toJSONString(content));}// (2.6)返回结果集return resultMap;}
}

3. 调用CacheUtil

说明:此处使用了伪代码来实现,此处重点是 —— 将无法在缓存中查询的数据,转而需要通过使用RPC框架或者调用本地Service层查询数据库的代码,使用函数式编程方式,将其调用作为一个参数传入CacheUtil的方法中!

package com.qj.sys.controller;import com.qj.redis.util.CacheUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.Collections;
import java.util.List;
import java.util.Map;/*** 测试控制器* 用于演示本地缓存的使用方式和效果*/
@RestController // 声明为REST控制器,所有方法返回值直接作为HTTP响应体
@Slf4j // Lombok日志注解
@RequestMapping("test") // 设置基础请求路径为/test
public class TestController {// 注入缓存工具类@Autowiredprivate CacheUtil cacheUtil;/*** 测试本地缓存接口* 演示如何使用CacheUtil获取多种类型的数据并利用缓存提高性能* * @param skuIdList SKU ID列表,通过请求参数传入*/@GetMapping("/testLocalCache")public void testLocalCache(List<Long> skuIdList) {// 获取SKU名称信息,使用缓存优化// 参数说明:// 1. skuIdList: 需要查询的SKU ID列表// 2. "skuInfo.skuName": 缓存键前缀,用于区分不同类型的缓存// 3. SkuInfo.class: 返回值类型// 4. Lambda表达式: 缓存未命中时的数据加载逻辑cacheUtil.getResult(skuIdList, "skuInfo.skuName", SkuInfo.class, (list) -> {// 当缓存中不存在数据时,执行此方法获取数据Map<Long, SkuInfo> skuInfo = getSkuInfo(skuIdList);return skuInfo;});// 获取SKU价格信息,同样使用缓存优化cacheUtil.getResult(skuIdList, "skuInfo.skuPrice", SkuPrice.class, (list) -> {// 当缓存中不存在数据时,执行此方法获取数据Map<Long, SkuPrice> skuPrice = getSkuPrice(skuIdList);return skuPrice;});}/*** 模拟RPC接口 - 获取SKU信息* 实际项目中可以使用OpenFeign等工具实现远程调用* * @param skuIdList SKU ID列表* @return SKU信息映射表*/public Map<Long, SkuInfo> getSkuInfo(List<Long> skuIdList) {// 模拟远程调用,返回空映射// 实际项目中这里会调用商品服务的接口获取SKU信息return Collections.emptyMap();}/*** 模拟RPC接口 - 获取SKU价格* 实际项目中可以使用OpenFeign等工具实现远程调用* * @param skuIdList SKU ID列表* @return SKU价格映射表*/public Map<Long, SkuPrice> getSkuPrice(List<Long> skuIdList) {// 模拟远程调用,返回空映射// 实际项目中这里会调用价格服务的接口获取SKU价格return Collections.emptyMap();}/*** SKU信息内部类* 用于表示商品基本信息*/class SkuInfo {private Long id; // SKU IDprivate String name; // SKU名称}/*** SKU价格内部类* 用于表示商品价格信息*/class SkuPrice {private Long id; // SKU IDprivate Double price; // SKU价格}
}

http://www.dtcms.com/a/354664.html

相关文章:

  • 我店生活平台是不是 “圈钱平台”?揭开消费补贴新模式的面纱
  • 从零开始的云计算生活——第五十三天,发愤图强,kubernetes模块之Prometheus和发布
  • DistributedLock 实现.Net分布式锁
  • Kafka02-集群选主
  • BeyondMimic——通过引导式扩散实现动作捕捉:基于Diffuse-CLoC构建扩散框架,可模仿动作、导航避障(含UniTracker的详解)
  • InstructGPT:使用人类反馈训练语言模型以遵循指令
  • ARM相关的基础概念和寄存器
  • Shell编程知识整理
  • 从 WPF 到 Avalonia 的迁移系列实战篇2:路由事件的异同点与迁移技巧
  • Linux下OpenRadioss源码编译安装及使用
  • Shell 字符串操作与运算符
  • 利用ChatGPT打造行业LLM大模型应用
  • 外部请求至k8s集群内部对应节点全流程介绍
  • 使用docker搭建嵌入式Linux开发环境
  • HTML5七夕节网站源码
  • Java:TCP/UDP网络编程
  • DevOps篇之利用Jenkins实现多K8S集群的版本发布
  • Docker-compose常用命令
  • Helm 在 K8s 中的常见应用场景
  • 【K8s】整体认识K8s之K8s的控制器
  • Node.js + MongoDB 搭建 RESTful API 实战教程
  • 从入门到入土之——奇异值分解(SVD)
  • 重塑可观测性成本:解析Coralogix的智能成本优化之道
  • 深入浅出:贴片式eMMC存储与国产芯(君正/瑞芯微)的协同设计指南
  • GitHub 宕机自救指南:确保开发工作不间断
  • 学习做动画6.瞄准偏移
  • 5.2 I/O软件
  • STL库——list(类函数学习)
  • 搭建私有云3步法:cpolar简化Puter本地云端配置
  • leetcode238:除自身以外的数组的乘积(前缀和思想)