Redis分布式锁(中)

news2025/2/24 14:05:15
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

我们在不久前介绍了SpringBoot定时任务,最近又一起探究了如何使用Redis实现简单的消息队列,都是一些不错的小知识点。为了能跟前面的内容产生联动,这次我们打算把Redis分布式锁相关的介绍融合进定时任务的案例中,学起来更带劲~

Redis的锁长啥样?

上一篇我们粗略介绍了JVM锁,比如synchronized关键字和ReentrantLock,它们都是实实在在已经实现的锁,而且还有标志位啥的。但Redis就是一个内存...怎么作为锁呢?

有一点大家要明确,Redis之所以能用来做分布式锁,肯定不只是因为它是一片内存,否则JVM本身也占有内存,为什么无法自己实现分布式锁呢?

我个人的理解是,要想自定义一个分布式锁,必须至少满足几个条件:

  • 独立于多节点系统之外的一片内存
  • 唯一性(可以通过单线程,也可以通过选举机制,能保证唯一即可)
  • 当然,如果性能高一点,甚至支持高可用就更好啦

以上三点Redis都能满足。在上面三个条件下,其实怎么设计锁,完全取决于个人如何定义锁。就好比现实生活中,通常我们理解的锁就是有个钥匙孔、需要插入钥匙的金属小物件。然而锁的形态可不止这么一种,随着科技的发展,什么指纹锁、虹膜锁层出不穷,但归根结底它们之所以被称为“锁”,是因为都保证了“唯一”。

如果我们能设计一种逻辑,它能造成某个场景下的“唯一事件”,那么它就可以被称为“锁”。比如,某家很有名的网红店,一天只接待一位客人。门口没有营业员,就放了一台取号机,里面放了一张票。你如果去迟了,票就没了,你就进不了这家店。这个场景下,没票的顾客进不去,被锁在门外。此时,取票机造成了“唯一事件”,那么它就可以叫做“锁”。

而Redis提供了setnx指令,如果某个key当前不存在则设置成功并返回true,否则不再重复设置,直接返回false。这不就是编程界的取号机吗?当然,实际用到的命令可不止这一个,具体如何实现,大家等下看代码即可。

Demo构思

在我看来,同样需要使用锁,动机可能完全相反:

  • 在保证线程安全的前提下,尽量让所有线程都执行成功
  • 在保证线程安全的前提下,只让一个线程执行成功

前者适用于秒杀等场景。作为商家,当然希望在不发生线程安全问题的前提下,让每一个订单都生效,直到商品售罄。此时分布式锁的写法可以是“不断重试”“阻塞等待”,即:递归或while true循环尝试获取、阻塞等待。

而后者适用于分布式系统或多节点项目的定时任务,比如同一份代码部署在A、B两台服务器上,而数据库共用同一个。如果不做限制,那么在同一时刻,两台服务器都会去拉取列表执行,会发生任务重复执行的情况。

此时可以考虑使用分布式锁,在cron触发的时刻只允许一个线程去往数据库拉取任务:

在实现Redis分布式锁控制定时任务唯一性的同时,我们引入之前的Redis消息队列。注意,这与Redis分布式锁本身无关,就是顺便复习一遍Redis消息队列而已,大家可以只实现Redis分布式锁+定时任务的部分。

整个Demo的结构大致如图:

当然,实际项目中一般是这样的:

分布式锁为什么难设计?

首先,要和大家说一下,但凡牵涉到分布式的处理,没有一个是简单的,上面的Demo设计也不过是玩具,用来启发 大家的思路。

为什么要把demo设计得这么复杂呢?哈哈,因为这是我在上一家公司自己设计的,遇到了很多坑...拿出来自嘲一番,与各位共勉。

我当时的设计思路是:

由于小公司没有用什么Elastic-Job啥的,就是很普通的多节点部署。为了避免任务重复执行,我想设计一个分布式锁。但因为当时根本不知道redisson,所以就自己百度了redis实现分布式锁的方式,然后依葫芦画瓢自己手写了一个 。

