惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Palo Alto Networks Blog
Recent Commits to openclaw:main
Recent Commits to openclaw:main
C
CERT Recently Published Vulnerability Notes
C
Cybersecurity and Infrastructure Security Agency CISA
S
Schneier on Security
S
Securelist
酷 壳 – CoolShell
酷 壳 – CoolShell
C
CXSECURITY Database RSS Feed - CXSecurity.com
Cyberwarzone
Cyberwarzone
Apple Machine Learning Research
Apple Machine Learning Research
S
SegmentFault 最新的问题
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
GbyAI
GbyAI
Security Latest
Security Latest
Last Week in AI
Last Week in AI
Microsoft Security Blog
Microsoft Security Blog
云风的 BLOG
云风的 BLOG
Recorded Future
Recorded Future
Webroot Blog
Webroot Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
TaoSecurity Blog
TaoSecurity Blog
C
Cisco Blogs
博客园 - 【当耐特】
Blog — PlanetScale
Blog — PlanetScale
Hugging Face - Blog
Hugging Face - Blog
B
Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Attack and Defense Labs
Attack and Defense Labs
The Last Watchdog
The Last Watchdog
U
Unit 42
阮一峰的网络日志
阮一峰的网络日志
Project Zero
Project Zero
WordPress大学
WordPress大学
L
LINUX DO - 最新话题
F
Fortinet All Blogs
L
LINUX DO - 热门话题
PCI Perspectives
PCI Perspectives
Simon Willison's Weblog
Simon Willison's Weblog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
MongoDB | Blog
MongoDB | Blog
Latest news
Latest news
P
Proofpoint News Feed
T
Threat Research - Cisco Blogs
The Hacker News
The Hacker News
爱范儿
爱范儿
O
OpenAI News
J
Java Code Geeks
T
The Exploit Database - CXSecurity.com
H
Hackread – Cybersecurity News, Data Breaches, AI and More

元视角

