限流算法-redis实现与java实现

主流算法

固定窗口(Fixed Window)

这是最直观的逻辑,就像限制每分钟只能有 60 个人通过检票口。

核心:

  • 划定窗口:以每分钟或者每秒为一个窗口
  • 计算:在这个窗口内,每接收一个请求,计数器就+1
  • 判断:如果计数器超过60,就拒绝请求
  • 重置:当始时间从 00:00 走到 00:01 则 计数器清零,表示新的窗口开始。

问题:

  • 如果在上一个窗口的最后一秒进来60个请求及当前窗口的第一秒也进来60个请求,那么就是两秒钟接收了120个请求。并且是没有超时的。
  • 这就是固定窗口的缺点,可能短暂的两秒突发请求就会把后端服务打挂

滑动窗口 (Sliding Window)

固定窗口算法的"进化版"。

它的核心目的就是解决固定窗口临界值的问题。

核心:

  • 一个可移动的窗口,根据传入的时间来确定窗口的范围。
  • 如果你请求的时间是 12:00:30 那么窗口就是 11:59:3012:00:30
  • 系统只统计当前时刻往前推 1 分钟这个区间内的请求总数。
  • 它是如何消除临界突发的?
    • 假设限制是 100/分。12:00:59 来了 100 个请求。12:01:00 又来了 1 个请求。固定窗口 会重置,允许通过。滑动窗口 会看 12:00:0012:01:00 这区间。它发现里面已经有 100 个请求了(刚才那波),所以第 101 个请求会被拒绝。完美解决!

使用场景

漏桶算法 (Leaky Bucket)

想象一个底部有漏洞的水桶 🪣。不管上面的水(请求)倒进来的速度有多快,水都会以恒定的速度流出。这种算法非常适合用于平滑流量。

核心:

  • 固定的处理速度,强制要求削峰填谷
  • 超出桶容量的请求,会直接拒绝, 强行平滑突发流量
  • 绝对不允许突发流量。就算桶里有水,他也是以固定流速处理

适用场景:

  • 需要严格保护下游服务,防止其不稳定的场景(比如写数据库,或者调用一个极其脆弱的第三方API)

令牌桶算法 (Token Bucket)

系统以恒定速度像桶里发放"令牌" 🪙,请求只有拿到令牌才能通过。这个算法最有趣的地方在于它允许一定程度的"突发流量"。

核心:

  • 系统以恒定的速度往桶里放令牌
  • 桶有容量上限,满了之后新的令牌就丢掉
  • 每个请求必须带走桶里的一个令牌
  • 如果桶积攒了10个令牌,瞬间来了10个请求,它们可以同时拿走令牌

适用场景:

  • 大部分场景都适用。因为它即限制了平均速度,又允许用户偶尔快一下,也就是允许突发流量

总结一张图

维度 1. 固定窗口 (Fixed Window) 2. 滑动窗口 (Sliding Window - ZSET) 3. 漏桶 (Leaky Bucket) 4. 令牌桶 (Token Bucket)
核心逻辑 切豆腐 。 按时间段(如1分钟)一刀切,计数器清零重置。 移动相框 。 记录每个请求的时间点,实时统计"当前时刻前N秒"内的总数。 漏斗 。 不管进水(请求)多快,出水(处理)永远是匀速的。 存钱罐 。 按固定速度发钱(令牌),有钱才能消费(处理请求),钱能攒。
Redis结构 String= (INCR + EXPIRE) ZSET (存 UUID + Timestamp) String (存水位 + 上次时间) String (存令牌数 + 上次时间)
内存开销 极低 (O(1), 仅存1个计数) 💥 极高 (O(N), 存N个请求记录) (O(1), 存2个数字) (O(1), 存2个数字)
流量特征 锯齿状 临界点允许双倍流量突发。 平滑 极其精准,无临界突发。 直线 强行削平波峰,绝对匀速。 平稳+突发 限制平均速率,但允许短时间突发。
优点 实现最简单,性能最高。 精度最高,符合直觉。 保护能力最强,防止下游崩溃。 用户体验最好,能应对瞬间高并发。
缺点 有临界突发隐患(防不住跨窗口攻击)。 随着QPS升高,内存和CPU消耗爆炸。 无法应对突发流量(严格模式下),用户需排队。 参数配置稍微复杂一点点。
适用场景 粗粒度限制 防爬虫、防恶意刷接口、短信频次限制。 低频高精限制 1分钟限5次短信、1小时限5次登录失败。 保护脆弱下游 调用银行接口、写老旧数据库、秒杀削峰。 API网关通用限流 绝大多数互联网高并发业务。
推荐指数 ⭐⭐ ⭐ (仅限低频) ⭐⭐⭐ (特定场景) ⭐⭐⭐⭐⭐ (通用推荐)

