使用 .NET System.IO.Pipelines 和 Kestrel Sockets 库创建 Redis 客户端





5.00/5 (6投票s)
一个从头开始使用 System.IO.Pipelines、Span 等编写的 Redis 客户端
一个用于测试 GitHub Webhook 到 CodeProject 的小改动。又一个……
本文是关于为 Redis 服务器创建异步客户端系列文章的第一篇,该客户端具有低分配(从而降低 GC 压力)和最小数据复制的特点。这是通过使用使 Kestrel 成为每秒原始请求量排名前十的 Web 服务器的技术来实现的,正如 TechEmpower 纯文本性能测试第 13 轮中记录的那样。
背景
不久前,我开始编写一个异步的 .NET Core Redis 客户端。当时,所有的 Redis 客户端都不支持 .NET Core,我想写一篇关于如何为简单协议实现客户端的文章。
不幸的是,从 VS2015 RC1 到 RC2 的变化表明平台将在一段时间内不稳定,虽然我有一个相当完整的实现,但我把它搁置起来,直到 .NET 和 Visual Studio 世界变得更稳定。
随着即将发布的 VS2019、.NET 3.0 以及 CLI、NetStandard 和工具的稳定,我认为是时候重新审视这个项目了。有一件事引起了我对 .NET Core 的兴趣,那就是它的性能改进了多少,尤其是在 Kestrel Web 服务器的性能方面。
.NET Core 团队,特别是 David Fowler,将他们在改进 Kestrel 方面学到的知识,创建了一套库,允许以很少或没有内存分配和最小数据复制的方式处理数据流。这是通过反转现有 Stream 范式来实现的,即不再将数据缓冲区推入和推出流,而是由底层 API 管理数据缓冲区并将其推送到应用程序。这些库使用高效的内存缓冲区池和结构来实现高性能,这使得 Kestrel 成为最快的 Web 服务器之一。
话虽如此,Kestrel 现在似乎使用 System.IO.Pipelines NuGet 包,它也用于 SignalR。作为 Kestrel 项目的一部分,创建了许多基于低级管道的库,以实现低分配、高性能的网络 IO,以取代基于流的 IO。Socket 基于 IO 的实现可以在 Nuget.org 上找到:Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets。
简介
几年前,在 Code Project,我们检查了网页响应时间的性能,发现其严重不足。在每个请求中,我们都在对常用数据进行数据库请求,并对内容进行复杂而昂贵的清理和格式化。
我们着手进行一个项目,利用各种信息和视图模型的缓存来提高网站的性能。这种缓存需要是分布式的,以便我们网络服务器场中的所有服务器都能与最新数据保持一致和同步。在评估了几种方案后,我们决定使用 Redis,因为它速度快、成本低、广泛采用、评价高,并且其数据结构和 API 功能强大。
由此产生的性能改进超出了我们的预期,以前需要数秒甚至数十秒的页面,现在在不到一秒,通常不到 500 毫秒的时间内返回,大大降低了我们 SQL Server 的 CPU 负载。通过添加后台事件处理和优化一些昂贵且大量使用的算法,进一步提高了性能,但我怀疑我们所做的一切都无法产生我们通过使用 Redis 获得的改进。
我们目前的实现使用 ServiceStack Redis 客户端 V3。我们也有一个使用 StackExchange.Redis 客户端的实现,但也有一些问题。我不得不查看代码来解决许多问题,作为任何程序员都会做的那样,我决定我可以做得更好,或者至少不同。这主要是由于 C# 语言的改进,例如扩展方法。这使得我可以创建一个小型客户端,它只向 Redis 服务器发送和接收数据。实际的命令是使用扩展方法实现的。这消除了 Service Stack 实现中庞大的类和 StackExchange 实现中的一些代码重复,从而使每个类具有更大的单一职责。
这两个库中都有许多很棒的想法,例如 StackExchange 客户端中的 ConnectionMultiplexer,它允许共享单个套接字,而不是每次需要访问 Redis 服务器时都必须创建新的套接字连接。类似的东西将在本系列文章的后面实现。
本次实现的目标是
- 简单
- 性能
- 效率
- 健壮性
- 完整的单元测试
Redis 协议
客户端使用 Redis 序列化协议 (RESP) 与 Redis 服务器通信,详细信息请参见 Redis 协议规范。如规范所述:
Redis 客户端使用一种称为 RESP 的协议与 Redis 服务器通信。(Redis 序列化协议)。虽然该协议是专门为 Redis 设计的,但它可用于其他客户端-服务器软件项目。
RESP 是以下几点之间的权衡:
- 易于实现。
- 解析速度快。
- 人类可读。
RESP 可以序列化不同的数据类型,如整数、字符串、数组。还有一种专门用于错误的数据类型。请求从客户端发送到 Redis 服务器,作为表示要执行命令参数的字符串数组。Redis 用命令特定的数据类型回复。
RESP 是二进制安全的,不需要处理从一个进程传输到另一个进程的大量数据,因为它使用前缀长度来传输大量数据。
与其详细介绍协议,不如留给读者参考规范,如果需要澄清我正在做的任何事情。它小巧、简单且易于理解。我将在解释使用它们的代码时解释具体的协议细节。
软件设计
基于管道的套接字传输的魔力在于它公开了一对管道的 PipeReaders 和 PipeWriters。一个管道,即 OutputPipe,将数据从应用程序传输到传输层,而另一个管道,即 InputPipe,将数据从传输层传输到应用程序。
该连接公开了一个 IDuplexPipe,即 Application,它具有一个 Input PipeReader 和一个 Output PipeWriter。Input 设置为 InputPipe.Reader,而 Output 设置为 OutputPipe.Writer。该连接有两个任务,一个任务从 Socket 读取数据并将其写入 InputPipe,另一个任务从 OutputPipe 读取数据并将其写入 Socket。
管道使用一组内存块来提供和重用缓冲区以存储数据。这与流范式不同,流范式中用户负责分配和管理用于读写流的数据缓冲区。结果是管道传输几乎不需要或根本不需要缓冲区分配和垃圾回收即可从套接字读取和写入。实际上,在大多数情况下,几乎不需要将数据从一个缓冲区复制到另一个缓冲区,直到需要进行这种复制以从接收到的数据中反序列化某些对象。
在此处插入传输管道图
这意味着我们的 Redis 协议处理程序需要做两件事:
- 将 Redis 命令序列化为写入 PipeWriter 的字节
- 从 PipeWReader 读取字节并将其反序列化为 Redis 响应
因此,通过两个管道创建一个测试传输层很简单。被测代码连接到应用程序端,而测试读写传输端,从而可以在不需要为单元测试设置 Redis 实例的情况下测试预期的功能。
当然,在某个时候,将需要与真实的 Redis 服务器进行实际通信,以验证单元测试的假设。我将为此目的使用 Redis Docker 容器。