但我写完redis分布式锁后,在实际测试过程中发现还需要考虑锁的失效时间...

这里有两个问题:

  • 为什么要设置锁的过期时间?
  • 锁的过期时间设置多久合适?

最简单的实现方案是这样的,一般没问题:

但极端的情况下(项目在任务进行时重启或意外宕机),可能当前任务来不及解锁就挂了(死锁),那么下一个任务就会一直被锁在方法外等待。就好比厕所里有人被熏晕了,没法开门,而外面的人又进不去...

此时需要装一个自动解锁的门,到时间自动开门,也就是要给锁设置一个过期时间。但紧接着又会有第二个问题:锁的失效时间设多长合适?

很难定。

因为随着项目的发展,定时任务的执行时间很可能是变化的。

如果设置时间过长,极端点,定为365天。假设任务正常执行,比如10分钟就结束,此时执行完毕的任务自己会主动解锁。但万一和上面一样宕机了,虽说你设置了过期时间,但下一个任务需要等一年才能执行...本质上和没有设置过期时间一样!就好比...你自己想想什么例子合适,能加深你的理解哦。

如果设置时间过短,上一个人还没拉完,门就“咔嚓”一声开了,尴尬不,重复执行了。

终上所述,我当时之所以设计得这么复杂,就是想尽量缩短任务执行的时间,让它尽可能短(拉取后直接丢给队列,自己不处理),这样锁的时间一般设置30分钟就没啥问题。另外,对于死锁问题,我当时没有考虑宕机的情况,只考虑了意外重启…问题还有很多,文末会再总结。

请大家阅读下面代码时思考两个问题:

  • Demo如何处理锁的过期时间
  • Demo如何防止死锁

项目搭建

新建一个空的SpringBoot项目。

拷贝下方代码,构建工程:

构建完以后,拷贝一份,修改端口号为8081,避免和原先的冲突

统一管理Redis Key:RedisKeyConst

/**
 * 统一管理Redis Key
 *
 * @author mx
 */
public abstract class RedisKeyConst {
    /**
     * 分布式锁的KEY
     */
    public static final String RESUME_PULL_TASK_LOCK = "resume_pull_task_lock";
    /**
     * 简历异步解析任务队列
     */
    public static final String RESUME_PARSE_TASK_QUEUE = "resume_parse_task_queue";
}

Redis消息队列:RedisMessageQueueConsumer

/**
 * 消费者,异步获取简历解析结果并存入数据库
 *
 * @author mx
 */
@Slf4j
@Component
public class RedisMessageQueueConsumer implements ApplicationListener<ContextRefreshedEvent> {

