【sentinel】热点规则详解及源码分析

news2025/1/24 1:29:30

何为热点?热点即经常访问的数据。很多时候我们希望统计某些热点数据中访问频次最高的Top K数据,并对其访问进行限制。

比如:

  • 商品ID为参数,统计一段时间内最常购买的商品ID并进行限制
  • 用户ID为参数,针对一段时间内频繁访问的用户ID进行限制

热点参数限流会统计传入参数中的热点参数,并根据配置的限流阈值与模式,对包含热点参数的资源调用进行限流。热点参数限流可以看做是一种特殊的流量控制,仅对包含热点参数的资源调用生效。

注意:

  • 热点规则需要使用@SentinelResource(“resourceName”)注解,否则不生效
  • 参数必须是7种基本数据类型才会生效

Sentinel利用LRU策略统计最近最常访问的热点参数,结合令牌桶算法来进行参数级别的流控。

热点参数规则

热点参数规则(ParamFlowRule)类似于流量控制规则(FlowRule):

属性说明默认值
resource资源名,必填
count限流阈值,必填
grade限流模式QPS 模式
durationInSec统计窗口时间长度(单位为秒),1.6.0 版本开始支持1s
controlBehavior流控效果(支持快速失败和匀速排队模式),1.6.0 版本开始支持快速失败
maxQueueingTimeMs最大排队等待时长(仅在匀速排队模式生效),1.6.0 版本开始支持0ms
paramIdx热点参数的索引,必填,对应 SphU.entry(xxx, args) 中的参数索引位置
paramFlowItemList参数例外项,可以针对指定的参数值单独设置限流阈值,不受前面 count 阈值的限制。仅支持基本类型和字符串类型
clusterMode是否是集群参数流控规则false
clusterConfig集群流控相关配置

我们可以通过ParamFlowRuleManager的loadRules方法更新热点参数规则,下面是一个示例:

ParamFlowRule rule = new ParamFlowRule(resourceName)
    .setParamIdx(0)
    .setCount(5);
// 针对 int 类型的参数 PARAM_B,单独设置限流 QPS 阈值为 10,而不是全局的阈值 5.
ParamFlowItem item = new ParamFlowItem().setObject(String.valueOf(PARAM_B))
    .setClassType(int.class.getName())
    .setCount(10);
rule.setParamFlowItemList(Collections.singletonList(item));

ParamFlowRuleManager.loadRules(Collections.singletonList(rule));

热点规则的使用

要使用热点参数限流功能,需要引入以下依赖:

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-parameter-flow-control</artifactId>
    <version>x.y.z</version>
</dependency>

然后为对应的资源配置热点参数限流规则,并在entry的时候传入相应的参数,即可使热点参数限流生效。

注:若自行扩展并注册了自己实现的SlotChainBuilder,并希望使用热点参数限流功能,则可以在chain里面合适的地方插入 ParamFlowSlot。

那么如何传入对应的参数以便Sentinel统计呢?我们可以通过SphU类里面几个entry重载方法来传入:

public static Entry entry(String name, EntryType type, int count, Object... args) throws BlockException

public static Entry entry(Method method, EntryType type, int count, Object... args) throws BlockException

其中最后的一串args就是要传入的参数,有多个就按照次序依次传入。比如要传入两个参数paramA和paramB,则可以:

// paramA in index 0, paramB in index 1.
// 若需要配置例外项或者使用集群维度流控,则传入的参数只支持基本类型。
SphU.entry(resourceName, EntryType.IN, 1, paramA, paramB);

注意:若entry的时候传入了热点参数,那么exit的时候也一定要带上对应的参数(exit(count, args)),否则可能会有统计错误。

正确的示例:

Entry entry = null;
try {
    entry = SphU.entry(resourceName, EntryType.IN, 1, paramA, paramB);
    // Your logic here.
} catch (BlockException ex) {
    // Handle request rejection.
} finally {
    if (entry != null) {
        entry.exit(1, paramA, paramB);
    }
}

注意在Sentinel Dashboard中的簇点链路中根据链接直接配置热点规则是无效的,因为将链接标记为资源是在拦截器AbstractSentinelInterceptor的preHandle()方法中完成的,这个方法里并没有将方法的参数传入entry中。