实现方式

Redis+lua脚本实现

固定窗口
java 复制代码
/**
 * -- KEYS[1]: 限流的 Key,
 * -- ARGV[1]: 窗口大小(秒),例如 60
 * -- ARGV[2]: 限制次数,例如 10
 */
private final static String FIXED_WINDOW_SCRIPT =
"local key = KEYS[1] " +
"local window = tonumber(ARGV[1]) " +
"local limit = tonumber(ARGV[2]) " +
"local current = redis.call('INCR', key) " +
"if current == 1 then " +       // 窗口内 第一次访问时 添加过期时间
" redis.call('EXPIRE', key, window) " +
"end  " +
"if current > limit then " +    // 窗口内访问次数超过限制则返回0 反之返回1
" return false " +
"else " +
" return true " +
"end";
滑动窗口(ZSET)
java 复制代码
/**
 * -- KEYS[1]: 限流 Key (例如 limit:sliding:user_1)
 * -- ARGV[1]: 窗口时间 (毫秒),例如 60000 (1分钟)
 * -- ARGV[2]: 限流阈值,例如 100
 * -- ARGV[3]: 本次请求的唯一ID (防止高并发下member冲突)
 */
private final static String SLIDING_WINDOW_SCRIPT =
"local key = KEYS[1]  " +
"local timeWindowSecond = tonumber(ARGV[1])  " +
"local maxCount = tonumber(ARGV[2])  " +
"local uuid = ARGV[3]  " +
// "-- 1.获取获取时间戳信息 time_info是一个数组 [0] 是unix时间 10位时间戳 [1] 是微秒 " +
"local time_info = redis.call('TIME')  " +
"local now = tonumber(time_info[1]) * 1000 + math.floor(tonumber(time_info[2]) / 1000)  " +
// "-- 2.计算以当前时间计算最开始的窗口的时间 " +
"local startWindowsTime = now - (timeWindowSecond * 1000)  " +
// "redis.log(redis.LOG_NOTICE, \"Debug: startWindowsTime=\" .. tostring(startWindowsTime)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: now=\" .. tostring(now)) " +
// "-- 3.删除0-窗口开始时间的所有元素 " +
"local remove = redis.call('ZREMRANGEBYSCORE', key, 0 , '(' .. startWindowsTime)  " +
// "redis.log(redis.LOG_NOTICE, \"Debug: remove=\" .. tostring(remove)) " +
// "-- 4.计算元素内的请求数量 " +
"local count = redis.call('ZCARD', key);  " +
// "redis.log(redis.LOG_NOTICE, \"Debug: count=\" .. tostring(count)) " +
// "-- 5.判断窗口内是否以超限 " +
"if count < maxCount then  " +
// " -- 5.1未超限:记录本次请求 " +
" redis.call('ZADD', key, now , uuid)  " +
// " -- 5.2 设置超时时间 窗口大小 +5 秒冗余 " +
" redis.call('EXPIRE',key, timeWindowSecond + 5)  " +
" return true  " +
"end " +
// "-- 6. 超限:拒绝访问 " +
"return false";
漏桶
java 复制代码
/**
 * -- KEYS[1]: 漏桶的 Key (上次漏水时间的水位)
 * -- KEYS[2]: 上次漏水时间的 Key
 * -- ARGV[1]: 漏水速率 (leaks/sec),例如 2
 * -- ARGV[2]: 桶容量 (capacity),例如 10
 * -- ARGV[3]: 本次请求加水量,通常 1
 */