    @Autowired
    private RedisService redisService;
    @Autowired
    private AsyncResumeParser asyncResumeParser;
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        log.info("开始监听RedisMessageQueue...");
        CompletableFuture.runAsync(() -> {
            // 大循环,不断监听队列任务(阻塞式)
            while (true) {
                // 阻塞监听
                ResumeCollectionDTO resumeCollectionDTO = (ResumeCollectionDTO) redisService.popQueue(RedisKeyConst.RESUME_PARSE_TASK_QUEUE, 5, TimeUnit.SECONDS);
                if (resumeCollectionDTO != null) {
                    int rePullCount = 0;
                    int retryCount = 0;
                    log.info("从队列中取出:{}", resumeCollectionDTO.getName());
                    log.info(">>>>>>>>>>>>>>>>>>>开始拉取简历:{}", resumeCollectionDTO.getName());
                    Long asyncPredictId = resumeCollectionDTO.getAsyncPredictId();
                    // 小循环,针对每一个任务多次调用第三方接口,直到获取最终结果或丢弃任务
                    while (true) {
                        try {
                            PredictResult result = asyncResumeParser.getResult(asyncPredictId);
                            rePullCount++;
                            // 如果已经解析完毕
                            if (result.getStatus() == 2) {
                                // 保存数据库
                                try {
                                    log.info("简历:{}解析成功", resumeCollectionDTO.getName());
                                    log.info("resultJson:{}", result.getResultJson());
                                    ResumeCollectionDO resumeCollectionDO = objectMapper.readValue(result.getResultJson(), ResumeCollectionDO.class);
                                    log.info("<<<<<<<<<<<<<<<<<<<保存简历:{}到数据库", resumeCollectionDO);
                                    // 归零
                                    rePullCount = 0;
                                    retryCount = 0;
                                    break;
                                } catch (Exception e) {
                                    discardTask(resumeCollectionDTO);
                                    log.info("<<<<<<<<<<<<<<<<<<<保存简历失败,丢弃任务");
                                    rePullCount = 0;
                                    retryCount = 0;
                                    break;
                                }
                            }
                            // 远程服务还未解析完毕,重试
                            else {
                                try {
                                    if (rePullCount <= 3) {
                                        // 前3次重试,时间为1s间隔
                                        TimeUnit.SECONDS.sleep(1);
                                        log.info("简历:{}尚未解析完毕, 准备进行第{}次重试, 停顿1s后进行", resumeCollectionDTO.getName(), rePullCount);
                                    } else if (rePullCount > 3 && rePullCount <= 6) {
                                        // 说明任务比较耗时,加长等待时间
                                        TimeUnit.SECONDS.sleep(2);
                                        log.info("简历:{}尚未解析完毕, 准备进行第{}次重试, 停顿2s后进行", resumeCollectionDTO.getName(), rePullCount);
                                    } else if (rePullCount > 6 && rePullCount <= 8) {
                                        // 说明任务比较耗时,加长等待时间
                                        TimeUnit.SECONDS.sleep(3);
                                        log.info("简历:{}尚未解析完毕, 准备进行第{}次重试, 停顿3s后进行", resumeCollectionDTO.getName(), rePullCount);
                                    } else {
                                        discardTask(resumeCollectionDTO);
                                        log.info("<<<<<<<<<<<<<<<<<<<多次拉取仍未得到结果, 丢弃简历:{}", resumeCollectionDTO.getName());
                                        retryCount = 0;
                                        rePullCount = 0;
                                        break;
                                    }
                                } catch (InterruptedException e) {
                                    discardTask(resumeCollectionDTO);
                                    log.info("<<<<<<<<<<<<<<<<<<<任务中断异常, 简历:{}", resumeCollectionDTO.getName());
                                    rePullCount = 0;
                                    retryCount = 0;
                                    break;
                                }
                            }
                        } catch (Exception e) {
                            if (retryCount > 3) {
                                discardTask(resumeCollectionDTO);
                                log.info("<<<<<<<<<<<<<<<<<<<简历:{}重试{}次后放弃, rePullCount:{}, retryCount:{}", resumeCollectionDTO.getName(), retryCount, rePullCount, retryCount);
                                rePullCount = 0;
                                retryCount = 0;
                                break;
                            }
                            retryCount++;
                            log.info("简历:{}远程调用异常, 准备进行第{}次重试...", resumeCollectionDTO.getName(), retryCount);
                        }
                    }
                    log.info("break......");
                }
            }
        });
    }

    private void discardTask(ResumeCollectionDTO task) {
        // 根据asyncPredictId删除任务...
        log.info("丢弃任务:{}...", task.getName());
    }

}