.NET 生态下的 Agent 框架选型:从 ReAct 到原生推理 - 元视角 从「能用」到「好用」:LLM 流式响应实现方式的探索之路 - 元视角 当我用 2000 条聊天记录,让 AI 为我画一幅自画像 - 元视角 基于 Supabase 的 AI 应用开发探索 - 元视角 微博 × MCP:社交媒体新玩法解锁 - 元视角 四点钟海棠花未眠 - 元视角 Semantic Kernel × MCP:智能体的上下文增强探索 - 元视角 基于 K-Means 聚类分析实现人脸照片的快速分类 - 元视角 容器技术驱动下的代码沙箱实践与思考 - 元视角 温故而知新:后端通用查询方案的再思考 - 元视角 浅议 CancellationToken 在前后端协同取消场景中的应用 - 元视角 Semantic Kernel 视角下的 Text2SQL 实践与思考 - 元视角 关于 ChatGPT 的流式传输,你需要知道的一切 - 元视角 RAG 的是与非、Rewrite 和 Rerank - 元视角 使用 EFCore 和 PostgreSQL 实现向量存储及检索 - 元视角 基于 LLaMA 和 LangChain 实践本地 AI 知识库 - 元视角 使用 llama.cpp 在本地部署 AI 大模型的一次尝试 - 元视角 如何为 Git 配置多个 SSH Key - 元视角 C# 使用 LibUsbDotNet 实现 USB 设备检测 - 元视角 基于 C# 实现样式与数据分离的打印方案 - 元视角 基于 SVG 的图形交互方案实践 - 元视角 前端视频播放技术概览 - 元视角 温故而知新,再话 Python 动态导入 - 元视角 后 GPT 时代,NLP 不存在了? - 元视角 视频是不能 P 的系列:使用 Milvus 实现海量人脸快速检索 - 元视角 GDI+下字体大小自适应方案初探 - 元视角 小爱音箱集成 ChatGPT 的不完全教程 - 元视角 程序员视角下的三体世界随想 - 元视角 关于 Docker 容器配置信息的渐进式思考 - 元视角 在 Docker 容器内集成 Crontab 定时任务 - 元视角 为你的服务器集成 LDAP 认证 - 元视角 似花还似非花 - 元视角 视频是不能 P 的系列:使用 Dlib 实现人脸识别 - 元视角 浅议分布式链路追踪与日志的整合 - 元视角 关于 Git 大文件上传这件小事 - 元视角 .NET 进程内队列 Channel 的入门与应用 - 元视角 使用 Fody 实现 .NET 的静态编织 - 元视角 .NET Core + ELK 搭建可视化日志分析平台(下) - 元视角 聊一聊前端图片懒加载背后的故事 - 元视角 支持外部链接跳转的 Vue Router 扩展实现 - 元视角 视频是不能 P 的系列:OpenCV 和 Dlib 实现表情包 - 元视角 不得不说的 ASP.NET Core 集成测试 - 元视角 再议 DDD 视角下的 EFCore 与 领域事件 - 元视角 Vue.js 前端项目容器化部署实践极简教程 - 元视角 再见,人间四月天 - 元视角 Python 图像风格化迁移助力画家梦想 - 元视角 利用 ASP.NET Core 中的标头传播实现分布式链路追踪 - 元视角 利用 gRPC 实现文件的上传与下载 - 元视角 七种武器:延迟队列的原理和实现总结 - 元视角 gRPC 流式传输极简入门指南 - 元视角 Envoy 集成 Jaeger 实现分布式链路追踪 - 元视角 浅议非典型 Web 应用场景下的身份认证 - 元视角 gRPC 借助 Any 类型实现接口的泛化调用 - 元视角 分布式丛林探险系列之 Redis 集群模式 - 元视角 分布式丛林探险系列之 Redis 主从复制模式 - 元视角 通过 Python 预测 2021 年双十一交易额 - 元视角 gRPC 搭配 Swagger 实现微服务文档化 - 元视角 SSL/TLS 加密传输与数字证书的前世今生 - 元视角 使用 Python 自动识别防疫健康码 - 元视角 你不可不知的容器编排进阶技巧 - 元视角 ASP.NET Core 搭载 Envoy 实现 gRPC 服务代理 - 元视角 再话 AOP,从简化缓存操作说起 - 元视角 ASP.NET Core 搭载 Envoy 实现微服务身份认证(JWT) - 元视角 ASP.NET Core 搭载 Envoy 实现微服务的监控预警 - 元视角 ASP.NET Core 搭载 Envoy 实现微服务的反向代理 - 元视角 ASP.NET Core gRPC 打通前端世界的尝试 - 元视角 EFCore 实体命名约定库:EFCore.NamingConventions - 元视角 ASP.NET Core gRPC 集成 Polly 实现优雅重试 - 元视角 ASP.NET Core gRPC 健康检查的探索与实现 - 元视角 ASP.NET Core gRPC 拦截器的使用技巧分享 - 元视角 SnowNLP 使用自定义语料进行模型训练 - 元视角 使用 HttpMessageHandler 实现 HttpClient 请求管道自定义 - 元视角 ABP vNext 的实体与服务扩展技巧分享 - 元视角 ABP vNext 对接 Ant Design Vue 实现分页查询 - 元视角 源代码探案系列之 .NET Core 跨域中间件 CORS - 元视角 源代码探案系列之 .NET Core 并发限制中间件 ConcurrencyLimiter - 元视角 通过 EmbededFileProvider 实现 Blazor 的静态文件访问 - 元视角 低代码,想说爱你不容易 - 元视角 记一次失败的 ThoughtWorks 面试经历 - 元视角 从 C# 1.0 到 C# 9.0,历代 C# 语言特性一览 - 元视角 通过 Python 分析 2020 年全年微博热搜数据 - 元视角 基于 Python 和 Selenium 实现 CSDN 一键三连自动化 - 元视角 使用多线程为你的 Python 爬虫提速的 N 种姿势,你会几种? - 元视角 实现网页长截图的常见思路总结 - 元视角 温故而知新,由 ADO.NET 与 Dapper 所联想到的 - 元视角 视频是不能 P 的系列:OpenCV 人脸检测 - 元视角 作为技术宅的我,是这样追鬼滅の刃的 - 元视角 使用 Python 抽取《半泽直树》原著小说人物关系 - 元视角 厉害了!打工人用 Python 分析西安市职位信息 - 元视角 使用 dotTrace 对 .NET 应用进行性能分析与优化 - 元视角 一道 HashSet 面试题引发的蝴蝶效应 - 元视角 基于选项模式实现.NET Core 的配置热更新 - 元视角 Dapper.Contrib 在 Oracle 环境下引发 ORA-00928 异常问题的解决 - 元视角 .NET Core 中对象池(Object Pool)的使用 - 元视角 利用 MySQL 的 Binlog 实现数据同步与订阅(下):EventBus 篇 - 元视角 利用 MySQL 的 Binlog 实现数据同步与订阅(中):RabbitMQ 篇 - 元视角 利用 MySQL 的 Binlog 实现数据同步与订阅(上):基础篇 - 元视角 记一次从已损坏的 Git 仓库中找回代码的经历 - 元视角 .NET Core 原生 DI 扩展之属性注入实现 - 元视角 .NET Core 原生 DI 扩展之基于名称的注入实现 - 元视角
源代码探案系列之 .NET Core 限流中间件 AspNetCoreRateLimit - 元视角
飞鸿踏雪 · 2021-03-11 · via 元视角