private final static String LEAKY_BUCKET_SCRIPT =
        "local bucket_key = KEYS[1] " +
        "local timestamp_key = KEYS[2] " +
        "local rate = tonumber(ARGV[1]) " +
        "local capacity = tonumber(ARGV[2]) " +
        "local water_add = tonumber(ARGV[3]) " +
        "local rate_per_ms = rate / 1000 " +
        // 获取获取时间戳信息 time_info是一个数组 [0] 是unix时间 10位时间戳 [1] 是微秒
        "local time_info = redis.call('TIME') " +
        "local now = tonumber(time_info[1]) * 1000 + math.floor(tonumber(time_info[2]) / 1000) " +
        // 1. 获取上次水位信息 (默认为 0)
        "local last_water = tonumber(redis.call('GET', bucket_key) or 0) " +
        "local last_time = tonumber(redis.call('GET', timestamp_key) or now) " +
        // 2.计算上次漏水时间与当前时间的间隔 单位 毫秒
        "local interval = math.max(0, (now - last_time)) " +
        // 3.计算流水速率 * 间隔时间 判断间隔时间内桶内流出
        "local interval_out_water = interval * rate_per_ms " +
        // 4.计算当前水位
        "local current_water = math.max(0, last_water - interval_out_water) " +
        // "redis.log(redis.LOG_NOTICE, \"Debug: interval=\" .. tostring(interval)) " +
        // "redis.log(redis.LOG_NOTICE, \"Debug: last_water=\" .. tostring(last_water)) " +
        // "redis.log(redis.LOG_NOTICE, \"Debug: 当前水位=\" .. tostring(current_water)) " +
        // 5.当前水位+本次入水量是否小于桶内容量,小于则入桶,并记录当前水位及漏水时间
        "if current_water + water_add > capacity then  " +
        " return false " +
        "end " +
        "current_water = current_water + water_add " +
        "redis.call('SET', bucket_key, current_water) " +
        "redis.call('SET', timestamp_key, now) " +
        // 6.计算满桶水流完所需秒数 * 2 是为了防止临界点的问题
        "local ttl = math.ceil((capacity / rate) * 2) " +
        "redis.call('EXPIRE', bucket_key, ttl) " +
        "redis.call('EXPIRE', timestamp_key, ttl) " +
        "return true";
令牌桶
java 复制代码
/**
* -- KEYS[1]: 令牌桶的 Key (存当前令牌数)
* -- KEYS[2]: 上次刷新时间的 Key (存时间戳)
* -- ARGV[1]: 生成速率 (rate),例如 2 (个/秒)
* -- ARGV[2]: 桶容量 (capacity),例如 10
* -- ARGV[3]: 本次请求消耗令牌数,通常是 1
*/
private final static String TOKEN_BUCKET_SCRIPT = 
"local token_key = KEYS[1] " +
"local timestamp_key = KEYS[2] " +
"local rate = tonumber(ARGV[1]) " +
"local capacity = tonumber(ARGV[2]) " +
"local reduce_token = tonumber(ARGV[3]) " +
// 获取获取时间戳信息 time_info是一个数组 [0] 是unix时间 10位时间戳 [1] 是微秒
"local time_info = redis.call('TIME') " +
"local now = tonumber(time_info[1]) * 1000 + math.floor(tonumber(time_info[2]) / 1000) " +
// "-- 1. 获取桶的"每毫秒"生成速率 (为了计算方便) " +
"local rate_per_ms = rate / 1000 " +
// "-- 2. 获取上次剩余令牌数 (如果没有,默认满桶) " +
"local last_tokens = tonumber(redis.call('GET', token_key) or capacity) " +
// "-- 3. 获取上次刷新时间 (如果没有,默认就是现在) " +
"local last_time = tonumber(redis.call('GET', timestamp_key) or now) " +
// "-- 4. 计算时间差 " +
"local interval = math.max(0, now - last_time) " +
// "-- 5.计算这段时间内生成的令牌 不能大于桶的容量 " +
"local current_tokens = math.min(capacity, last_tokens + (interval * rate_per_ms)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: interval=\" .. tostring(interval)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: last_tokens=\" .. tostring(last_tokens)) " +
// "redis.log(redis.LOG_NOTICE, \"Debug: current_tokens=\" .. tostring(current_tokens)) " +
// "-- 6.令牌不够直接拒绝 " +
"if current_tokens < reduce_token then  " +
" return false " +
"end " +
// "-- 7.消耗令牌 " +
"current_tokens = current_tokens - reduce_token " +
// "redis.log(redis.LOG_NOTICE, \"Debug: reduce_after_token=\" .. tostring(current_tokens)) " +
// "-- 8.更新上次的刷新时间为当前时间及上次剩余令牌为当前令牌数 " +
"redis.call('SET', token_key, current_tokens) " +
"redis.call('SET', timestamp_key, now) " +
// "-- 9.防止零界点问题 设置填满桶时间的2倍 " +
"local ttl = math.ceil(capacity / rate * 2) " +
"redis.call('EXPIRE', token_key, ttl) " +
"redis.call('EXPIRE', timestamp_key, ttl) " +
"return true ";