com.alibaba.csp.sentinel.adapter.spring.webmvc.AbstractSentinelInterceptor#preHandle

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
    throws Exception {
    try {
        // 拦截所有的web请求
        String resourceName = getResourceName(request);

        if (StringUtil.isEmpty(resourceName)) {
            return true;
        }
        
        if (increaseReferece(request, this.baseWebMvcConfig.getRequestRefName(), 1) != 1) {
            return true;
        }
        
        // Parse the request origin using registered origin parser.
        String origin = parseOrigin(request);
        String contextName = getContextName(request);
        ContextUtil.enter(contextName, origin);
        // sentinel的入口,注意没有传入方法的参数,无法实现热点规则限流
        Entry entry = SphU.entry(resourceName, ResourceTypeConstants.COMMON_WEB, EntryType.IN);
        request.setAttribute(baseWebMvcConfig.getRequestAttributeName(), entry);
        return true;
    } catch (BlockException e) {
        try {
            handleBlockException(request, response, e);
        } finally {
            ContextUtil.exit();
        }
        return false;
    }
}

对于@SentinelResource注解方式定义的资源,若注解作用的方法上有参数,Sentinel会将它们作为参数传入SphU.entry(res, args)

比如以下的方法里面p1和p2会分别作为第一个和第二个参数传入Sentinel API,从而可以用于热点规则判断:

@RequestMapping("sentinel")
@RestController
public class ParamFlowController {

    @RequestMapping("paramFlow")
    @SentinelResource("paramFlow")
    public String paramFlow(@RequestParam(value = "p1", required = false) String p1,
                            @RequestParam(value = "p2", required = false) String p2) {
        return "paramFlow p1=" + p1 + ", p2=" + p2;
    }
}

例如对上面的资源paramFlow进行热点规则配置:

限流模式只支持QPS模式,也只有QPS模式下才叫热点。

配置的参数索引是@SentinelResource注解的方法参数索引,0代表第一个参数,1代表第二个参数,以此类推;单机阀值以及统计窗口时长表示在此窗口时间超过阀值就限流。

上例中,我们将参数索引指定为0,所以当访问路径带上第一个参数p1时,在一秒(统计窗口时长)内访问超过一次(单机阈值)就可能发生限流,如果不带参数p1不会触发限流。

参数例外项的演示:


在前面的例子基础上,我们增加参数例外项,参数值为1,限流阈值为10,这样当访问路径上第一个参数p1的值为1时,在一秒(统计窗口时长)内访问超过10次(单机阈值)才会发生限流,如果第一个参数p1的值不是1时,限流的阈值还是1,如果不带参数p1不会触发限流,注意指定的参数类型要与方法的参数类型保持一致。

源码分析

ParamFlowSlot插槽

处理热点参数的Slot是ParamFlowSlot。

com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowSlot#entry

public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                  boolean prioritized, Object... args) throws Throwable {
    // 处理热点参数规则
    // 判断资源名是否配置了规则
    if (!ParamFlowRuleManager.hasRules(resourceWrapper.getName())) {
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
        return;
    }

    // 校验热点规则
    checkFlow(resourceWrapper, count, args);
    fireEntry(context, resourceWrapper, node, count, prioritized, args);
}

com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowSlot#checkFlow

void checkFlow(ResourceWrapper resourceWrapper, int count, Object... args) throws BlockException {
    if (args == null) {
        return;
    }
    // 判断资源名是否配置了规则
    if (!ParamFlowRuleManager.hasRules(resourceWrapper.getName())) {
        return;
    }
    // 根据资源名查询规则
    List<ParamFlowRule> rules = ParamFlowRuleManager.getRulesOfResource(resourceWrapper.getName());

    for (ParamFlowRule rule : rules) {
        // 对规则中参数的index进行处理,index可以为负数
        applyRealParamIdx(rule, args.length);

        // Initialize the parameter metrics.
        // 初始化统计参数
        ParameterMetricStorage.initParamMetricsFor(resourceWrapper, rule);

        // 校验规则
        if (!ParamFlowChecker.passCheck(resourceWrapper, rule, count, args)) {
            String triggeredParam = "";
            if (args.length > rule.getParamIdx()) {
                Object value = args[rule.getParamIdx()];
                triggeredParam = String.valueOf(value);
            }
            // 校验不通过,抛出ParamFlowException
            throw new ParamFlowException(resourceWrapper.getName(), triggeredParam, rule);
        }
    }
}