在上一篇文章中,博主带领大家一起深入了解 ConcurrencyLimiter 这个中间件,正当我得意洋洋地向 Catcher Wong 大佬吹嘘这一点小收获时,大佬一脸嫌弃地说,一个单机版的方案有什么好得意的啊。大佬言下之意,显然是指,这个中间件在分布式环境中毫无用武之地。其实,你只需要稍微想一下,就能想明白这个问题。毕竟,它只是通过SeamphoreSlim控制线程数量而已,一旦放到分布式环境中,这个并发控制就被大大地削弱。所以,在今天这篇文章中,博主会带领大家一起“探案ASP.NET Core 中的限流中间件 AspNetCoreRateLimite,希望大家可以从中感悟到不一样的东西。对我而言,这可能是人到中年的焦虑感所催生出来的一种源动力,同时亦是为了不让那些订阅专栏的同学失望。

关于“限流”这个话题,我个人以为,它可以引申出非常多的东西,譬如“熔断”和“限流”,其实可以看作是同一类问题的“一体两面”。最早接触熔断,是源于 Spring Cloud 中的 Hystrix,它其实是指当服务不可用的时候,客户端应该采取什么样的措施去应对,实际使用中我们可能会考虑重试、超时、降级等策略。相应地,当服务端在面对来自客户端的异常流量时,就产生了“限流”这个概念,“限流”可以是线程隔离**(线程数 + 队列大小限制),可以是信号量隔离(设置最大并发请求数目),可以是限制QPS。这里,我们讨论的主要是第三种,而实现限流的常见算法主要有计数器算法漏桶算法令牌桶算法。这里,AspNetCoreRateLimit 这个中间件,则主要使用了计数器算法,并配合 IMemoryCacheIDistributedCache 分别实现了基于内存和基于分布式缓存的持久化逻辑。

源代码解读

首先,使用者通过配置定义了一个或者多个规则,这些规则决定了每个客户端在访问特定终结点时,一段时间内可以访问的最大次数。 RateLimitMiddleware 通过注入的IRateLimitProcessor 来匹配规则,然后依次判断每个规则是否达到了限流条件。一旦达到限流条件,中间件会改变 HTTP 响应的状态码、响应头、返回值,告知使用者已达到最大调用次数。而针对每一种 IRateLimitProcessor ,主要通过ProcessRequestAsync() 方法来实现计数,如果上一次的请求对应的时间戳 + 规则中时间间隔 >= 当前时间,则说明请求没有过期,此时,就需要给这个计数增加1。好了,现在我们来针对 AspNetCoreRateLimit 中的核心部件逐个进行解读。

RateLimitProcessor