实体类:DO+DTO

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResumeCollectionDO {
    /**
     * 简历id
     */
    private Long id;
    /**
     * 简历名称
     */
    private String name;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResumeCollectionDTO implements Serializable {
    /**
     * 简历id
     */
    private Long id;
    /**
     * 异步解析id,稍后根据id可获取最终解析结果
     */
    private Long asyncPredictId;
    /**
     * 简历名称
     */
    private String name;
}

分布式锁:RedisService

public interface RedisService {

    /**
     * 向队列插入消息
     *
     * @param queue 自定义队列名称
     * @param obj   要存入的消息
     */
    void pushQueue(String queue, Object obj);

    /**
     * 从队列取出消息
     *
     * @param queue    自定义队列名称
     * @param timeout  最长阻塞等待时间
     * @param timeUnit 时间单位
     * @return
     */
    Object popQueue(String queue, long timeout, TimeUnit timeUnit);

    /**
     * 尝试上锁
     *
     * @param lockKey
     * @param value
     * @param expireTime
     * @param timeUnit
     * @return
     */
    boolean tryLock(String lockKey, String value, long expireTime, TimeUnit timeUnit);

    /**
     * 根据MACHINE_ID解锁(只能解自己的)
     *
     * @param lockKey
     * @param value
     * @return
     */
    boolean unLock(String lockKey, String value);

    /**
     * 释放锁,不管是不是自己的
     *
     * @param lockKey
     * @param value
     * @return
     */
    boolean releaseLock(String lockKey, String value);

}

@Slf4j
@Component
public class RedisServiceImpl implements RedisService {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 向队列插入消息
     *
     * @param queue 自定义队列名称
     * @param obj   要存入的消息
     */
    @Override
    public void pushQueue(String queue, Object obj) {
        redisTemplate.opsForList().leftPush(queue, obj);
    }

    /**
     * 从队列取出消息
     *
     * @param queue    自定义队列名称
     * @param timeout  最长阻塞等待时间
     * @param timeUnit 时间单位
     * @return
     */
    @Override
    public Object popQueue(String queue, long timeout, TimeUnit timeUnit) {
        return redisTemplate.opsForList().rightPop(queue, timeout, timeUnit);
    }

    /**
     * 尝试上锁
     *
     * @param lockKey
     * @param value
     * @param expireTime
     * @param timeUnit
     * @return
     */
    @Override
    public boolean tryLock(String lockKey, String value, long expireTime, TimeUnit timeUnit) {
        Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, value);
        if (lock != null && lock) {
            redisTemplate.expire(lockKey, expireTime, timeUnit);
            return true;
        } else {
            return false;
        }
    }


    /**
     * 根据MACHINE_ID解锁(只能解自己的)
     *
     * @param lockKey
     * @param value
     * @return
     */
    @Override
    public boolean unLock(String lockKey, String value) {
        String machineId = (String) redisTemplate.opsForValue().get(lockKey);
        if (StringUtils.isNotEmpty(machineId) && machineId.equals(value)) {
            redisTemplate.delete(lockKey);
            return true;
        }
        return false;
    }

    /**
     * 释放锁,不管是不是自己的
     *
     * @param lockKey
     * @param value
     * @return
     */
    @Override
    public boolean releaseLock(String lockKey, String value) {
        Boolean delete = redisTemplate.delete(lockKey);
        if (delete != null && delete) {
            log.info("Spring启动,节点:{}成功释放上次简历汇聚定时任务锁", value);
            return true;
        }
        return false;
    }

}

定时任务:ResumeCollectionTask

@Slf4j
@Component
@EnableScheduling
public class ResumeCollectionTask implements ApplicationListener<ContextRefreshedEvent> {

    /**
     * 当这份代码被部署到不同的服务器,启动时为每台机器分配一个唯一的机器ID
     */
    private static String MACHINE_ID = IdUtil.randomUUID();

    @Autowired
    private RedisService redisService;
    @Autowired
    private AsyncResumeParser asyncResumeParser;

    @Scheduled(cron = "0 */1 * * * ?")
//    @Scheduled(fixedDelay = 60 * 1000L)
    public void resumeSchedule() {
        // 尝试上锁,返回true或false,锁的过期时间设置为10分钟(实际要根据项目调整,这也是自己实现Redis分布式锁的难点之一)
        boolean lock = redisService.tryLock(RedisKeyConst.RESUME_PULL_TASK_LOCK, MACHINE_ID, 10, TimeUnit.MINUTES);

        // 如果当前节点成功获取锁,那么整个系统只允许当前程序去MySQL拉取待执行任务
        if (lock) {
            log.info("节点:{}获取锁成功,定时任务启动", MACHINE_ID);
            try {
                collectResume();
            } catch (Exception e) {
                log.info("定时任务异常:", e);
            } finally {
                redisService.unLock(RedisKeyConst.RESUME_PULL_TASK_LOCK, MACHINE_ID);
                log.info("节点:{}释放锁,定时任务结束", MACHINE_ID);
            }
        } else {
            log.info("节点:{}获取锁失败,放弃定时任务", MACHINE_ID);
        }
    }