ParamFlowChecker的数据结构

热点参数限流使用的算法为令牌桶算法,首先来看一下数据结构是如何存储的:

// timeRecorder
// 记录令牌桶的最后添加时间,用于QPS限流
private final Map<ParamFlowRule, CacheMap<Object, AtomicLong>> ruleTimeCounters = new HashMap<>();
// tokenCounter
// 记录令牌桶的令牌数量,用于QPS限流
private final Map<ParamFlowRule, CacheMap<Object, AtomicLong>> ruleTokenCounter = new HashMap<>();

每个Resource对应一个ParameterMetric对象,上述CacheMap<Object, AtomicLong>的Key代表热点参数的值,Value则是对应的计数器。

所以这里数据结构的关系是这样的:

  • 一个Resource有一个ParameterMetric
  • 一个ParameterMetric统计了多个Rule所需要的限流指标数据
  • 每个Rule又可以配置多个热点参数

CacheMap的默认实现,包装了com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap,使用该类的主要原因是为了实现热点参数的LRU。

ParamFlowChecker的校验逻辑

com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowChecker#passCheck

public static boolean passCheck(ResourceWrapper resourceWrapper, /*@Valid*/ ParamFlowRule rule, /*@Valid*/ int count,
                         Object... args) {
    // 如果参数不存在直接返回
    if (args == null) {
        return true;
    }

    int paramIdx = rule.getParamIdx();
    //参数的个数小于规则的索引直接返回
    if (args.length <= paramIdx) {
        return true;
    }

    // Get parameter value.
    Object value = args[paramIdx];

    // Assign value with the result of paramFlowKey method
    if (value instanceof ParamFlowArgument) {
        value = ((ParamFlowArgument) value).paramFlowKey();
    }
    // If value is null, then pass
    // 如果参数为空,直接返回
    if (value == null) {
        return true;
    }

    if (rule.isClusterMode() && rule.getGrade() == RuleConstant.FLOW_GRADE_QPS) {
        return passClusterCheck(resourceWrapper, rule, count, value);
    }

    // 校验规则
    return passLocalCheck(resourceWrapper, rule, count, value);
}

private static boolean passLocalCheck(ResourceWrapper resourceWrapper, ParamFlowRule rule, int count,
                                      Object value) {
    try {
        // 根据参数的类型来校验
        if (Collection.class.isAssignableFrom(value.getClass())) {
            for (Object param : ((Collection)value)) {
                if (!passSingleValueCheck(resourceWrapper, rule, count, param)) {
                    return false;
                }
            }
        } else if (value.getClass().isArray()) {
            int length = Array.getLength(value);
            for (int i = 0; i < length; i++) {
                Object param = Array.get(value, i);
                // 参数一般是数组
                if (!passSingleValueCheck(resourceWrapper, rule, count, param)) {
                    return false;
                }
            }
        } else {
            return passSingleValueCheck(resourceWrapper, rule, count, value);
        }
    } catch (Throwable e) {
        RecordLog.warn("[ParamFlowChecker] Unexpected error", e);
    }

    return true;
}

static boolean passSingleValueCheck(ResourceWrapper resourceWrapper, ParamFlowRule rule, int acquireCount,
                                    Object value) {
    if (rule.getGrade() == RuleConstant.FLOW_GRADE_QPS) {
        // 热点规则的阈值类型只能配置QPS类型
        if (rule.getControlBehavior() == RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER) {
            return passThrottleLocalCheck(resourceWrapper, rule, acquireCount, value);
        } else {
            // 流控效果只能是快速失败
            return passDefaultLocalCheck(resourceWrapper, rule, acquireCount, value);
        }
    } else if (rule.getGrade() == RuleConstant.FLOW_GRADE_THREAD) {
        Set<Object> exclusionItems = rule.getParsedHotItems().keySet();
        long threadCount = getParameterMetric(resourceWrapper).getThreadCount(rule.getParamIdx(), value);
        if (exclusionItems.contains(value)) {
            int itemThreshold = rule.getParsedHotItems().get(value);
            return ++threadCount <= itemThreshold;
        }
        long threshold = (long)rule.getCount();
        return ++threadCount <= threshold;
    }

    return true;
}