原生Java实现

固定窗口
java 复制代码
package com.mfyuan.rate.jvm;

import com.mfyuan.rate.RateLimitStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author yuanmengfan(mf.yuan @ qq.com) on 2026/1/19 21:32
 */
@RequiredArgsConstructor
@Slf4j
public class JvmFixedWindowRateLimitStrategy implements RateLimitStrategy {

    // 窗口大小 单位秒
    private final Long timeWindowSecond;

    // 窗口内最大访问次数
    private final Long maxCount;

    private final ConcurrentHashMap<String, Lock> CACHE = new ConcurrentHashMap<>();

    @Override
    public boolean tryAcquire(String ident) {
        Lock lock = CACHE.get(ident);
        if (lock == null) {
            synchronized (CACHE) {
                long now = System.currentTimeMillis();
                Lock finalLock = CACHE.computeIfAbsent(ident, k -> new Lock());
                lock = finalLock;

                // 利用锁 CACHE 的时机 删除过期的锁
                CACHE.entrySet()
                        .stream()
                        // 不删除 当前新建出来的lock
                        .filter(entry -> !Objects.equals(entry.getValue(), finalLock))
                        // 过期去已过期的锁 给一个3秒的冗余
                        .filter(entry -> entry.getValue().lastTime < now + 3000)
                        .map(Map.Entry::getKey)
                        .forEach(CACHE::remove);
            }
        }
        synchronized (lock) {
            long now = System.currentTimeMillis();
            if (lock.lastTime == null || lock.lastTime < now) {
                lock.lastTime = now + (timeWindowSecond * 1000);
                lock.count = 0L;
            }
            boolean flag = false;
            if (lock.count < maxCount) {
                flag = true;
            }
            lock.count++;
            log.info("lock hashcode:{} lastTime:{},count:{},flag:{}", lock, lock.lastTime, lock.count, flag);
            return flag;
        }
    }

    static class Lock {
        private Long lastTime;
        private Long count;
    }
}
滑动窗口
java 复制代码
、package com.mfyuan.rate.jvm;

import com.mfyuan.rate.RateLimitStrategy;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

/**
 * @author yuanmengfan(mf.yuan @ qq.com) on 2026/1/21 17:19
 */
@RequiredArgsConstructor
@Slf4j
public class JvmSlidingWindowRateLimitStrategy implements RateLimitStrategy {

    private final ConcurrentHashMap<String, Lock> CACHE = new ConcurrentHashMap<>();

    // 窗口大小 单位秒
    private final Long timeWindowSecond;

    // 窗口内最大访问次数
    private final Long maxCount;