RateLimitProcessor,是一个抽象类,实现了IRateLimitProcessor接口,公开的方法有 3 个:ProcessRequestAsync()IsWhitelisted()GetRateLimitHeaders()。在此基础上,派生出ClientRateLimitProcessorIpRateLimitProcessor两个子类。两者最大的不同在于,其所依赖的Store不同,前者为IClientPolicyStore,后者IIpPolicyStore,它们都实现了同一个接口IRateLimitStore

public interface IRateLimitStore<T>
{
    Task<bool> ExistsAsync(string id, CancellationToken cancellationToken = default);
    Task<T> GetAsync(string id, CancellationToken cancellationToken = default);
    Task RemoveAsync(string id, CancellationToken cancellationToken = default);
    Task SetAsync(string id, T entry, TimeSpan? expirationTime = null, 
        CancellationToken cancellationToken = default
    );
}

可以注意到,这些都是典型的基于键-值的存储,所以,不管是基于内存的IMemeryCache,还是基于分布式缓存的IDistributedCache,都可以做到无缝切换。不同的Processor,本质上是它们生成缓存键的方式不同,例如,IpRateLimitProcessor是用一个前缀来表示一组IP,而ClientRateLimitProcessor则是用通过客户端前缀和客户端Id来作为区分:

// src/AspNetCoreRateLimit/Core/IpRateLimitProcessor.cs
public async Task<IEnumerable<RateLimitRule>> GetMatchingRulesAsync(
    ClientRequestIdentity identity, 
    CancellationToken cancellationToken = default
)
{
    var policies = await _policyStore.GetAsync(
        $"{_options.IpPolicyPrefix}",
        cancellationToken
    );
    var rules = new List<RateLimitRule>();
    if (policies?.IpRules?.Any() == true)
    {
        // search for rules with IP intervals containing client IP
        var matchPolicies = policies.IpRules
            .Where(r => IpParser.ContainsIp(r.Ip, identity.ClientIp));
        foreach (var item in matchPolicies)
        {
            rules.AddRange(item.Rules);
        }
    }

    return GetMatchingRules(identity, rules);
}

// src/AspNetCoreRateLimit/Core/ClientRateLimitProcessor.cs
public async Task<IEnumerable<RateLimitRule>> GetMatchingRulesAsync(
    ClientRequestIdentity identity, 
    CancellationToken cancellationToken = default
)
{
    var policy = await _policyStore.GetAsync(
        $"{_options.ClientPolicyPrefix}_{identity.ClientId}", 
        cancellationToken
    );
    return GetMatchingRules(identity, policy?.Rules);
}

对于RateLimitProcessor而言,其实现思路是,通过CounterKeyBuilder及其子类来生成计数器标识(CounterId),然后再通过AsyncKeyLock来实现计数,最终通过IRateLimitCounterStore来实现存储:

public virtual async Task<RateLimitCounter> ProcessRequestAsync(
    ClientRequestIdentity requestIdentity, 
    RateLimitRule rule, 
    CancellationToken cancellationToken = default
)
{
    var counter = new RateLimitCounter
    {
        Timestamp = DateTime.UtcNow,
        Count = 1
    };

    // 生成CounterId
    var counterId = BuildCounterKey(requestIdentity, rule);
    
    // 基于AsyncLock的计数器
    // serial reads and writes on same key
    using (await AsyncLock.WriterLockAsync(counterId).ConfigureAwait(false))
    {
        var entry = await _counterStore.GetAsync(counterId, cancellationToken);

        if (entry.HasValue)
        {
            // entry has not expired
            if (entry.Value.Timestamp + rule.PeriodTimespan.Value >= DateTime.UtcNow)
            {
                // increment request count
                var totalCount = entry.Value.Count + _config.RateIncrementer?.Invoke() ?? 1;

                // deep copy
                counter = new RateLimitCounter
                {
                    Timestamp = entry.Value.Timestamp,
                    Count = totalCount
                };
            }
        }
        
        // 计数器存储
        // stores: id (string) - timestamp (datetime) - total_requests (long)
        await _counterStore.SetAsync(
            counterId, 
            counter, 
            rule.PeriodTimespan.Value, 
            cancellationToken
        );
    }

    return counter;
}

AsyncKeyLock