com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowChecker#passDefaultLocalCheck

static boolean passDefaultLocalCheck(ResourceWrapper resourceWrapper, ParamFlowRule rule, int acquireCount,
                                     Object value) {
    /**
     * ParamFlowSlot#checkFlow中会调用ParameterMetricStorage.initParamMetricsFor()初始化统计数据
     * @see ParamFlowSlot#checkFlow(com.alibaba.csp.sentinel.slotchain.ResourceWrapper, int, java.lang.Object...)
     */
    ParameterMetric metric = getParameterMetric(resourceWrapper);
    CacheMap<Object, AtomicLong> tokenCounters = metric == null ? null : metric.getRuleTokenCounter(rule);
    CacheMap<Object, AtomicLong> timeCounters = metric == null ? null : metric.getRuleTimeCounter(rule);

    if (tokenCounters == null || timeCounters == null) {
        return true;
    }

    // Calculate max token count (threshold)
    Set<Object> exclusionItems = rule.getParsedHotItems().keySet();
    // tokenCount表示的是QPS的阈值,默认使用热点参数中配置的单机阈值
    long tokenCount = (long)rule.getCount();
    if (exclusionItems.contains(value)) {
        // 如果参数例外项中配置了,则使用参数例外项中配置的阈值
        tokenCount = rule.getParsedHotItems().get(value);
    }

    // 阈值为0,直接返回false,不通过
    if (tokenCount == 0) {
        return false;
    }

    // burstCount表示应对突发流量额外允许的数量,默认为0
    long maxCount = tokenCount + rule.getBurstCount();
    // acquireCount表示请求需要的QPS数量,默认为1
    if (acquireCount > maxCount) {
        return false;
    }

    while (true) {
        long currentTime = TimeUtil.currentTimeMillis();

        // tokenCounters用来记录参数当前还能获取到的令牌数
        // timeCounters用来记录时间段的开始时间。
        // 获取当前统计的时间段的开始时间
        AtomicLong lastAddTokenTime = timeCounters.putIfAbsent(value, new AtomicLong(currentTime));
        if (lastAddTokenTime == null) {
            // 当前时间段还没有计算就初始化令牌数
            // Token never added, just replenish the tokens and consume {@code acquireCount} immediately.
            tokenCounters.putIfAbsent(value, new AtomicLong(maxCount - acquireCount));
            return true;
        }

        // Calculate the time duration since last token was added.
        // 计算已经过去了多长时间
        long passTime = currentTime - lastAddTokenTime.get();
        // A simplified token bucket algorithm that will replenish the tokens only when statistic window has passed.
        if (passTime > rule.getDurationInSec() * 1000) {
            // 当前时间不在这个窗口内
            AtomicLong oldQps = tokenCounters.putIfAbsent(value, new AtomicLong(maxCount - acquireCount));
            if (oldQps == null) {
                // Might not be accurate here.
                lastAddTokenTime.set(currentTime);
                return true;
            } else {
                // 重新计算QPS
                long restQps = oldQps.get();
                // 每毫秒应该生成的 token = tokenCount / (rule.getDurationInSec() * 1000)
                // 再 * passTime 即等于应该补充的 token
                long toAddCount = (passTime * tokenCount) / (rule.getDurationInSec() * 1000);
                long newQps = toAddCount + restQps > maxCount ? (maxCount - acquireCount)
                    : (restQps + toAddCount - acquireCount);

                if (newQps < 0) {
                    return false;
                }
                if (oldQps.compareAndSet(restQps, newQps)) {
                    // 更新当前时间
                    lastAddTokenTime.set(currentTime);
                    return true;
                }
                Thread.yield();
            }
        } else {
            // 当前时间还在还在这个窗口内
            AtomicLong oldQps = tokenCounters.get(value);
            if (oldQps != null) {
                long oldQpsValue = oldQps.get();
                if (oldQpsValue - acquireCount >= 0) {
                    // CAS减少令牌数
                    if (oldQps.compareAndSet(oldQpsValue, oldQpsValue - acquireCount)) {
                        return true;
                    }
                } else {
                    // 令牌数不够直接返回false,限流
                    return false;
                }
            }
            Thread.yield();
        }
    }
}

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

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