    /**
     * 任务主体:
     * 1.从数据库拉取符合条件的HR邮箱
     * 2.从HR邮箱拉取附件简历
     * 3.调用远程服务异步解析简历
     * 4.插入待处理任务到数据库,作为记录留存
     * 5.把待处理任务的id丢到Redis Message Queue,让Consumer去异步处理
     */
    private void collectResume() throws InterruptedException {
        // 跳过1、2两步,假设已经拉取到简历
        log.info("节点:{}从数据库拉取任务简历", MACHINE_ID);
        List<ResumeCollectionDO> resumeCollectionList = new ArrayList<>();
        resumeCollectionList.add(new ResumeCollectionDO(1L, "张三的简历.pdf"));
        resumeCollectionList.add(new ResumeCollectionDO(2L, "李四的简历.html"));
        resumeCollectionList.add(new ResumeCollectionDO(3L, "王五的简历.doc"));
        // 模拟数据库查询耗时
        TimeUnit.SECONDS.sleep(3);

        log.info("提交任务到消息队列:{}", resumeCollectionList.stream().map(ResumeCollectionDO::getName).collect(Collectors.joining(",")));

        for (ResumeCollectionDO resumeCollectionDO : resumeCollectionList) {
            // 上传简历异步解析,得到异步结果id
            Long asyncPredictId = asyncResumeParser.asyncParse(resumeCollectionDO);

            // 把任务插入数据库
            // 略...

            // 把任务丢到Redis Message Queue
            ResumeCollectionDTO resumeCollectionDTO = new ResumeCollectionDTO();
            BeanUtils.copyProperties(resumeCollectionDO, resumeCollectionDTO);
            resumeCollectionDTO.setAsyncPredictId(asyncPredictId);
            redisService.pushQueue(RedisKeyConst.RESUME_PARSE_TASK_QUEUE, resumeCollectionDTO);
        }

    }


    /**
     * 项目重启后先尝试删除之前的锁(如果存在),防止死锁等待
     *
     * @param event the event to respond to
     */
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        redisService.releaseLock(RedisKeyConst.RESUME_PULL_TASK_LOCK, MACHINE_ID);
    }

}

模拟第三方服务(异步)

/**
 * 第三方提供给的简历解析服务
 *
 * @author mx
 */
@Service
public class AsyncResumeParser {

    @Autowired
    private ObjectMapper objectMapper;

    /**
     * 模拟分配异步任务结果id,不用深究,没啥意义,反正每个任务都会得到一个id,稍后根据id返回最终解析结果
     */
    private static final AtomicLong ASYNC_RESULT_ID = new AtomicLong(1000);
    /**
     * 解析结果
     */
    private static final Map<Long, String> results = new HashMap<>();