在分析RateLimitProcessor类的时候,我们提到了AsyncKeyLock。对于AsyncKeyLock的实现,我个人认为这是整个中间件的精华,因为这里出现了,和SeamphoreSlim一样经典的东西,这里用到了自旋锁SpinLock。我个人理解,SpinLock 约等于 Interlocked + 内核级别的while。这部分代码本身并不复杂,难就难在这样一个精妙的想法上面。其中,AsyncKeyLockDoorman 这个类的实现,应该是参考了微软的一篇博客—— Building Async Coordination Primitives, Part 7: AsyncReaderWriterLock,因为ReaderLockAsync()WriterLockAsync()ReaderRelease()WriterRelease() 这 4 个关键方法完全一样。结合限流这个场景来看,它是典型的“多写”场景,因为如果是相同的请求,那么就会产生相同的计数器标识(CounterId),所以,这个AsyncLockDoorman这个类所定义的上下文边界,其实是“一读多写”的问题,所以,我们可以注意到,它里面定义了一个“写”操作的队列_waitingWriters,一个“读操作”的_waitingReader

public AsyncKeyLockDoorman(Action<AsyncKeyLockDoorman> reset)
{   
    // 多个写入者
    _waitingWriters = new Queue<TaskCompletionSource<Releaser>>();
    // 单个读取者
    _waitingReader = new TaskCompletionSource<Releaser>();
    _status = 0;

    _readerReleaser = Task.FromResult(new Releaser(this, false));
    _writerReleaser = Task.FromResult(new Releaser(this, true));
    _reset = reset;
}

对于“写”操作而言,当一个新的写入者希望进来的时候,如果此时锁没有被别人占用,那么这个新的写入者会获得这个锁,状态值m_status会被修改为-1。反之,如果此时这个锁已经被别人占用了,那么这个新的写入者将会进入等待队列。

public Task<Releaser> WriterLockAsync()
{
    lock (_waitingWriters)
    {
        if (_status == 0)
        {
            _status = -1;
            return _writerReleaser;
        }
        else
        {
            var waiter = new TaskCompletionSource<Releaser>();
            _waitingWriters.Enqueue(waiter);
            return waiter.Task;
        }
    }
}

对于“读”操作而言,我们来思考这样一个问题,什么时候“读”操作会被允许呢?答案是这一时刻没有写入者正在“写”或者“等”,因为如果不这样的话,就会发生我们平常所说的“脏读”,所以,这种情况下,就必须强迫“读取者”去等待写入者“空闲”下来。此时,不难理解ReadLockAsync()的实现:

public Task<Releaser> ReaderLockAsync()
{
    lock (_waitingWriters)
    {
        if (_status >= 0 && _waitingWriters.Count == 0)
        {
            ++_status;
            return _readerReleaser;
        }
        else
        {
            ++_readersWaiting;
            return _waitingReader.Task.ContinueWith(t => t.Result);
        }
    }
}

现在,让我们把视线拉回到AsyncKeyLock,它负责维护一组AsyncKeyLockDoorman,其内部部通过一个字典来维护CounterIdAsyncKeyLockDoorman间的关系。与此同时,为了减少创建·AsyncKeyLockDoorman·带来的性能损耗,它使用一个栈来存储AsyncKeyLockDoorman。每次获取AsyncKeyLockDoorman的过程,本质上就是为指定的Key分配AsyncKeyLockDoorman的过程,同时会更新其引用数RefCount。相应地,释放AsyncKeyLockDoorman的过程,本质上就是减少其引用数RefCount,从字典中移除指定Key,“归还”对象池的过程:

// GetDoorman()
private static AsyncKeyLockDoorman GetDoorman(string key)
{
    AsyncKeyLockDoorman doorman;
    bool lockTaken = false;
    try
    {
        _spinLock.Enter(ref lockTaken);
        if (!Keys.TryGetValue(key, out doorman))
        {
            doorman = (Pool.Count > 0) ? Pool.Pop() : 
                new AsyncKeyLockDoorman(ReleaseDoorman);
            doorman.Key = key;
            Keys.Add(key, doorman);
        }

        doorman.RefCount++;
    }
    finally
    {
        if (lockTaken)
        {
            _spinLock.Exit();
        }
    }

    return doorman;
}