    @Override
    public boolean tryAcquire(String ident) {
        Lock lock = CACHE.get(ident);
        if (lock == null) {
            synchronized (CACHE) {
                long now = System.currentTimeMillis();

                Lock finalLock = CACHE.computeIfAbsent(ident, k -> new Lock());

                lock = finalLock;

                // 利用锁 CACHE 的时机 删除过期的锁
                CACHE.entrySet()
                        .stream()
                        // 不删除 当前新建出来的lock
                        .filter(entry!Objects.equals(entry.getValue(), finalLock))
                        // 删除过期的
                        .filter(entry ->
                                // 窗口内部已经没有请求了,则认为是过期的
                                CollectionUtils.isEmpty(entry.getValue().requestTreeSet)
                                        // 当前时间大于最后请求到窗口内的时间戳 为窗口时间的2倍时则认为是过期的
                                        || (now - entry.getValue().requestTreeSet.last().timestamp > timeWindowSecond * 1000 * 2))
                        .map(Map.Entry::getKey)
                        .forEach(CACHE::remove);
            }
        }
        synchronized (lock) {
            long now = System.currentTimeMillis();

            if (lock.requestTreeSet == null) {
                lock.requestTreeSet = new TreeSet<>();
            } else {
                long startTime = now - timeWindowSecond * 1000;

                Set<Lock.Request> removeSet = lock.requestTreeSet
                        .stream()
                        .filter(request -> request.timestamp < startTime)
                        .collect(Collectors.toSet());
                lock.requestTreeSet.removeAll(removeSet);
                log.info("now:{},startTime:{},removeSize:{},currentSize:{},removeProfile:{}"
                        , now, startTime
                        , removeSet.size()
                        , lock.requestTreeSet.size()
                        , removeSet.stream()
                                .map(Lock.Request::getTimestamp)
                                .map(Objects::toString)
                                .collect(Collectors.joining(",")));
            }

            if (lock.requestTreeSet.size() < maxCount) {
                lock.requestTreeSet.add(new Lock.Request(now, UUID.randomUUID().toString()));
                return true;
            }
            return false;
        }
    }

    static class Lock {
        TreeSet<Request> requestTreeSet = null;

        @AllArgsConstructor
        @Getter
        static class Request implements Comparable<Request> {
            private long timestamp;

            private String uuid;

            @Override
            public int compareTo(Request o) {
                return Long.compare(timestamp, o.timestamp);
            }
        }
    }
}
漏桶
java 复制代码
package com.mfyuan.rate.jvm;

import com.mfyuan.rate.RateLimitStrategy;
import lombok.RequiredArgsConstructor;

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author yuanmengfan(mf.yuan @ qq.com) on 2026/1/19 22:01
 */
@RequiredArgsConstructor
public class JvmLeakyBucketRateLimitStrategy implements RateLimitStrategy {

    // 每秒流出速率
    private final Long rate;
    // 桶大小
    private final Long capacity;

    private final ConcurrentHashMap<String, Lock> CACHE = new ConcurrentHashMap<>();

    @Override
    public boolean tryAcquire(String ident) {
        Lock lock = CACHE.get(ident);
        if (lock == null) {
            synchronized (CACHE) {
                long now = System.currentTimeMillis();
                Lock finalLock = CACHE.computeIfAbsent(ident, k -> new Lock());

                lock = finalLock;

                // 利用锁 CACHE 的时机 删除过期的锁
                CACHE.entrySet()
                        .stream()
                        // 不删除 当前新建出来的lock
                        .filter(entry -> !Objects.equals(entry.getValue(), finalLock))
                        // 过期去已过期的锁 capacity / rate 为桶里面的水留空的时间 冗余个二倍
                        .filter(entry -> entry.getValue().lastTime < now + (capacity / rate * 2))
                        .map(Map.Entry::getKey)
                        .forEach(CACHE::remove);
            }
        }
        synchronized (lock) {
            long now = System.currentTimeMillis();
            if (lock.lastTime == null) {
                lock.lastTime = now;
                lock.lastWater = 0D;
            } else {
                // 计算出当前的水位
                long interval = now - lock.lastTime;
                double flowOutWater = rate / 1000.0 * interval;
                lock.lastWater = Math.max(0D, lock.lastWater - flowOutWater);
                lock.lastTime = now;
            }

            if (lock.lastWater + 1 <= capacity) {
                lock.lastWater = Math.min(capacity, lock.lastWater + 1);
                return true;
            }
            return false;
        }
    }

    static class Lock {
        private Long lastTime;
        // 因为每次并不是完全流出一个 用 double 防止精度丢失
        private Double lastWater;
    }
}
令牌桶
java 复制代码
package com.mfyuan.rate.jvm;