    /**
     * 模拟第三方服务异步解析,返回解析结果
     *
     * @param resumeCollectionDO
     * @return
     */
    public Long asyncParse(ResumeCollectionDO resumeCollectionDO) {
        long asyncPredictId = ASYNC_RESULT_ID.getAndIncrement();
        try {
            String resultJson = objectMapper.writeValueAsString(resumeCollectionDO);
            results.put(asyncPredictId, resultJson);
            return asyncPredictId;
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return -1L;
    }

    /**
     * 根据异步id返回解析结果,但此时未必已经解析成功
     * <p>
     * 解析状态
     * 0 初始化
     * 1 处理中
     * 2 调用成功
     * 3 调用失败
     *
     * @param asyncPredictId
     * @return
     */
    public PredictResult getResult(Long asyncPredictId) throws ParseErrorException, InterruptedException {
        // 随机模拟异步解析的状态
        int value = ThreadLocalRandom.current().nextInt(100);
        if (value >= 85) {
            // 模拟解析完成
            TimeUnit.SECONDS.sleep(1);
            String resultJson = results.get(asyncPredictId);
            return new PredictResult(resultJson, 2);
        } else if (value <= 5) {
            // 模拟解析异常
            TimeUnit.SECONDS.sleep(1);
            throw new ParseErrorException("简历解析异常");
        }
        // 如果时间过短,返回status=1,表示解析中
        TimeUnit.SECONDS.sleep(1);
        return new PredictResult("", 1);
    }

}

/**
 * 解析异常
 *
 * @author mx
 */
public class ParseErrorException extends Exception {
    /**
     * Constructs a new exception with {@code null} as its detail message.
     * The cause is not initialized, and may subsequently be initialized by a
     * call to {@link #initCause}.
     */
    public ParseErrorException() {
    }

    /**
     * Constructs a new exception with the specified detail message.  The
     * cause is not initialized, and may subsequently be initialized by
     * a call to {@link #initCause}.
     *
     * @param message the detail message. The detail message is saved for
     *                later retrieval by the {@link #getMessage()} method.
     */
    public ParseErrorException(String message) {
        super(message);
    }
}

/**
 * 第三方返回值
 *
 * @author mx
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PredictResult {
    /**
     * 解析结果
     */
    private String resultJson;
    /**
     * 解析状态
     * 0 初始化
     * 1 处理中
     * 2 调用成功
     * 3 调用失败
     */
    private Integer status;
}

模拟异常

在项目运行过程中,启动这个测试类的方法,即可观察不一样的现象。

@SpringBootTest
class RedisDistributedLockApplicationTests {

    @Autowired
    private RedisService redisService;

    /**
     * 作为失败案例(因为不存在777L这个解析任务,AsyncResumeParse.results会返回null)
     * 观察RedisMessageQueueConsumer的处理方式
     */
    @Test
    void contextLoads() {
        ResumeCollectionDTO resumeCollectionDTO = new ResumeCollectionDTO();
        resumeCollectionDTO.setId(666L);
        resumeCollectionDTO.setAsyncPredictId(777L);
        resumeCollectionDTO.setName("测试1号");

        redisService.pushQueue(RedisKeyConst.RESUME_PARSE_TASK_QUEUE, resumeCollectionDTO);

    }

}

pom.xml

server:
  port: 8080

spring:
  redis:
    host:  
    password:  
    database:  

效果展示

啥都不说了,都在代码里了。大家自己拷贝到本地,动手玩一下,加深对Redis锁和Redis消息队列的理解。

只有一个定时任务能去数据库拉取任务,到时多节点部署大致是下面这样(redis一般是独立部署的,和节点代码无关):

后话

上面展示的代码其实存在很多问题,我们会在下一篇指出并讨论解决方案。

本文仅提供思路,开阔大家的眼界,千万别在自己项目中使用!!!!我当年被这个坑惨了,花里胡哨的,尤其Consumer里一大堆的sleep(),是非常low的!!

对于异步调用的结果,不要循环等待,而应该分为几步:

  1. 调用异步接口,得到异步结果唯一id
  2. 将结果id保存到任务表中,作为一个任务
  3. 启动定时任务,根据id拉取最终结果(如果还没有结果,不更改状态,等下一个定时任务处理)

分布式定时任务可以考虑xxl-job或elastic-job,分布式锁推荐使用redisson。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1215192.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【场景】高并发解决方案

文章目录 1. 硬件2. 缓存2.1 HTTP缓存2.1.1 浏览器缓存2.1.2 Nginx缓存2.1.3 CDN缓存 2.2 应用缓存 3 集群4. 拆分4.1 应用拆分&#xff08;分布式、微服务&#xff09;4.2 数据库拆分 5. 静态化6. 动静分离7. 消息队列8. 池化8.1 对象池8.2 数据库连接池8.3 线程池 9. 数据库优…