// ReleaseDoorman()
private static void ReleaseDoorman(AsyncKeyLockDoorman doorman)
{
    bool lockTaken = false;
    try
    {
        _spinLock.Enter(ref lockTaken);
        if (--doorman.RefCount == 0)
        {
            Keys.Remove(doorman.Key);
            if (Pool.Count < MaxPoolSize)
            {
                doorman.Key = null;
                Pool.Push(doorman);
            }
        }
    }
    finally
    {
        if (lockTaken)
        {
            _spinLock.Exit();
        }
    }
}

RateLimitMiddleware

OK,到这里,我们再回过头去看源代码解读这里的内容,大概就可以串起来整合中间件的调用链路,Middleware->RateLimteProcessor->AsyncKeyLock->AsyncKeyLockDoorman,坦白来讲,我一直没能想明白为什么要用SpinLock?难道仅仅是为了减少等待时间、提高性能吗?经过精简,我们发现,整个中间件的Invoke()方法,大致要经历下面几个阶段:

public async Task Invoke(HttpContext context)
{
    // 检查限流是否启用
    if (_options == null)
    {
        await _next.Invoke(context);
        return;
    }

    // 获取用户身份
    var identity = await ResolveIdentityAsync(context);

    // 检查白名单
    if (_processor.IsWhitelisted(identity))
    {
        await _next.Invoke(context);
        return;
    }

    //获取限流规则
    var rulesDict = new Dictionary<RateLimitRule, RateLimitCounter>();
    var rules = await _processor.GetMatchingRulesAsync(
        identity, 
        context.RequestAborted
    );
    foreach (var rule in rules)
    {
        // 获取计数器数目
        var rateLimitCounter = await _processor.ProcessRequestAsync(
            identity, 
            rule, 
            context.RequestAborted
        );
        if (rule.Limit > 0)
        {
            // 请求未过期
            if (rateLimitCounter.Timestamp + rule.PeriodTimespan.Value < DateTime.UtcNow)
            {
                continue;
            }

            // 请求过期
            if (rateLimitCounter.Count > rule.Limit)
            {
                // 各种记日志,告诉调用者多长时间后再重试
                var retryAfter = rateLimitCounter.Timestamp.RetryAfterFrom(rule);
                
                // ...

                // 中止请求
                await ReturnQuotaExceededResponse(context, rule, retryAfter);
                return;
            }
        }
        else
        {
            // Limit <= 0, 相当于直接不允许放行,中止请求
            await ReturnQuotaExceededResponse(
                context, 
                rule, 
                int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture)
            ); 
        }

        // ...
    }

    // 设置X-Rate-Limit头
    // ...

    await _next.Invoke(context);
}

本文小结

作为 并发限制 这一篇的“姊妹篇”,这一篇的难度相对上一篇堪称“高山仰止”,主要的难点是 SpinLock 、“一读多写”的异步读写锁 AsyncKeyLock 以及 AsyncKeyLockDoorman 。如果大家感兴趣的话,可以去搜索一下 AsyncKeyLock 这个关键字,大家就会发现在好多开源项目 中都能找到类似的代码片段,莫非这是某种神奇的算法吗?阅读源代码,其实是一个无法“立竿见影”的学习方法,有时候我们要通过叙述或者表达来输出我们对待一件事物的看法。这是因为,我们自以为是的“学会”和真正的“学会”,这两者间可能千差万别,就像我最近在用 ABP vNext 搭建一个小项目,阅读文档的时候,眼睛觉得它“学会”了,而实际需要需要扩展或者替换 ABP 的实体/服务的时候。我的手会告诉我,它真的“不会”。做一个知难行易的“调包”侠也许会非常容易,可正因为如此,你要凸显自我就会非常困难。世上的事情,“夫夷以近,则游者众;险以远,则至者少。而世之奇伟、瑰怪,非常之观,常在于险远,而人之所罕至焉,故非有志者不能至也”,哪怕就是增长一下见识呢,你说对吧……