import com.mfyuan.rate.RateLimitStrategy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author yuanmengfan(mf.yuan @ qq.com) on 2026/1/20 10:35
 */
@RequiredArgsConstructor
@Slf4j
public class JvmTokenBucketRateLimitStrategy implements RateLimitStrategy {

    // 每秒生成令牌的数量
    private final Long rate;
    // 桶大小
    private final Long capacity;

    private final ConcurrentHashMap<String, Lock> CACHE = new ConcurrentHashMap<>();

    @Override
    public boolean tryAcquire(String ident) {
        long now = System.currentTimeMillis();
        Lock lock = CACHE.get(ident);
        if (lock == null) {
            synchronized (CACHE) {
                Lock finalLock = CACHE.computeIfAbsent(ident, k -> new Lock());

                lock = finalLock;

                // 利用锁 CACHE 的时机 删除过期的锁
                CACHE.entrySet()
                        .stream()
                        // 不删除 当前新建出来的lock
                        .filter(entry -> !Objects.equals(entry.getValue(), finalLock))
                        // 过期去已过期的锁 capacity / rate 为桶里面的水留空的时间 冗余个二倍
                        .filter(entry -> entry.getValue().lastTime < now + (capacity / rate * 2))
                        .map(Map.Entry::getKey)
                        .forEach(CACHE::remove);
            }
        }
        synchronized (lock) {
            log.info("lock hashcode:{} lastTime:{},lastTokens:{}", lock, lock.lastTime, lock.lastTokens);
            if (lock.lastTime == null) {
                lock.lastTime = now;
                lock.lastTokens = capacity * 1.0;
            } else {
                // 计算出当前的水位
                long interval = now - lock.lastTime;
                double flowInTokens = rate / 1000.0 * interval;
                lock.lastTokens = Math.min(capacity, lock.lastTokens + flowInTokens);
                lock.lastTime = now;
            }

            if (lock.lastTokens > 1) {
                lock.lastTokens = Math.max(0, lock.lastTokens - 1);
                return true;
            }
            return false;
        }
    }

    static class Lock {
        private Long lastTime;

        // 因为每次并不是完全流出一个 用 double 防止精度丢失
        private Double lastTokens;
    }
}

配置建议

场景 推荐算法 Rate/ (速率) Capacity** /MaxCount(容量)** 备注
高并发读接口 (首页/商品详情) 令牌桶 压测极限的 80% Rate 的 2 倍 允许突发,体验优先
高并发写接口 (下单/抢购) 令牌桶 数据库TPS的 80% Rate 的 1.0 ~ 1.2 倍 写操作要谨慎突发
调用第三方API (支付/短信) 漏桶 第三方限制值的 90% Rate × 1秒 宁可排队,不能报错
防恶意刷单 (验证码/登录) 固定窗口 N/A 正常频率的 5-10 倍 窗口通常设为 60s+
低频高精限制 (短信频次/密码试错) 滑动窗口 N/A 业务严格规定的次数 精度最高 ,无临界突发。 窗口通常设为 60s 不能过大,(不要限制QPS超过100的 Redis 内存会瞬间爆炸)
相关推荐
蒟蒻的贤2 小时前
两数之和。
算法
lixin5565562 小时前
基于迁移学习的图像风格增强器
java·人工智能·pytorch·python·深度学习·语言模型
面汤放盐2 小时前
企业权限--系统性方案探究
java·开发语言
wen__xvn2 小时前
代码随想录算法训练营DAY27第八章 贪心算法 part01
算法·贪心算法
what丶k2 小时前
深度解析Redis LRU与LFU算法:区别、实现与选型
java·redis·后端·缓存
悟能不能悟2 小时前
java Date转换为string
java·开发语言
菜宾2 小时前
java-redis面试题
java·开发语言·redis
We་ct2 小时前
LeetCode 125. 验证回文串:双指针解法全解析与优化
前端·算法·leetcode·typescript
客卿1232 小时前
力扣20-有效括号(多家面试题)
算法·leetcode·职场和发展