酷开系统 酷开科技,将家庭娱乐推向新高潮

在当今数字化时代&#xff0c;家庭娱乐已经成为人们日常生活中不可或缺的一部分。如果你厌倦了传统的家庭娱乐方式&#xff0c;想要一种全新的、充满惊喜的娱乐体验&#xff0c;那么&#xff0c;不妨进入到酷开科技的世界&#xff0c;作为智能电视行业领军企业&#xff0c;酷开…

Git常用操作-MD

文章目录 1. 本地创建分支&#xff0c;编写代码&#xff0c;提交本地分支到远程仓库2. 提交本地代码到本地仓库3. 提交本地代码到本地dev分支4. 提交本地dev分支到远程仓库5. 本地dev分支拉取远程master分支&#xff0c;并将master分支内容合并到本地dev6. 同义命令7. 撤销上次…

Codeforces Round 908 (Div 2——AB)

A. Secret Sport 题目 AB二人玩游戏&#xff0c;每一局&#xff08;plays&#xff09;游戏会有一个获胜者&#xff0c;首先获胜X局&#xff08;play&#xff09;的玩家得一分&#xff08;赢得一轮sets&#xff09;。率先获得Y分的玩家获得最终胜利。 给你整场游戏的每局&…

Spring Boot使用EhCache完成一个缓存集群

在上一篇在SpringBoot中使用EhCache缓存&#xff0c;我们完成了在Spring Boot中完成了对EhCaChe的使用&#xff0c;这篇&#xff0c;我们将对EhCache的进一步了解&#xff0c;也就是搭建一个EhCache的缓存集群。 集群 在搭建一个EhCache的时候&#xff0c;我们需要先了解&…

areadetector ADURL模块应用在面探测控制的初步应用

本章中讨论了使用ADURL控制面探测器Lambda的过程&#xff1a; ADURL的使用请见&#xff1a; EPICS -- areaDetector URL驱动程序-CSDN博客 需要启动一个ADURL的IOC程序&#xff0c;并且设置相关的插件中参数的值&#xff1a; # st.cm < envPaths < st_base.cmddbpf 1…

WorkPlus即时通讯app支持多种信创环境组合运行

在信息技术领域&#xff0c;国产信创技术的快速发展为企业带来了更多的选择和机会。在此背景下&#xff0c;WorkPlus作为一款全方位的移动数字化平台&#xff0c;全面支持国产信创操作系统、芯片和数据库&#xff0c;并且全面兼容鸿蒙操作系统。这一优势使得WorkPlus成为了企业…

企业数据备份应该怎么操作?应该知道的四种备份方法

​企业数据备份对于保护最重要的资产至关重要。在面对不断增加的安全威胁时&#xff0c;很多企业都感到无从下手&#xff0c;不知如何保护关键业务数据。通过采用正确的数据备份方法&#xff0c;可以成为确保企业数据安全的最有效手段。因此&#xff0c;不论您是个人还是在职人…

第二证券:今日投资前瞻:小米汽车引关注 全球风光有望持续高速发展

昨日&#xff0c;两市股指盘中轰动上扬&#xff0c;深成指、创业板指一度涨超1%。到收盘&#xff0c;沪指涨0.55%报3072.83点&#xff0c;深成指涨0.72%报10077.96点&#xff0c;创业板指涨0.53%报2015.36点&#xff0c;北证50指数涨2.64%&#xff1b;两市算计成交9900亿元&…

亚马逊云科技云存储服务指南

文章作者&#xff1a;Libai 高效的云存储服务对于现代软件开发中的数据管理至关重要。亚马逊云科技云存储服务提供了强大的工具&#xff0c;可以简化工作流程并增强数据管理能力。 亚马逊云科技开发者社区为开发者们提供全球的开发技术资源。这里有技术文档、开发案例、技术专栏…

