运行所有tj开头的容器
docker start $(docker ps -aq -f name=tj*)
docker查看容器运行状态的命令
dps [-a] 查看[所有]运行中的容器
dlog -f 容器名 动态查看日志
select * from learning_lesson where user_id = xxx order by latest_learn_time desc limit 0,5
对于以上的查询,可以使用一下的方法
public PageDTO<xxxx> page(PageQuery pageQuery){
Page<LearningLesson> page = lambdaQuery()
.eq(LearningLesson::getUserId, userId)
// 默认根据最后一次学习的时间 降序
.page(pageQuery.toMpPage("latest_learn_time", false));
// 判断是否为null
if (CollUtils.isEmpty(records)) {
// 如果为空返回
return PageDTO.empty(page);
}
}
toMpPage 方法的细节 会默认使用参数的中的page
No pageSize sortBy 所以十分的方便
public <T> Page<T> toMpPage(String defaultSortBy, boolean isAsc) {
if (StringUtils.isBlank(sortBy)){
sortBy = defaultSortBy;
this.isAsc = isAsc;
}
Page<T> page = new Page<>(pageNo, pageSize);
OrderItem orderItem = new OrderItem();
orderItem.setAsc(this.isAsc);
orderItem.setColumn(sortBy);
page.addOrder(orderItem);
return page;
}
返回查询到的page即可,会处理其中 的总条数, 以及页面信息, 和返回的集合
PageDTO.of(page,learningLessonVOS)
Map<Long, CourseSimpleInfoDTO> courseMap = simpleInfoList.stream().collect(Collectors.toMap(CourseSimpleInfoDTO::getId, c -> c));
map中的两个参数都代表当前对象,所以一个可以使用getId 一个使用 c-> c 将自己放入Map集合
LearningLessonVO learningLessonVO = BeanUtils.copyBean(record, LearningLessonVO.class);
也可以拷贝List
BeanUtils.copyList()
通过传入数据源以及想要copy后的结果对象即可返回具体的对象十分方便
CollUtils.singletonList(lesson.getLatestSectionId())
lambdaUpdate().eq(LearningLesson::getUserId, userID)
.eq(LearningLesson::getCourseId, courseId)
.set(LearningLesson::getWeekFreq, weekFreq)
.set(LearningLesson::getPlanStatus, PlanStatus.PLAN_RUNNING)
.update();
maven的三套生命周期
第一套: clean清理
第二套: default(compile编译,test测试、package打包、install安装)
第三套: site站点(deploy部署)
三套生命周期相互独立,但是在同一套生命周期内部,执行后面命令时,会依次执行前面的命令
@Validate
信息校验具体网址:
javax.validation 说明
实体类中添加注解 ----> 接收参数中使用 @Validate
如下:
@PostMapping
@ApiOperation("提交学习记录")
public void addLearningRecord(@RequestBody @Validated LearningRecordFormDTO dto){
learningRecordService.addLearningRecord(dto);
}
实体类LearningRecordFormDTO
中部分代码如下
@ApiModelProperty("小节类型:1-视频,2-考试")
@NotNull(message = "小节类型不能为空")
@EnumValid(enumeration = {1, 2}, message = "小节类型错误,只能是:1-视频,2-考试")
private SectionType sectionType;
@ApiModelProperty("课表id")
@NotNull(message = "课表id不能为空")
private Long lessonId;
int weekFinished = lessons.stream()
.map(LearningLesson::getId)
.mapToInt(id -> countMap.getOrDefault(id, 0))
.sum();
iLearningLessonService
.lambdaUpdate()
// 如果是第一次学习,需要把学习状态设置为学习中
.set(lesson.getLearnedSections() == 0 , LearningLesson::getStatus, LessonStatus.LEARNING)
// 如果是全部学完那么就把转他设置为已学完
.set(allLearned, LearningLesson::getStatus, LessonStatus.FINISHED)
// 如果没有全部学完
.set(!allLearned, LearningLesson::getLatestSectionId, dto.getSectionId())
.set(!allLearned, LearningLesson::getLatestLearnTime, dto.getCommitTime())
.setSql(allLearned, "learned_sections = learned_sections + 1")
.eq(LearningLesson::getId, lesson.getId())
.update();
- 优化代码及SQL
- 变同步写为异步写
- 合并写请求
MQ —> 解耦–异步–削峰
假如一个业务比较复杂,需要有多次数据库的写业务,如图所示:
由于各个业务之间是同步串行执行,因此整个业务的响应时间就是每一次数据库写业务的响应时间之和,并发能力肯定不会太好。
优化的思路很简单,我们之前讲解MQ的时候就说过,利用MQ可以把同步业务变成异步,从而提高效率。
这样一来,用户请求处理和后续数据库写就从同步变为异步,用户无需等待后续的数据库写操作,响应时间自然会大大缩短。并发能力自然大大提高。
优点:
- 无需等待复杂业务处理,大大减少响应时间
- 利用MQ暂存消息,起到流量削峰整形作用
- 降低写数据库频率,减轻数据库并发压力
缺点:
- 依赖于MQ的可靠性
- 降低了些频率,但是没有减少数据库写次数
应用场景:
- 比较适合应用于业务复杂, 业务链较长,有多次数据库写操作的业务。
合并写请求方案其实是参考高并发读的优化思路:当读数据库并发较高时,我们可以把数据缓存到Redis,这样就无需访问数据库,大大减少数据库压力,减少响应时间。
既然读数据可以建立缓存,那么写数据可以不可以也缓存到Redis呢?
答案是肯定的,合并写请求就是指当写数据库并发较高时,不再直接写到数据库。而是先将数据缓存到Redis,然后定期将缓存中的数据批量写入数据库。
由于Redis是内存操作,写的效率也非常高,这样每次请求的处理速度大大提高,响应时间大大缩短,并发能力肯定有很大的提升。
而且由于数据都缓存到Redis了,积累一些数据后再批量写入数据库,这样数据库的写频率、写次数都大大减少,对数据库压力小了非常多!
优点:
- 写缓存速度快,响应时间大大减少
- 降低数据库的写频率和写次数,大大减轻数据库压力
缺点:
- 实现相对复杂
- 依赖Redis可靠性
- 不支持事务和复杂业务
场景:
- 写频率较高、写业务相对简单的场景
JDK中自带的延迟队列功能,存入队列的元素可以指定延迟执行的时间。
基于Redis数据结构模拟JDK的DelayQueue
一些MQ本身支持延迟消息,例如RocketMQ
而RabbitMQ则需要通过插件来实现延迟消息 ( 死信队列 )
时间轮算法可以实现延迟任务或定时任务。其中Netty中有开源的实现
package com.itheima;
import java.util.Calendar;
import java.util.Date;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
public class MyDelayedTask implements Delayed {
// 任务的执行时间
private int executeTime = 0;
private String name;
public MyDelayedTask(int delay, String name){
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND,delay);
this.executeTime = (int)(calendar.getTimeInMillis() /1000 );
this.name = name;
}
/**
* 元素在队列中的剩余时间
* @param unit
* @return
*/
@Override
public long getDelay(TimeUnit unit) {
Calendar calendar = Calendar.getInstance();
return executeTime - (calendar.getTimeInMillis()/1000);
}
/**
* 元素排序
* @param o
* @return
*/
@Override
public int compareTo(Delayed o) {
long val = this.getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
return val == 0 ? 0 : ( val < 0 ? -1: 1 );
}
@Override
public String toString() {
return "MyDelayedTask{" +
"executeTime=" + executeTime +
", name='" + name + '\'' +
'}';
}
public static void main(String[] args) {
DelayQueue<MyDelayedTask> queue = new DelayQueue<MyDelayedTask>();
queue.add(new MyDelayedTask(10, "李四"));//李四任务10秒后执行
queue.add(new MyDelayedTask(5, "张三"));//张三任务5秒后执行
queue.add(new MyDelayedTask(15, "王五"));//王五任务15秒后执行
System.out.println(new Date() +" start consume ");
while(queue.size() != 0){
MyDelayedTask delayedTask = queue.poll();
if(delayedTask !=null ){
System.out.println(new Date() +" cosume task" + delayedTask);
}
//每隔一秒消费一次
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 打印顺序是按任务的执行时间
* Mon May 08 23:21:44 CST 2023 start consume
* Mon May 08 23:21:49 CST 2023 cosume taskDelayedTask{executeTime=1683559309, name='张三'}
* Mon May 08 23:21:54 CST 2023 cosume taskDelayedTask{executeTime=1683559314, name='李四'}
* Mon May 08 23:21:59 CST 2023 cosume taskDelayedTask{executeTime=1683559319, name='王五'}
*/
}
}
package com.itheima;
import lombok.Data;
import java.time.Duration;
import java.util.Date;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
@Data
public class DelayTask<D> implements Delayed {
private D data;
private long deadlineNanos;
public DelayTask(D data, Duration delayTime) {
this.data = data;
this.deadlineNanos = System.nanoTime() + delayTime.toNanos();
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(Math.max(0, deadlineNanos - System.nanoTime()), TimeUnit.NANOSECONDS);
}
@Override
public int compareTo(Delayed o) {
long l = getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
if(l > 0){
return 1;
}else if(l < 0){
return -1;
}else {
return 0;
}
}
public static void main(String[] args) throws InterruptedException {
DelayQueue<DelayTask> delayQueue = new DelayQueue();
delayQueue.add(new DelayTask<>("李四", Duration.ofSeconds(10)));
delayQueue.add(new DelayTask<>("张三", Duration.ofSeconds(5)));
delayQueue.add(new DelayTask<>("王五", Duration.ofSeconds(15)));
System.out.println(new Date() + "开始消费延任务");
while (delayQueue.size()>0){
DelayTask delayed = delayQueue.take();
System.out.println(new Date() +" "+ delayed);
}
/**
* Mon May 08 23:32:26 CST 2023开始消费延任务
* Mon May 08 23:32:31 CST 2023 DelayTask(data=张三, deadlineNanos=399151908886800)
* Mon May 08 23:32:36 CST 2023 DelayTask(data=李四, deadlineNanos=399156908694300)
* Mon May 08 23:32:41 CST 2023 DelayTask(data=王五, deadlineNanos=399161909060900)
*/
}
}
一句废话: 下面注意的地方一定要注意
@PostConstruct
public void init(){
// 注意: 这里为了不让主线程阻塞, 这里要开启新的线程来执行`handleDelayTask`方法. 当然这里也可以采用线程池的技术
CompletableFuture.runAsync(this::handleDelayTask);
}
@PreDestroy
public void destroy(){
log.debug("关闭学习记录处理的延迟任务");
begin = false;
}
建议1 : 如果任务式属于CPU运算型任务 推荐核心线程为CPU的核心数
建议2 : 如果任务时属于IO型的,推荐核心线程为CPU2倍
课程名称的模糊查询
多级缓存的使用场景
1. 缓存命中率提高:通过多级缓存,后级缓存可以补充前级缓存未命中的请求,总的缓存命中率会提高,从而减轻后端系统的压力。
2. 热点数据管理:可以将热点数据放在靠前的缓存层,让更多请求可以在前级缓存就获取到数据,减少对后级缓存与数据库的访问。
3. 过期时间控制:每级缓存可以设置不同的过期时间,让靠前的缓存层过期时间短一些,后级的过期时间长一些。这可以实现步进过期,避免大量热点数据直接过期失效。
4. 缓存雪崩防护:如果所有缓存都指向同一后端数据源,当后端数据库宕机等情况发生时,所有缓存都会过期失效,会产生缓存雪崩的情况。采用多级缓存,可以隔离不同级缓存之间的过期影响。
5. 缓存一致性控制:可以采取写CACHE-A,读CACHE-A,CACHE-B,DB的策略。写请求只更新前级缓存,读请求会在多个级别的缓存中查找,最后才读取数据库。这可以保证前级缓存中的数据有很高的一致性,后级缓存与数据库的数据会有一定的时延。
Redis虽然能提高性能,但每次查询缓存还是会增加网络带宽消耗,也会存在网络延迟。
(对于本项目中分类数据具备两大特点):
像这样的数据,除了建立Redis缓存以外,还非常适合做本地缓存(Local Cache)。这样就可以形成多级缓存机制:
本地缓存简单来说就是JVM内存的缓存,比如你建立一个HashMap,把数据库查询的数据存入进去。以后优先从这个HashMap查询,一个本地缓存就建立好了。
本地缓存优点:
本地缓存缺点:
本地缓存由于无需网络查询,速度非常快。不过由于上述缺点,本地缓存往往适用于数据量小、更新不频繁的数据。而课程分类恰好符合。
缓存常用的基本API
@Test
void testBasicOps() {
// 构建cache对象
Cache<String, String> cache = Caffeine.newBuilder().build();
// 存数据
cache.put("gf", "迪丽热巴");
// 取数据
String gf = cache.getIfPresent("gf");
System.out.println("gf = " + gf);
// 取数据,包含两个参数:
// 参数一:缓存的key
// 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
// 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式 ==================================>>>>>>>>>>
String defaultGF = cache.get("defaultGF", key -> {
// 根据key去数据库查询数据
return "柳岩";
});
System.out.println("defaultGF = " + defaultGF);
}
Caffeine既然是缓存的一种,肯定需要有缓存的清除策略,不然的话内存总会有耗尽的时候。
Caffeine提供了三种缓存驱逐策略:
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(1) // 设置缓存大小上限为 1
.build();
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
// 设置缓存有效期为 10 秒,从最后一次写入开始计时
.expireAfterWrite(Duration.ofSeconds(10))
.build();
注意: 在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。
缓存的声明
public class CategoryCacheConfig {
/**
* 课程分类的caffeine缓存
*/
@Bean
public Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches(){
return Caffeine.newBuilder()
.initialCapacity(1) // 容量限制
.maximumSize(10_000) // 最大内存限制
.expireAfterWrite(Duration.ofMinutes(30)) // 有效期
.build();
}
/**
* 课程分类的缓存工具类
*/
@Bean
public CategoryCache categoryCache(
Cache<String, Map<Long, CategoryBasicDTO>> categoryCaches, CategoryClient categoryClient){
return new CategoryCache(categoryCaches, categoryClient);
}
}
点赞系统需要支持不同业务的点赞功能 所以需要设计为一个独立的系统, 一些热点业务的点赞会很多,此时就需要点赞功能支持高并发的环境
所以我们就需要: 点赞记录, 点赞数,等两个记录的数据 ,
如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段。但是点赞系统无法修改其它业务服务的数据库,否则就出现了业务耦合。该怎么办呢?
点赞系统可以在点赞数变更时,通过MQ通知业务方,这样业务方就可以更新自己的点赞数量了。并且还避免了点赞系统与业务方的耦合。
于是,实现思路变成了这样:
比如说问答服务,在统计之后就可以在问答中接收MQ消息,来实现数据库中的数据的更新操作
需要注意的是,由于每次点赞的业务类型不同,所以没有必要通知到所有业务方,而是仅仅通知与当前点赞业务关联的业务方即可。
package com.tianji.learning.mq;
import com.tianji.api.dto.remark.LikedTimesDTO;
import com.tianji.learning.domain.po.InteractionReply;
import com.tianji.learning.service.IInteractionReplyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import static com.tianji.common.constants.MqConstants.Exchange.LIKE_RECORD_EXCHANGE;
import static com.tianji.common.constants.MqConstants.Key.QA_LIKED_TIMES_KEY;
@Slf4j
@Component
@RequiredArgsConstructor
public class LikeTimesChangeListener {
private final IInteractionReplyService replyService;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "qa.liked.times.queue", durable = "true"),
exchange = @Exchange(name = LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
key = QA_LIKED_TIMES_KEY
))
public void listenReplyLikedTimesChange(LikedTimesDTO dto){
log.debug("监听到回答或评论{}的点赞数变更:{}", dto.getBizId(), dto.getLikedTimes());
List<InteractionReply> list = new ArrayList<>(likedTimesDTOs.size());
InteractionReply r = new InteractionReply();
r.setId(dto.getBizId());
r.setLikedTimes(dto.getLikedTimes());
replyService.updateById(r);
}
}
Feign中实现RequestInterceptor
作用 ----> 在请求发出前对请求作出拦截,可以进行参数变换、Header添加等操作。
public class FeignRelayUserInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
Long userId = UserContext.getUser();
if (userId == null) {
return;
}
template.header(JwtConstants.USER_HEADER, userId.toString());
}
}
一, 定义feign接口
@FeignClient(value = "remark-service", fallbackFactory = RemarkClientFallback.class)
public interface RemarkClient {
@GetMapping("/likes/list")
Set<Long> isBizLiked(@RequestParam("bizIds") Iterable<Long> bizIds);
}
二, 定义RemarkClientFallBack降级类
为什么做降级?
在级联调用中 当业务量比较大的时候很容易出现 雪崩的现象(级联失败) , 即调用业务需要阻塞等待被调用业务的执行但是如果被调用业务宕机,就会导致后面的业务一直处于阻塞状态,如果请求继续增加,那么当前的服务也有可能宕机.从而产生雪崩的状态
package com.tianji.api.client.remark.fallback;
import com.tianji.api.client.remark.RemarkClient;
import com.tianji.common.utils.CollUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import java.util.Set;
@Slf4j
public class RemarkClientFallback implements FallbackFactory<RemarkClient> {
@Override
public RemarkClient create(Throwable cause) {
log.error("查询remark-service服务异常", cause);
return new RemarkClient() {
// 降级处理的方式 返回一个空的集合
@Override
public Set<Long> isBizLiked(Iterable<Long> bizIds) {
return CollUtils.emptySet();
}
};
}
}
我们需要通过SpringBoot的自动加载机制来加载这些fallback类:
我们需要在feign接口中,resources目录下定义文件META-INF 下 spring.factories
文件定义当前的降级类方法
由于SpringBoot会在启动时读取/META-INF/spring.factories
文件,我们只需要在该文件中指定了要加载
FallbackConig
类:
不要在一次批处理中传输太多命令,否则单次命令占用带宽过多,会导致网络阻塞
redis管道技术 在某些高并发的场景下,网络开销成了Redis速度的瓶颈,所以需要使用管道技术来实现突破。
原始:
学习网址: redis使用管道(Pipelining)提高查询速度
@Override
public Set<Long> isBizLiked(List<Long> bizIds) {
// 1.获取登录用户id
Long userId = UserContext.getUser();
// 2.查询点赞状态 --- 使用管道查询
List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
StringRedisConnection src = (StringRedisConnection) connection;
for (Long bizId : bizIds) {
String key = RedisConstants.LIKES_BIZ_KEY_PREFIX + bizId;
src.sIsMember(key, userId.toString());
}
// 这里不用管
return null;
});
// 3.返回结果
return IntStream.range(0, objects.size()) // 创建从0到集合size的流
.filter(i -> (boolean) objects.get(i)) // 遍历每个元素,保留结果为true的角标i
.mapToObj(bizIds::get)// 用角标i取bizIds中的对应数据,就是点赞过的id
.collect(Collectors.toSet());// 收集
}
像这种把每一个二进制位,与某些业务数据一一映射(本例中是与一个月的每一天映射),然后用二进制位上的数字0和1来标识业务状态的思路,称为位图。也叫做BitMap.
# 第1天签到
SETBIT bm 0 1
# 第2天签到
SETBIT bm 1 1
# 第3天签到
SETBIT bm 2 1
获取
BITFIELD key GET encoding offset
- key:就是BitMap的key
- GET:代表查询
- encoding:返回结果的编码方式,BitMap中是二进制保存,而返回结果会转为10进制,但需要一个转换规则,也就是这里的编码方式
- u:无符号整数,例如 u2,代表读2个bit位,转为无符号整数返回
- i:又符号整数,例如 i2,代表读2个bit位,转为有符号整数返回
- offset:从第几个bit位开始读取,例如0:代表从第一个bit位开始
示例:
bitfield bm get u3 0
添加
Boolean weekRecord = redisTemplate.opsForValue().setBit("week", 3, true); // key 索引(0开始).true 设置为1
// 返回值表示原始当前位置的值
System.out.println(weekRecord);
获取
List<Long> messageInfo = redisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(28)).valueAt(0) // 无符号, 起始位置
);
// 得到十进制 前28位的十进制
Long aLong = messageInfo.get(0);
private Integer getSignDays(Integer daysOfDifference, String key) {
//1. 获取当前是本月的多少天
List<Long> longs = redisTemplate.opsForValue()
.bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(daysOfDifference + 1)).valueAt(0));
//2. 获取值以及处理值
if (CollUtils.isEmpty(longs)) {
// 可能是第一天
return 0;
}
int num = longs.get(0).intValue();
// 此时可以让每一位和1 做与运算
int count = 0;
while ((num & 1) == 1) {
count++;
num = num >>> 1;
}
// 返回连续登录的数量
return count;
}
如果查询的结果只有一条数据可以使用如下的方法使用mybatisPlus查询 但是需要注意返回值
QueryWrapper<PointsRecord> wrapper = Wrappers.<PointsRecord>query()
.select("sum(points) as total")
.eq("user_id", userId)
.eq("type", type)
.between("create_time", begin, end);
Map<String, Object> map = getMap(wrapper);
if (map == null) {
return 0;
}
BigDecimal total = (BigDecimal) map.get("total");
案例2 先借用实体类中的某一字段用户存值取值
// 2. 查询今日积分情况
QueryWrapper<PointsRecord> queryWrapper = new QueryWrapper<>();
// 3. 构建条件
queryWrapper.select("type", "sum(points) as points");
queryWrapper.eq("user_id", userId);
queryWrapper.between("create_time", dayStartTime, dayEndTime);
queryWrapper.groupBy("type");
数据库按照规则对表做水平拆分, 根据某个字段的值的不同存放在不同的分区中
表分区(Partition) 是一种数据存储方案,可以解决单表数据较多的问题。MySQL5.1开始支持表分区功能。
数据库的表最终肯定是保存在磁盘中,对于InoDB引擎,一张表的数据在磁盘上对应一个ibd文件。如图,我们的积分榜单表对应的文件:
本地磁盘会生成两个文件:
一个frm文件: 用来存表结构
一个ibd文件: 用来存数据和索引 (分区中可以有多个ibd文件)
但是有一个缺点就是对应指定字段的值必须事先指定好, 如果不在指定的范围内就会报错,
这样做有几个好处:
表分区的本质是对数据的水平拆分,而拆分的方式也有多种,常见的有:
具体描述网址:MySQL分区表
对于赛季榜单来说,最合适的分区方式是基于赛季值分区,我们希望同一个赛季放到一个分区。这就只能使用List分区,而List分区却需要枚举出所有可能的分区值。但是赛季分区id是无限的,无法全部枚举,所以就非常尴尬。
开发者按需求对表进行水平或者垂直拆分
分表是一种表设计方案,由开发者在创建表时按照自己的业务需求拆分表。也就是说这是开发者自己对表的处理,与数据库无关。
而且,一旦做了分表,无论是逻辑上,还是物理上,就从一张表变成了多张表!增删改查的方式就发生了变化,必须自己考虑要去哪张表做数据处理。
分区则在逻辑上是同一张表,增删改查与以前没有区别。这就是分区和分表最大的一种区别。
对于赛季榜单,我们可以按照赛季拆分为多张表,每一个赛季一张新的表
这种方式就是水平分表,表结构不变,仅仅是每张表数据不同。查询赛季1,就找第一张表。查询赛季2,就找第二张表。
由于分表是开发者的行为,因此拆分方式更加灵活。除了水平分表,也可以做垂直分表。
什么是垂直分表呢?
如果一张表的字段非常多,比如达到30个以上,这样的表我们称为宽表。宽表由于字段太多,单行数据体积就会非常大,虽然数据不多,但可能表体积也会非常大!从而影响查询效率。
例如一个用户信息表,除了用户基本信息,还包含很多其它功能信息:
这个时候一张表就变成了两张表。而且两张表的结构不同,数据也不同。这种按照字段拆分表的方式,称为垂直拆分。
分表方案与分区方案相比有一些优点:
但是也有一些确定:
不过,在开发中我们很多情况下业务需求复杂,更看重分表的灵活性。因此,我们大多数情况下都会选择分表方案。
根据业务将相关的表存放到不同的数据库中
无论是分区,还是分表,我们刚才的分析都是建立在单个数据库的基础上。但是单个数据库也存在一些问题:
综上,在大型系统中,我们除了要做分表、还需要对数据做分库,建立综合集群。
首先,在微服务项目中,我们会按照项目模块,每个微服务使用独立的数据库,因此每个库的表是不同的,这种分库模式成为垂直分库。
而为了保证单节点的高可用性,我们会给数据库建立主从集群,主节点向从节点同步数据。两者结构一样,可以看做是水平扩展。
这个时候就会出现垂直分库、水平扩展的综合集群,如图:
给读写压力较大的数据库搭建主从集群
这种模式的优缺点:
优点:
缺点:
------> 超链接: Xxl-job的使用
@Accessors(chain = true)
持久化的流程中存在一个问题,我们的数据库持久化采用的是MybatisPlus来实现的。而MybatisPlus读取表名的方式是通过实体类上的@Table
注解,而注解往往是写死的:如下
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("points_board")
@ApiModel(value="PointsBoard对象", description="学霸天梯榜")
public class PointsBoard implements Serializable {
}
官网地址: mybatis-plus 动态表名插件
Map中存的数据: key----> 旧的表名 value-----> 名称处理器
这个Map的key是旧的表名称,value是TableNameHandler,就是表的名称处理器,用于根据旧名称获取新名称。
TablenameHandler源码如下:
public interface TableNameHandler {
/**
* 生成动态表名
*
* @param sql 当前执行 SQL
* @param tableName 表名
* @return String
*/
String dynamicTableName(String sql, String tableName);
}
做法:
定义DynamicTableNameInnterInterceptor
,向其中添加一个TableNameHandler
,将points_board
这个表名,替换为points_board_赛季id
的名称。
我们的做法是将表名写入到 ThreadLocal中, 然后再拦截器中在获取
1. 定义ThreadLocal
package com.tianji.learning.utils;
public class TableInfoContext {
private static final ThreadLocal<String> TL = new ThreadLocal<>();
public static void setInfo(String info) {
TL.set(info);
}
public static String getInfo() {
return TL.get();
}
public static void remove() {
TL.remove();
}
}
2. 自定义动态的拦截器
@Configuration
public class MybatisConfiguration {
@Bean
public DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor() {
// 准备一个Map,用于存储TableNameHandler
Map<String, TableNameHandler> map = new HashMap<>(1);
// 存入一个TableNameHandler,用来替换points_board表名称
// 替换方式,就是从TableInfoContext中读取保存好的动态表名
map.put("points_board", (sql, tableName) -> TableInfoContext.getInfo());
return new DynamicTableNameInnerInterceptor(map);
}
}
注意(重要)
由于
DynamicTableNameInnerInterceptor
并不是每一个微服务都用了,所以这里加入了@Autowired(required= false),避免未定义该拦截器的微服务报错。
// 下面的判断一定要做
在Mybatis-plus中加入动态表拦截器
@Bean
@ConditionalOnMissingBean
public MybatisPlusInterceptor mybatisPlusInterceptor(
@Autowired(required = false) DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加表名拦截器
//多租户,动态表名
if (dynamicTableNameInnerInterceptor != null) { // 这里一定要做判断
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
}
//分页,乐观锁
//sql 性能规范,防止全表更新与删除
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
interceptor.addInnerInterceptor(new MyBatisAutoFillInterceptor());
return interceptor;
}
最最最最重要:
拦截器中的插件的顺序一定要有先后顺序, 如下:
https://baomidou.com/pages/2976a3/#innerinterceptor
使用多个功能需要注意顺序关系,建议使用如下顺序
多租户,动态表名
分页,乐观锁
sql 性能规范,防止全表更新与删除
总结: 对 sql 进行单次改造的优先放入,不对 sql 进行改造的最后放入
描述: 一般是不太建议使用数据库中的关键字的但是有些情况还是难以避免会使用到, 所以这个时候就需要通过 `` 来区分关键字
如果使用Lombok可以使用@TableField("")
注解添加来和数据库中的字段区分
Redis的del和unlink区别_yfs1024的博客-CSDN博客
String yyyyMM = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
具体的代码实现如下博客中: 兑换码的生成_yfs1024的博客-CSDN博客
核心的两个方法:
generateCode(long serialNum, long fresh)
:根据自增id生成兑换码。两个参数
- serialNum:兑换码序列号,也就是自增id
- fresh:新鲜值,这里建议使用兑换码对应的优惠券id做新鲜值
parseCode(String code)
:验证并解析兑换码,返回的是兑换码的序列号,也就是自增id
在本项目中对兑换码的要求是: 可读性好, 数据量大,**不可重复 **,不可重兑,防止爆刷,高效
- 可读性好:兑换码是要给用户使用的,用户需要输入兑换码,因此可读性必须好。我们的要求:
- 长度不超过10个字符
- 只能是24个大写字母和8个数字:ABCDEFGHJKLMNPQRSTUVWXYZ23456789
- 数据量大:优惠活动比较频繁,必须有充足的兑换码,最好有10亿以上的量
- 唯一性:10亿兑换码都必须唯一,不能重复,否则会出现兑换混乱的情况
- 不可重兑:兑换码必须便于校验兑换状态,避免重复兑换
- 防止爆刷:兑换码的规律性不能很明显,不能轻易被人猜测到其它兑换码
- 高效:兑换码生成、验证的算法必须保证效率,避免对数据库带来较大的压力
要满足唯一性,很多同学会想到以下技术:
那重兑问题该如何判断呢?此处有两种方案:
项目中的做法通过Redis来存储自增ID自增后的ID, 这样就可以防止数据库中的兑换码的ID冲突①, 设置一种算法, 通过传入自增ID, 以及优惠券的ID来固定的获取兑换码(秘钥是通过做模运算, 得到十六个秘钥中的一个,即新鲜值),
前面还差14位, 将 自增ID和密钥进行加权运算, 得到的值作为签名, 转为二进制, 前面不够的补零
最终如下:
这里是通过算法来生成兑换码, 为什么使用异步呢?
因为生成兑换码相对业务的处理来说相对比较耗时, 而采用异步的算法, 对应的业务只需要处理好自己的逻辑即可, 其余的有程序异步执行
如下: SpringBoot异步执行方法_yfs1024的博客-CSDN博客
ArrayList<LearningPlanDTO> learningPlanDTOS = new ArrayList<>();
LearningPlanDTO l1 = new LearningPlanDTO().setCourseId(1L).setFreq(100);
LearningPlanDTO l2 = new LearningPlanDTO().setCourseId(1L).setFreq(200);
LearningPlanDTO l3 = new LearningPlanDTO().setCourseId(2L).setFreq(300);
learningPlanDTOS.add(l1);
learningPlanDTOS.add(l2);
learningPlanDTOS.add(l3);
// 1. 统计Freq大于等于200的个数
long count = learningPlanDTOS.stream().filter(c -> c.getFreq() >= 200L).count();
System.out.println("大于等于200的个数" + count);
//2.统计 各个ID下的个数
Map<Long, Long> collect = learningPlanDTOS
.stream()
.collect(Collectors.groupingBy(LearningPlanDTO::getCourseId, Collectors.counting()));
System.out.println(collect);
//3. 统计所有的频率之和
int sum = learningPlanDTOS
.stream()
.mapToInt(LearningPlanDTO::getFreq).sum();
System.out.println(sum);
//4. 将数字集合转换为 1,2,3 类似的字符串
Long arr[] = {1L, 2L, 3L, 4L};
String collect = Stream.of(arr).sorted(Long::compare).map(String::valueOf).collect(Collectors.joining(","));
System.out.println(collect);
结果:
大于等于200的个数2
{1=2, 2=1}
600
1,2,3,4
超卖问题的核心原因就是对于相同的代码, 前面的条件都符合因为此时的数据库的信息还尚未更改,问题就出在这, 合法性校验都通过, 都将执行最后的修改操作,从而导致超卖
左边为优惠券一共的数量, 右边为实际领取的数量
此时为什么出现超卖的现象呢? 因为 A- > 过来可以开启事务, 在A还没有提交之前B线程也可以开启事务来进行数据的操作, 此时A提交, B提交
️ (现在我们对于优惠券库存的处理逻辑是这样的:)
这里采用的是先查询,再判断,再更新的方案,而以上三步操作并不具备原子性。单线程的情况下确实没有问题。但如果是多线程并发运行,如果N个线程同时去查询(N大于剩余库存),此时大概率查询到的库存是充足的,然后判断库存自然没问题。最后一起更新库存,自然就会超卖。
总结一下,原因是:
何为悲观锁?
悲观锁是一种独占和排他的锁机制,保守地认为数据会被其他事务修改,所以在整个数据处理过程中将数据处于锁定状态。
悲观锁认为安全问题一定会发生,所以直接独占资源。结果就是多个线程会串行执行被保护的代码。
何为乐观锁?
乐观锁是一种较为乐观的并发控制方法,假设多用户并发的不会产生安全问题,因此无需独占和锁定资源。但在更新数据前,会先检查是否有其他线程修改了该数据,如果有,则认为可能有风险,会放弃修改操作
悲观锁优缺点:
乐观锁优缺点:
项目中的修改: 是允许字段被修改的,只要不超过要求的范围即可以
实例如下:
UPDATE coupon SET issue_num = issue_num + 1 WHERE id = 1 AND issue_num < total_num
领券时还有对于用户限领数量的判断:
- 查询优惠券
- 判断库存是否充足(领取数量<总数量)
- 如果充足,更新优惠券领取数量
可以看到,这部分逻辑也是按照三步走:(前面的步骤如上:)
因为这三个优惠券并不具备原子性, 所以当多个线程来的时候可以同时的判断数据库中的数据,且都通过验证, 知道最后一步的新增用户券,导致多个线程同时执行. 从而产生超卖
那怎么解决呢? 这是一个循序渐进的步骤,下面就一点一点的进行
用户限领数量判断是针对单个用户的,因此锁的范围不需要是整个方法,只要锁定某个用户即可。所以这里建议采用Synchronized的代码块,而不是同步方法。并且同步代码块的锁指定为用户id,那么同一个用户并发操作时会被锁定,不同用户互相没有影响,整体效率也是可以接受的。
@Transactional
private void checkAndCreateUserCoupon(Coupon Coupon , Long userId){
// 这里对当前用户加锁
synchronized(userId.toString().intern()){
..... 业务代码省略
// - 查询数据库
// - 判断是否超出限领数量
// - 新增用户券
}
}
Long的toString()方法 会发现底层依然是New 这样同一个UserID 还是不同的对象
public static String toString(long i) {
int size = stringSize(i);
if (COMPACT_STRINGS) {
byte[] buf = new byte[size];
getChars(i, size, buf);
return new String(buf, LATIN1);
} else {
byte[] buf = new byte[size * 2];
StringUTF16.getChars(i, size, buf);
return new String(buf, UTF16);
}
}
这里采用的直接使用获取当前字符串的常量池中的方法
public native String intern();
上面的代码我们虽然加了锁, 但是由于事务的隔离问题, 导致依然会出现超领现象
当前业务执行的流程如下:
- 开启事务
- 获取锁
- 统计用户已领券的数量
- 判断是否超出限领数量
- 如果没超,新增一条用户券
- 释放锁
- 提交事务
此时对于开启事务是没有限制的, 所以在高并发的时候,任何一个线程都可以开启事务, 然后阻塞等待锁, 当事务在释放锁之后, 在这一段时间内就会发生超卖的现象, 此时其他的线程就可以获取到锁然后对数据一通操作, 也来到释放锁的位置, 随后二者一同提交事务, 产生超卖
解决方案:
既然是因为事务提交时机的问题, 那么我们就把二者调换一下的位置, 然后就可以解决, 如下:
public void receiveCoupon(Long couponId) {
// 这里对当前用户加锁
synchronized(userId.toString()){
checkAndCreateUserCoupon( Coupon , userId)
}
}
@Transactional // 这里进事务,同时,事务方法一定要public修饰
private void checkAndCreateUserCoupon(Coupon Coupon , Long userId){
// - 查询数据库
// - 判断是否超出限领数量
// - 新增用户券
}
此时如果测试会发现依然出现超卖 这是为什么呢?
常见的事务失效原因包括如下六个:
由于Spring的事务是基于AOP的方式结合动态代理来实现的。因此事务方法一定要是public的,这样才能便于被Spring做事务的代理和增强。
而且,在Spring内部也会有一个 org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource
类,去检查事务方法的修饰符:
@Nullable
protected TransactionAttribute computeTransactionAttribute(
Method method, @Nullable Class<?> targetClass) {
// Don't allow non-public methods, as configured.
if (allowPublicMethodsOnly() &&
!Modifier.isPublic(method.getModifiers())) {
return null;
}
// ... 略
return null;
}
@Service
public class OrderService {
public void createOrder(){
// ... 准备订单数据
// 生成订单并扣减库存
insertOrderAndReduceStock();
}
@Transactional
public void insertOrderAndReduceStock(){
// 生成订单
insertOrder();
// 扣减库存
reduceStock();
}
}
可以看到,insertOrderAndReduceStock
方法是一个事务方法,肯定会被Spring事务管理。Spring会给OrderService
类生成一个动态代理对象,对insertOrderAndReduceStock
方法做增加,实现事务效果。
但是现在createOrder
方法是一个非事务方法,在其中调用了insertOrderAndReduceStock
方法,这个调用其实隐含了一个this.
的前缀。也就是说,这里相当于是直接调用原始的OrderService中的普通方法,而非被Spring代理对象的代理方法。那事务肯定就失效了!
异常被捕获了但是没有往外抛异常,所以事务没有发现方法中出现错误,所以也就没有回滚
@Transactional
public void createOrder(){
// ... 准备订单数据
// 生成订单
insertOrder();
// 扣减库存
reduceStock();
}
private void reduceStock() {
try {
// ...扣库存
} catch (Exception e) {
// 处理异常
}
}
在这段代码中,reduceStock方法内部直接捕获了Exception类型的异常,也就是说方法执行过程中即便出现了异常也不会向外抛出。
而Spring的事务管理就是要感知业务方法的异常,当捕获到异常后才会回滚事务。
现在事务被捕获,就会导致Spring无法感知事务异常,自然不会回滚,事务就失效了。
@Transactional(rollbackFor = RuntimeException.class)
public void createOrder() throws IOException {
// ... 准备订单数据
// 生成订单
insertOrder();
// 扣减库存
reduceStock();
throw new IOException();
}
Spring的事务管理默认感知的异常类型是RuntimeException
,当事务方法内部抛出了一个IOException
时,不会被Spring捕获,因此就不会触发事务回滚,事务就失效了。
因此,当我们的业务中会抛出RuntimeException以外的异常时,应该通过@Transactional
注解中的rollbackFor
属性来指定异常类型:
@Transactional(rollbackFor = Exception.class)
@Transactional
public void createOrder(){
// 生成订单
insertOrder();
// 扣减库存
reduceStock();
throw new RuntimeException("业务异常");
}
@Transactional // 默认的是如果当前没有事务,自己创建事务,如果有事务则加入
public void insertOrder() {
}
// 不管当前方法所在方法有没有都开启一个事务
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void reduceStock() {
}
在示例代码中,事务的入口是createOrder()
方法,会开启一个事务,可以成为外部事务。在createOrder()方法内部又调用了insertOrder()
方法和reduceStock()
方法。这两个都是事务方法。
不过,reduceStock()
方法的事务传播行为是REQUIRES_NEW
,这会导致在进入reduceStock()
方法时会创建一个新的事务,可以成为子事务。insertOrder()
则是默认,因此会与createOrder()
合并事务。
因此,当createOrder
方法最后抛出异常时,只会导致insertOrder
方法回滚,而不会导致reduceStock
方法回滚,因为reduceStock
是一个独立事务。
所以,一定要慎用传播行为,注意外部事务与内部事务之间的关系。
即当前类没有被SpringBoot扫描
上面的问题在于非事务方法中调用事务方法其中隐含了一个this.
的前缀, 虽然当前方法的事务也被代理类生成了,但是因为默认关键字的原因,调用的还是原来的是没有事务的方法.
所以我们现在要做的就是要找到被代理之后的类,然后再在方法中调用该方法
<dependency>
<groupId>org.aspectjgroupId>
<artifactId>aspectjweaverartifactId>
<version>1.9.7version>
dependency>
在启动类上添加注解,暴露代理对象:
通过AopContext拿到当前类的代理对象,然后调用对应方法
// 返回值是Object
IUserCouponService userCouponService = (IUserCouponService) AopContext.currentProxy();
userCouponService.insertCouponAndCheck(userId, coupon, null);
在Spring中有两个和事务相关的接口
PlatformTransactionManager 平台事务管理接口
作用; 对于不同的数据源采用不同的管理平台, 常用的实现类如下
DataSourceTransactionManager:使用JDBC或MyBatis进行事务管理。适用于DataSource数据源。
HibernateTransactionManager:使用Hibernate进行事务管理。适用于Hibernate持久层框架。
TransactionDefinition 事务定义接口
TransactionDefinition 接口中定义了事务的描述相关的三类常量:
DEFAULT : 采用DB默认的事务隔离级别.mysql中默认的是 REPEATABLE_READ (repeatable_read)
READ_UNCOMMITTED: 读未提交 未解决任何并发问题
READ_COMMITTED: 读已提交 解决了脏读,存在不可重复读和幻读
REPETABLE_READ : 可重复读,解决了脏读,不可重复读,存在幻读
SERIALIZABLE :串行化.不存在并发问题
那么什么是脏读, 幻读和 不可重复读呢?
脏读(dirty read): 当一个事务读取另一个事务尚未提交的修改时,产生脏读
不可重复读(nonrepeatable read):同一查询在同一事务中多次进行,由于其他提交事务所做的修改或删除,每次返回不同的结果集,此时发生不可重复读
幻读(phantom read):同一查询在同一事务中多次进行,由于其他提交事务所做的插入操作,每次返回不同的结果集,此时发生幻读
事务传播行为是指,处于不同事务中的方法在相互调用时,执行期间事务的维护情况
propagation_required (spring默认的传播行为)
propagation_requires_new
propagation_supports
propagation_required :
说明:指定的方法必须在事务内执行。若当前存在事务,就加入到当前事务中;若当前没有事务,则创建一个新事务。这种传播行为是最常见的选择,也是 Spring 默认的事务传播行为。
演示说明:
**如该传播行为加在doOther()**方法上。若 doSome()方法在调用 doOther()方法时就是在事务内运行的,则 doOther()方法的执行也加入到该事务内执行。若 doSome()方法在调用 doOther()方法时没有在事务内执行,则 doOther()方法会创建一个事务,并在其中执行。
propagation_requires_new:
说明:总是新建一个事务,如当前存在事务,就将当前事务挂起,直到新事务执行完毕
propagation_supports:
说明:指定的方法支持当前事务,但若当前没有事务,也可以以非事务方法执行
该值一般就是用默认值
问题的提出:
在第十天中,我们已经实现了领取优惠券的功能,并且解决了多线程下的券超发的并发安全问题。不过,之前我们考虑的是单机模式下的多线程问题,解决的思路是基于Synchronized锁。但是在集群模式下,传统的并发锁是否依然有效呢?因锁带来的性能损耗又该如何解决呢?今天我们就来思考并解决这些问题。
Synchronized中的重量级锁,底层就是基于**锁监视器(Monitor)**来实现的。简单来说就是锁对象头会指向一个锁监视器,而在监视器中则会记录一些信息,比如:
为什么要记录重入次数呢?
就好像下面的操作一般: 当两个同步方法存在调用关系时,且两个方法的锁都是当前对象时,如果不允许锁重入就会产生死锁, 即: 我等待我已拥有的资源释放
public static void main(String[] args) {
MethodA();
}
// MethodA需要获取当前对象作为锁
public static synchronized void MethodA(){
System.out.println("methodA"+Thread.currentThread().getName());
// 但是在方法A调用方法B的时候, 此时方法B也需要获得当前锁,但可见在方法A内已经拿到当前锁
// 如果没有可重入锁的机制,那么就会出现我自己等待我自己释放锁的情况-----> 即死锁
MethodB();
}
// MethodB也需要获取当前对象作为锁
private static synchronized void MethodB() {
System.out.println("methodB"+Thread.currentThread().getName());
}
因此每一个锁对象,都会指向一个锁监视器,而每一个锁监视器,同一时刻只能被一个线程持有,这样就实现了互斥效果。但前提是,多个线程使用的是同一把锁。
重入次数就是为了释放锁时所使用,执行完一个同步方法,_recursions
减一,直到recursions为零释放锁.
此时其它线程想要获取锁,会发现监视器中的_owner已经有值了,就会获取锁失败。由于咱们代码在锁对象是用户id的字符串常量,因此同一个用户肯定是同一把锁,线程是绝对安全的。
最后一个配置需要注意
stateless: true
true无状态;false有状态。如果业务中包含事务,这里改为false
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000 # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
Redis本身可以被任意JVM实例访问,同时Redis中的setnx命令具备互斥性,因此符合分布式锁的需求。不过实现分布式锁的时候还有一些细节需要考虑,绝不仅仅是setnx这么简单。
在释放锁的时候首先需要判断是不是自己的锁,其次才是删除锁, 但是因为自己写没法保证是原子操作,所以依然会出现误删锁. 此时就可以使用已经成熟的框架Redisson
Redisson如何实现分布式锁、锁续约
图解Redisson如何实现分布式锁、锁续约?
Redis:Redisson分布式锁的锁续期原理
可以利用Redis的LUA脚本来编写锁操作, 确保原子
利用WatchDog(看门狗)机制,获取锁成功时开启一个定时任务,在锁到期前自动续期,避免超时释放。而当服务宕机后,WatchDog跟着停止运行,不会导致死锁。
可以模拟Synchronized原理,放弃setnx,而是利用Redis的Hash结构来记录锁的持有者以及重入次数,获取锁时重入次数+1,释放锁是重入次数-1,次数为0则锁删除
可以利用Redis官网推荐的RedLock机制来解决
基于Redis的Redisson分布式可重入锁实现了
java.util.concurrent.locks.Lock
接口
Redisson 锁的种类有以下几种:可重入锁,公平锁,联锁,红锁,读写锁
一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁。
类似于java中的 ReentrantLock
public void method1() {
RLock lock = redissonClient.getLock("lock");
try {
lock.lock();
method2();
} finally {
lock.unlock();
}
System.out.println("释放锁成功");
}
public void method2() {
RLock lock = redissonClient.getLock("lock");
try {
if (lock.tryLock()) {
System.out.println("加锁成功");
//业务逻辑
}
} finally {
lock.unlock();
}
}
它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
RLock fairLock = redisson.getFairLock("anyLock");
// 最常见的使用方法
fairLock.lock();
可以将多个锁对象关联到一个锁对象时
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 所有的锁都上锁成功才算成功。
lock.lock();
//业务逻辑
lock.unlock();
当有大部分(一半以上)锁加锁成功后,才算真正获得锁
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。 这里有3个锁,至少2个加锁成功,才算真的加锁成功
lock.lock();
...
lock.unlock();
上读锁的时候可以多个线程获取,上写锁只能有一个线程获取,经过测试当写锁还未释放的时候,读锁阻塞获取不了,直到写锁释放。
/**
* 保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁)。读锁是一个共享锁
* 写锁没释放,读就必须等待
*/
@GetMapping("/read")
@ResponseBody
public String readValue() {
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return "";
}
@GetMapping("/write")
@ResponseBody
public String writeValue() {
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
RLock rLock = readWriteLock.writeLock();
try {
//1、改数据加写锁,读数据加读锁
rLock.lock();
TimeUnit.SECONDS.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return "";
}
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
dependency>
@Autowired
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 1.获取锁对象,指定锁名称
RLock lock = redissonClient.getLock("anyLock");
try {
// 2.尝试获取锁,参数:waitTime、leaseTime、时间单位
// 注意这里, 如果什么参数都不穿,才会触发看门狗机制
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
// 获取锁失败处理 ..
} else {
// 获取锁成功处理
}
} finally {
// 4.释放锁
lock.unlock();
}
}
利用Redisson获取锁时可以传3个参数:
因为Redisson底层使用的就是Redis, 所以这里读取的配置直接就是Redis中的
@Slf4j
@ConditionalOnClass({RedissonClient.class, Redisson.class})
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class RedissonConfig {
private static final String REDIS_PROTOCOL_PREFIX = "redis://";
private static final String REDISS_PROTOCOL_PREFIX = "rediss://";
@Bean
@ConditionalOnMissingBean
public LockAspect lockAspect(RedissonClient redissonClient){
return new LockAspect(redissonClient);
}
@Bean
@ConditionalOnMissingBean
// 读取SpringBoot中的Redis配置
public RedissonClient redissonClient(RedisProperties properties){
log.debug("尝试初始化RedissonClient");
// 1.读取Redis配置
RedisProperties.Cluster cluster = properties.getCluster();
RedisProperties.Sentinel sentinel = properties.getSentinel();
String password = properties.getPassword();
int timeout = 3000;
Duration d = properties.getTimeout();
if(d != null){
timeout = Long.valueOf(d.toMillis()).intValue();
}
// 2.设置Redisson配置
Config config = new Config();
if(cluster != null && !CollectionUtil.isEmpty(cluster.getNodes())){
// 集群模式
config.useClusterServers()
.addNodeAddress(convert(cluster.getNodes()))
.setConnectTimeout(timeout)
.setPassword(password);
}else if(sentinel != null && !StrUtil.isEmpty(sentinel.getMaster())){
// 哨兵模式
config.useSentinelServers()
.setMasterName(sentinel.getMaster())
.addSentinelAddress(convert(sentinel.getNodes()))
.setConnectTimeout(timeout)
.setDatabase(0)
.setPassword(password);
}else{
// 单机模式
config.useSingleServer()
.setAddress(String.format("redis://%s:%d", properties.getHost(), properties.getPort()))
.setConnectTimeout(timeout)
.setDatabase(0)
.setPassword(password);
}
// 3.创建Redisson客户端
return Redisson.create(config);
}
private String[] convert(List<String> nodesObject) {
List<String> nodes = new ArrayList<>(nodesObject.size());
for (String node : nodesObject) {
if (!node.startsWith(REDIS_PROTOCOL_PREFIX) && !node.startsWith(REDISS_PROTOCOL_PREFIX)) {
nodes.add(REDIS_PROTOCOL_PREFIX + node);
} else {
nodes.add(node);
}
}
return nodes.toArray(new String[0]);
}
}
此项目中自定义的RedisClient
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer()
.setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 创建客户端
return Redisson.create(config);
}
几个关键点:
@ConditionalOnClass({RedissonClient.
class
, Redisson.
class
})
也就是说,只要引用了tj-common,并且引用了Redisson依赖,这套配置就会生效。不引入Redisson依赖,配置自然不会生效,从而实现按需引入。Redisson的分布式锁使用并不复杂,基本步骤包括:
但是,除了第3步以外,其它都是非业务代码,对业务的侵入较多:
可以发现,非业务代码格式固定,每次获取锁总是在重复编码。我们可不可以对这部分代码进行抽取和简化呢?
综上,我们计划利用注解来标记切入点,传递锁参数。同时利用AOP环绕增强来实现加锁、释放锁等操作。
注解本身起到标记作用,同时还要带上锁参数:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
/**
* 加锁key的表达式,支持SPEL表达式
*/
String name();
/**
* 阻塞超时时长,不指定 waitTime 则按照Redisson默认时长
*/
long waitTime() default 1;
/**
* 锁自动释放时长,默认是-1,其实是30秒 + watchDog模式
*/
long leaseTime() default -1;
/**
* 时间单位,默认为秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
方式一: 继承Ordered接口
方式二: 在类上添加 @Order 注解
@Component
@Aspect
@RequiredArgsConstructor
public class MyLockAspect implements Ordered{
private final RedissonClient redissonClient;
// 这里使用注解的方式指定切入点, 即带了MyLock的方法
@Around("@annotation(myLock)")
public Object tryLock(ProceedingJoinPoint pjp, MyLock myLock) throws Throwable {
// 1.创建锁对象
RLock lock = redissonClient.getLock(myLock.name());
// 2.尝试获取锁
boolean isLock = lock.tryLock(myLock.waitTime(), myLock.leaseTime(), myLock.unit());
// 3.判断是否成功
if(!isLock) {
// 3.1.失败,快速结束
throw new BizIllegalException("请求太频繁");
}
try {
// 3.2.成功,执行业务
return pjp.proceed();
} finally {
// 4.释放锁
lock.unlock();
}
}
@Override
public int getOrder() {
return 0;
}
}
注意1: @Around(“@annotation(myLock)”) 后面的参数一定要和方法入参的参数列表中的参数相同
=
注意2: 这里采用继承Ordered来指定不同注解生效的时机,注意,Spring中的AOP切面有很多,会按照Order排序,按照Order值从小到大依次执行。Spring事务AOP的order值是Integer.MAX_VALUE,优先级最低。
我们的分布式锁一定要先于事务执行,因此,我们的切面一定要实现Ordered接口,指定order值小于Integer.MAX_VALUE即可。
可以看到,业务中无需手动编写加锁、释放锁的逻辑了,没有任何业务侵入,使用起来也非常优雅
不过呢,现在还存在几个问题:(下面就通过一些方式来对其存在的问题优化)
对于锁的类型我们采用工厂模式来解决
Redisson中锁的类型有多种,例如:
那么问题来了,如何让用户选择锁类型呢?
锁的类型虽然有多种,但类型是有限的几种,完全可以通过枚举定义出来。然后把这个枚举作为MyLock
注解的参数,交给用户去选择自己要用的类型。
这里我们的需求是根据用户选择的锁类型,创建不同的锁对象。有一种设计模式刚好可以解决这个问题:简单工厂模式。
定义一个注解作为锁的key, ----> 目的是根据不同的key 来创建出不同的对象
工厂模式实现的两种方法
方式一(使用枚举,加自定义工厂类)
定义一个锁类型枚举:
public enum MyLockType {
DEFAULT, // 可重入锁
RE_ENTRANT_LOCK, // 可重入锁
FAIR_LOCK, // 公平锁
READ_LOCK, // 读锁
WRITE_LOCK, // 写锁
;
}
然后在自定义注解中添加锁类型这个参数
:
/**
* 锁的类型,包括:可重入锁、公平锁、读锁、写锁
*/
MyLockType lockType() default MyLockType.RE_ENTRANT_LOCK;
定义一个锁工厂,用于根据锁类型创建锁对象:
@Component
public class MyLockFactory {
private final Map> lockHandlers;
// Spring自动注入RedissonClient
public MyLockFactory(RedissonClient redissonClient) {
this.lockHandlers = new EnumMap<>(MyLockType.class);
this.lockHandlers.put(RE_ENTRANT_LOCK, redissonClient::getLock);
this.lockHandlers.put(FAIR_LOCK, redissonClient::getFairLock);
this.lockHandlers.put(READ_LOCK, name -> redissonClient.getReadWriteLock(name).readLock());
this.lockHandlers.put(WRITE_LOCK, name -> redissonClient.getReadWriteLock(name).writeLock());
}
public RLock getLock(MyLockType lockType, String name){
return lockHandlers.get(lockType).apply(name);
}
}
方式二(使用枚举类中的抽象方法)
使用枚举中的抽象方法
public enum LockType {
DEFAULT(){
@Override
public RLock getLock(RedissonClient redissonClient, String name) {
return redissonClient.getLock(name);
}
},
FAIR_LOCK(){
@Override
public RLock getLock(RedissonClient redissonClient, String name) {
return redissonClient.getFairLock(name);
}
},
READ_LOCK(){
@Override
public RLock getLock(RedissonClient redissonClient, String name) {
return redissonClient.getReadWriteLock(name).readLock();
}
},
WRITE_LOCK(){
@Override
public RLock getLock(RedissonClient redissonClient, String name) {
return redissonClient.getReadWriteLock(name).writeLock();
}
},
;
// 定义抽象方法
public abstract RLock getLock(RedissonClient redissonClient, String name);
}
加入到自定义注解中
/**
* 锁的类型,包括:可重入锁、公平锁、读锁、写锁
*/
LockType lockType() default LockType.DEFAULT;
多线程争抢锁,大部分线程会获取锁失败,而失败后的处理方案和策略是多种多样的。目前,我们获取锁失败后就是直接抛出异常,没有其它策略,这与实际需求不一定相符。
接下来,我们就分析一下锁失败的处理策略有哪些。
大的方面来说,获取锁失败要从两方面来考虑:
lock.tryLock(0, 10, SECONDS)
,也就是waitTime小于等于0lock.tryLock(5, 10, SECONDS)
,也就是waitTime大于0,重试一定waitTime时间后结束lock.lock(10, SECONDS)
, lock就是无限重试对应的API和策略名如下:
重试策略 + 失败策略组合,总共以下几种情况:
一般的策略模式大概是这样:
而在策略比较简单的情况下,我们完全可以用枚举代替策略工厂,简化策略模式。
使用策略模式
package com.tianji.promotion.utils;
import com.tianji.common.exceptions.BizIllegalException;
import org.redisson.api.RLock;
public enum MyLockStrategy {
SKIP_FAST(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
return lock.tryLock(0, prop.leaseTime(), prop.unit());
}
},
FAIL_FAST(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
boolean isLock = lock.tryLock(0, prop.leaseTime(), prop.unit());
if (!isLock) {
throw new BizIllegalException("请求太频繁");
}
return true;
}
},
KEEP_TRYING(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
lock.lock( prop.leaseTime(), prop.unit());
return true;
}
},
SKIP_AFTER_RETRY_TIMEOUT(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
return lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
}
},
FAIL_AFTER_RETRY_TIMEOUT(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
boolean isLock = lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
if (!isLock) {
throw new BizIllegalException("请求太频繁");
}
return true;
}
},
;
public abstract boolean tryLock(RLock lock, MyLock prop) throws InterruptedException;
}
玩转Spring中强大的spel表达式! - 知乎 (zhihu.com)
现在还剩下最后一个问题,就是锁名称的问题。
在当前业务中,我们的锁对象本来应该是当前登录用户,是动态获取的。而加锁是基于注解参数添加的,在编码时就需要指定。怎么办?
Spring中提供了一种表达式语法,称为SPEL表达式,可以执行java代码,获取任意参数。
我们可以让用户指定锁名称参数时不要写死,而是基于SPEL表达式。在创建锁对象时,解析SPEL表达式,动态获取锁名称。
首先,在使用锁注解时,锁名称可以利用SPEL表达式,例如我们指定锁名称中要包含参数中的用户id,则可以这样写:
如果是通过UserContext.getUser()获取,则可以利用下面的语法:
这里T(类名).方法名()
就是调用静态方法。
解析SPEL
在切面中,我们需要基于注解中的锁名称做动态解析,而不是直接使用名称:
/**
* SPEL的正则规则
*/
private static final Pattern pattern = Pattern.compile("\\#\\{([^\\}]*)\\}");
/**
* 方法参数解析器
*/
private static final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
/**
* 解析锁名称
* @param name 原始锁名称
* @param pjp 切入点
* @return 解析后的锁名称
*/
private String getLockName(String name, ProceedingJoinPoint pjp) {
// 1.判断是否存在spel表达式
if (StringUtils.isBlank(name) || !name.contains("#")) {
// 不存在,直接返回
return name;
}
// 2.构建context,也就是SPEL表达式获取参数的上下文环境,这里上下文就是切入点的参数列表
EvaluationContext context = new MethodBasedEvaluationContext(
TypedValue.NULL, resolveMethod(pjp), pjp.getArgs(), parameterNameDiscoverer);
// 3.构建SPEL解析器
ExpressionParser parser = new SpelExpressionParser();
// 4.循环处理,因为表达式中可以包含多个表达式
Matcher matcher = pattern.matcher(name);
while (matcher.find()) {
// 4.1.获取表达式
String tmp = matcher.group();
String group = matcher.group(1);
// 4.2.这里要判断表达式是否以 T字符开头,这种属于解析静态方法,不走上下文
Expression expression = parser.parseExpression(group.charAt(0) == 'T' ? group : "#" + group);
// 4.3.解析出表达式对应的值
Object value = expression.getValue(context);
// 4.4.用值替换锁名称中的SPEL表达式
name = name.replace(tmp, ObjectUtils.nullSafeToString(value));
}
return name;
}
private Method resolveMethod(ProceedingJoinPoint pjp) {
// 1.获取方法签名
MethodSignature signature = (MethodSignature)pjp.getSignature();
// 2.获取字节码
Class<?> clazz = pjp.getTarget().getClass();
// 3.方法名称
String name = signature.getName();
// 4.方法参数列表
Class<?>[] parameterTypes = signature.getMethod().getParameterTypes();
return tryGetDeclaredMethod(clazz, name, parameterTypes);
}
private Method tryGetDeclaredMethod(Class<?> clazz, String name, Class<?> ... parameterTypes){
try {
// 5.反射获取方法
return clazz.getDeclaredMethod(name, parameterTypes);
} catch (NoSuchMethodException e) {
Class<?> superClass = clazz.getSuperclass();
if (superClass != null) {
// 尝试从父类寻找
return tryGetDeclaredMethod(superClass, name, parameterTypes);
}
}
return null;
}
那么为什么要预生成订单号呢?
为了防止在订单界面用户多次的点击提交按钮, 导致后台每次的请求都生成订单, 此时就可以使用mybatis-plus
中的IdWorker
类来生成
代码实现:
long id = IdWorker.getId();
// 1667202759771824129
System.out.println(id);
这里是通过entries()方法
private Coupon queryCouponByCache(Long couponId) {
// 1 设置key
String key = PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId;
// 1.1 查询Redis获取数据
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
// 2. 数据校验,如果为
if (entries.isEmpty()) {
return null;
}
// 拷贝到Coupon实体中
return BeanUtils.mapToBean(entries, Coupon.class, false, CopyOptions.create());
}
// 求两个集合的交集
Collection<CouponDiscountDTO> bestSolutions = CollUtils.intersection(discountMaxSolution.values(), lessCouponSolution.values());
利用装饰模式根据传入的Collection生成特定同步的SynchronizedCollection,生成的集合每个同步操作都是持有mutex<互斥锁>这个锁,所以再进行操作时就是线程安全的集合了。
设计模式 装饰者模式 其在JDK中的应用_一叶一菩提魁的博客-CSDN博客
SynchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步。而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。
所以,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,这样,即使在遍历map时,如果其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。
java多线程基础知识_yfs1024的博客-CSDN博客
在阿里巴巴开发手册中禁用这种方式
参数七是内部类中定义的
ExecutorService pool = new ThreadPoolExecutor(
3, //核心线程数有3个
5, //最大线程数有5个。 临时线程数=最大线程数-核心线程数=5-3=2
8, //临时线程存活的时间8秒。 意思是临时线程8秒没有任务执行,就会被销毁掉。
TimeUnit.SECONDS,//时间单位(秒)
new ArrayBlockingQueue<>(4), //任务阻塞队列,没有来得及执行的任务在,任务队列中等待
Executors.defaultThreadFactory(), //用于创建线程的工厂对象,从线程工具类中的获取
new ThreadPoolExecutor.CallerRunsPolicy() //拒绝策略
);
@Autowired
ThreadPoolTaskExecutor taskExecutor;
// 方法中使用执行器,并调用
public void testMethod(){
ThreadPoolExecutor threadPoolExecutor = taskExecutor.getThreadPoolExecutor();
threadPoolExecutor.execute(()-> System.out.println("hello"));
}
为什么不使用默认的配置呢?自己看
这个和第一个不使用的原因都一样, 容易产生OOM(Out Of Memory)
这里如果什么都不写使用的就是上面ThreadPoolTaskExecutor中默认的线程池设置,所以很不靠谱一般会自己手动创建
示例:
@Bean("taskExecutor")
public Executor asyncServiceExecutor() {
log.info("start asyncServiceExecutor");
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
//配置核心线程数
executor.setCorePoolSize(10);
//配置最大线程数
executor.setMaxPoolSize(15);
//配置队列大小
executor.setQueueCapacity(99999);
//配置线程池中的线程的名称前缀
executor.setThreadNamePrefix("pd-user-async-service-");
// 设置拒绝策略:当pool已经达到max size的时候,如何处理新任务
// CALLER_RUNS:不在新线程中执行任务,而是有调用者所在的线程来执行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
//执行初始化
executor.initialize();
return executor;
}
这里的优化 采用了JUC中的两个类
CountDownLatch是通过一个计数器来实现的,计数器的初始化值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就相应的减1。当计数器到达0时,表示所有的线程都已完成任务,然后在闭锁上等待的线程就可以恢复执行任务。
// 构造一个用给定计数初始化的 CountDownLatch。
CountDownLatch(int count)
// 使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。
void await()
// 使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断或超出了指定的等待时间。
boolean await(long timeout, TimeUnit unit)
// 递减锁存器的计数,如果计数到达零,则释放所有等待的线程。
void countDown()
// 返回当前计数。
long getCount()
// 返回标识此锁存器及其状态的字符串。
String toString()
使用示例:
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for (int i=0; i<9; i++) {
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 运行");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}
}).start();
}
System.out.println("等待子线程运行结束");
latch.await(10, TimeUnit.SECONDS);
System.out.println("子线程运行结束");
}f
子线程先等待,主线线程处理完任务后恢复子线程,同时主线程等待子线程处理完任务继续执行
使用CompletableFuture
有两种格式,一种是supply开头的方法,一种是run开头的方法
// 无返回值
runAsync(Runnable runnable)
// 无返回值 可以自定义线程池
runAsync(Runnable runnable, Executor executor)
// 有返回值
supplyAsync(Supplier<U> supplier)
// 有返回值 可以自定义线程池
supplyAsync(Supplier<U> supplier, Executor executor)
传递任务的结果, 为上面的有返回值的调用—> 这样的目的就是让业务逻辑更加的清晰
// 任务完成后调用action,并传递任务结果
thenAccept(Consumer<? super T> action)
//任务完成后异步调用action,并传递任务结果
thenAcceptAsync(Consumer<? super T> action)
// 任务完成后调用action,但是不传递任务结果
thenRun(Runnable action)
//任务完成后异步调用action,但是不传递任务结果
thenRunAsync(Runnable action)