相关文章

【linux】init进程的详解

文章目录 概述init进程完成从内核态向用户态的转变&#xff08;1&#xff09;一个进程先后两种状态&#xff08;2&#xff09;init进程在内核态下的工作内容&#xff08;3&#xff09;init进程在用户态下的工作内容&#xff08;4&#xff09;init进程如何从内核态跳跃到用户态 …

springboot+vue高校社团管理系统(源码+文档)

风定落花生&#xff0c;歌声逐流水&#xff0c;大家好我是风歌&#xff0c;混迹在java圈的辛苦码农。今天要和大家聊的是一款基于springboot的高校社团管理系统。项目源码以及部署相关请联系风歌&#xff0c;文末附上联系信息 。 &#x1f495;&#x1f495;作者&#xff1a;风…

Linux快速安装Erlang和RabbitMQ单机版

环境 CentOS7Xshell6XFtp6Erlang 21.3RabbitMQ 3.8.4 安装方式 同一个软件有很多种安装方式&#xff0c;在Linux系统有几种常见的软件安装方式&#xff1a; 源码编译安装&#xff1a;一般需要解压&#xff0c;然后使用make、make install等命令RPM&#xff08;RedHat Packa…

从物业管理到IT互联网精英,月薪11k的她几经辗转,终得偿所愿!

所谓“男怕入错行”&#xff0c;其实对女生来说也是一样&#xff0c;不同行业对人生的改变太大&#xff0c;想要找到满意的工作&#xff0c;就要不断去尝试。 西安的学员小文&#xff0c;大学毕业后从事的本专业&#xff08;物业管理&#xff09;工作&#xff0c;但不是很喜欢…

条款1:理解模板类型推导

现代C中被广泛应用的auto是建立在模板类型推导的基础上的。而当模板类型推导规则应用于auto环境时&#xff0c;有时不如应用于模板中那么直观。由于这个原因&#xff0c;真正理解auto基于的模板类型推导的方方面面非常重要。 在c中声明一个模板函数的伪代码基本如下&#xff1…

JVM 直接内存(Direct Memory)

直接内存概述 不是虚拟机运行时数据区的一部分&#xff0c;也不是<<Java 虚拟机规范>> 中定义的内存区域直接内存是Java 堆外的、直接向系统申请的内存区间来源于 NIO&#xff0c;通过存在堆中的 DirectByteBuffer 操作 Native 内存访问直接内存的速度会优于 Java…

智慧停车APP系统开发 停车取车缴费智能搞定

生活水平的提高让车辆成为很多人出行主要的代步工具&#xff0c;很多家庭现在已经不止拥有一辆汽车了&#xff0c;所以城市建设中关于停车场的规划管理也是很重要的部分。不过现在出门很多时候还是会碰到找不到停车场&#xff0c;没有车位、收费不合理、乱收费等现象。智慧停车…

调试和优化遗留代码

1. 认识调试器 1.1 含义 一个能让程序运行、暂停、然后对进程的状态进行观测甚至修改的工具。 在日常的开发当中使用非常广泛。(PHP开发者以及前端开发者除外) 1.2 常见的调试器 Go语言的自带的 delve 简写为 “dlv”GNU组织提供的 gdbPHP Xdebug前端浏览器debug 调试 1.3…

DNS投毒

定义 DNS缓存投毒又称DNS欺骗,是一种通过查找并利用DNS系统中存在的漏洞,将流量从合法服务器引导至虚假服务器上的攻击方式。与一般的钓鱼攻击采用非法URL不同的是,这种攻击使用的是合法URL地址。 DNS缓存中毒如何工作 在实际的DNS解析过程中,用户请求某个网站,浏览器首…

English Learning - L3 作业打卡 Lesson1 Day6 2023.5.10 周三