Ubuntu 22.04 LTS ffmpeg mp4 gif 添加图片水印

ffmpeg编译安装6.0.1&#xff0c;参考 Ubuntu 20.04 LTS ffmpeg gif mp4 互转 许编译安装ffmpeg &#xff1b;解决gif转mp4转换后无法播放问题-CSDN博客 准备一个logo MP4添加水印 ffmpeg -i 2.mp4 -vf "movielogo.png[watermark];[in][watermark]overlayx10:y10[out]&…

torch_cluster、torch_scatter、torch_sparse三个包的安装

涉及到下面几个包安装的时候经常会出现问题&#xff0c;这里我使用先下载然后再安装的办法&#xff1a; pip install torch_cluster pip install torch_scatter pip install torch_sparse 1、选择你对应的torch版本&#xff1a;https://data.pyg.org/whl/ 2、点进去然后&…

PowerConsume功耗计算器

嵌入式低功耗产品开发&#xff0c;功耗计算器资源-CSDN文库 PowerConsume使用说明 安装说明 需要安装在无空格等特殊字符的路径&#xff0c;不推荐安装在C盘。 功能说明 已知条件 电池容量 各状态的电流和运行时间 自动计算出设备运行时间 启动界面如下 添加状态 在空白处…

【Python基础篇】运算符

博主&#xff1a;&#x1f44d;不许代码码上红 欢迎&#xff1a;&#x1f40b;点赞、收藏、关注、评论。 格言&#xff1a; 大鹏一日同风起&#xff0c;扶摇直上九万里。 文章目录 一 Python中的运算符二 算术运算符1 Python所有算术运算符的说明2 Python算术运算符的所有操作…

12.Oracle的索引

Oracle11g的索引 一、什么是索引二、索引的分类三、索引的语法四、分析索引四、索引的作用及使用场景 一、什么是索引 在Oracle数据库中&#xff0c;索引是一种特殊的数据结构&#xff0c;用于提高查询性能和加速数据检索。索引存储了表中某列的值和对应的行指针&#xff0c;这…

开源微信小程序源码/校园综合服务平台小程序源码+数据库/包括校园跑腿 快递代取 打印服务等功能

源码简介&#xff1a; 校园综合服务小程序源码&#xff0c;它是基于微信小程序开发&#xff0c;包括快递代取 打印服务 校园跑腿 代替服务 上门维修和其他帮助等功能。它是开源微信小程序源码。 校园综合服务小程序开源源码是一款功能强大的小程序&#xff0c;可用于搭建校园…

【uniapp/uview1.x】u-upload 在 v-for 中的使用时, before-upload 如何传参

引入&#xff1a; 是这样一种情况&#xff0c;在接口获取数据之后&#xff0c;是一个数组列表&#xff0c;循环展示后&#xff0c;需要在每条数据中都要有图片上传&#xff0c;互不干扰。 分析&#xff1a; uview 官网中有说明&#xff0c;before-upload 是不加括号的&#xff…

@Version乐观锁配置mybatis-plus使用(version)

1&#xff1a;首先在实体类的属性注解上使用Version import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.Versio…

番外 2 : LoadRunner 的安装以及配置

LoadRunner 的安装以及配置教程 一 . 配置 IE 浏览器二 . 安装 LoadRunner 工具三 . 修改默认浏览器的配置四 . 设置 LoadRunner 能够获取本地资源 Hello , 大家好 , 又给大家带来新的专栏喽 ~ 这个专栏是专门为零基础小白从 0 到 1 了解软件测试基础理论设计的 , 虽然还不足以…

《如何控制 LLM 的输出格式和解析其输出结果?》

内容来源&#xff1a;dotey 《如何控制 LLM 的输出格式和解析其输出结果&#xff1f;》 https://baoyu.io/blog/prompt-engineering/how-to-parse-the-output-from-llm 现在很多人对于如何使用像 ChatGPT 这样的 LLM 已经比较有经验了&#xff0c;可以使用各种不同的 Prompt …