English Learning - L3 作业打卡 Lesson1 Day6 2023.5.10 周三 引言&#x1f349;句1: The expression was first used in America at the beginning of the twentieth century .成分划分弱读连读爆破语调 &#x1f349;句2: It probably comes from the fact that many babies…

分享一组有意思的按钮设计

先上效果图&#xff1a; 一共16个&#xff0c;每个都有自己不同的样式和效果&#xff0c;可以用在自己的项目中&#xff0c;提升客户体验~ 再上代码&#xff1a; <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8">&l…

非Autosar软件手动集成XCP协议栈

文章目录 前言XCP发送XCP接收Xcp初始化Xcp主函数Xcp Event总结前言 最近项目由于各种原因没有直接采用基于Autosar工具生成的代码。只使用了NXP的MCAL。Demo需求实现XCP功能。本文记录手动集成XCP协议的过程,基于CAN总线。集成的前提过程是已有了XCP的静态代码和配置代码。可…

数据结构pta第一天: 堆中的路径 【用数组模拟堆的操作】

这道题其实就涉及两个堆操作&#xff0c; 一个是插入&#xff0c;一个是通过从底到根的遍历 堆的插入&#xff1a;其实就是从下面往上&#xff0c;一个一个比较&#xff0c;&#xff08;因为上面的节点里的值越来越小&#xff0c;如果插入的值比上面的节点小那么就要向上推&am…

基于AT89C51单片机的电子时钟设计与仿真

点击链接获取Keil源码与Project Backups仿真图&#xff1a; https://download.csdn.net/download/qq_64505944/87779867?spm1001.2014.3001.5503 源码获取 主要内容&#xff1a; 使用DS1302芯片作为计时设备&#xff0c;用6个7段LED数码管或者LCD162作为显示设备&#xff0c…

【软考七】面向对象技术--UML、设计模式(分数重,刷题刷题)

建议UML和设计模式去听听课&#xff0c;内容多&#xff0c;还需要记。这一部分内容较多&#xff0c;下半年的考生可以慢慢看&#xff0c;上半年的就去刷题吧。 该博客不适合学习UML和设计模式&#xff0c;只适合考试。要学的不要在这浪费时间&#xff0c;切记切记 在5月13号忽然…

MD-MTSP:孔雀优化算法POA求解多仓库多旅行商问题(提供MATLAB代码,可以修改旅行商个数及起点)

一、多仓库多旅行商问题 多旅行商问题&#xff08;Multiple Traveling Salesman Problem, MTSP&#xff09;是著名的旅行商问题&#xff08;Traveling Salesman Problem, TSP&#xff09;的延伸&#xff0c;多旅行商问题定义为&#xff1a;给定一个&#x1d45b;座城市的城市集…

Midjourney8种风格极其使用场景

目录 ​编辑 引言 等距动画 场景 分析性绘图 场景 着色书 场景 信息图画 场景 双重曝光 场景 图示性绘画 场景 二维插图 场景 图解式画像 场景 总结&#xff1a; 八种风格箴言&#xff1a; 引言 我相信大家都或多或少玩过Midjourney&#xff0c;但是要形…

linux系统sed编辑器

sed编辑器 sed编辑器sed基础语法sed查询sed删除sed 替换sed 插入 sed编辑器 sed是文本处理工具&#xff0c;依赖于正则表达式&#xff0c;可以读取文本内容&#xff0c;工具指定条件对数据进行添加、删除、替换等操作&#xff0c;被广泛应用于shell脚本&#xff0c;以完成自动…

我所了解的老板

我所了解的老板 修心篇以名命物 扰我心良心的本来如此 是我心不要全挑剔别人 既是我因 受我之果长久之爱 美女养眼 贤妻养心 调研篇深度思考确定趋势确定时机预测筹划生存资料 做事篇做短视频心态&#xff0c;也是创业的心态做销售冠军的心态&#xff0c;也是创业的心态 修心篇…

【Python--定时任务的四种方法】

定时任务 前言while True&#xff1a;sleep()优点缺点 threading.Timer定时器多线程执行优点缺点 Timeloop库执行定时任务调度模块schedule优缺点 前言 当每隔一段时间就要执行一段程序&#xff0c;或者往复循环执行某一个任务&#xff0c;这就需要使用定时任务来执行程序。应…