本文介绍了 Netflix 的时间序列数据抽象,该抽象以低成本、低延迟的方式为用户提供高效存储、查询大量时间序列数据的能力。原文:Introducing Netflix's TimeSeries Data Abstraction Layer

简介
随着 Netflix 不断拓展并涉足诸如视频点播 和游戏等不同领域,能够以毫秒级访问延迟处理和存储海量时序数据(通常可达 PB 级别)的能力变得愈发重要。在之前文章中,我们介绍了键值数据抽象层和数据网关平台,这两者都是 Netflix 数据架构的重要组成部分。键值抽象提供了存储和访问结构化键值数据的灵活且可扩展的解决方案,而数据网关平台则为保护、配置和部署数据层提供了必要的基础设施。
基于这些基本抽象,我们开发了 时间序列抽象(TimeSeries Abstraction) 这一解决方案 ------ 这是一种灵活且可扩展的方案,旨在以低毫秒级延迟的方式高效存储并查询大量时序事件数据,并以经济实惠的方式支持各种应用场景。
本文将深入探讨时间序列抽象的架构、设计原则以及实际应用,展示其提升平台对大规模时序数据的管理能力。
注意:与名称所暗示的有所不同,此系统并非设计为通用的时序数据库。我们不会将其用于指标、直方图、计时器或任何此类近乎实时的分析用例,这些用例已经由 Netflix 的 Atlas 监测系统很好的满足了。相反,我们专注于解决以低延迟和高成本效益的方式存储和访问极高吞吐量、不可变的时序事件数据这一挑战。
挑战
在 Netflix,各类时间相关的数据会持续生成并加以利用,数据源包括用户的互动行为(如观看视频的事件)、资产的展示情况,或是复杂的微服务网络活动等。有效的大规模管理这些数据并从中提取有价值的信息,对于确保最佳的用户体验和系统可靠性至关重要。
然而,存储和查询此类数据则带来了诸多挑战:
- 高吞吐量:每秒处理多达 1000 万次写入操作,同时保持高可用性。
- 大规模数据集的高效查询:存储数 PB 数据,同时确保主键读取能在低个位数毫秒内返回结果,并支持对多个次要属性的搜索和聚合。
- 全球读写:通过可调节的一致性模型,实现从全球任何地方进行读写操作。
- 可调配置:提供在单租户或多租户数据存储中对数据集进行分区的能力,并可调整各种数据集设置,如保留期和一致性。
- 应对突发流量:在高需求事件期间(如新内容发布或区域故障转移)管理大量流量高峰。
- 成本效益:降低每字节和每操作的成本,以优化长期存储成本,同时最大限度减少基础设施费用,对于 Netflix 而言,这一费用可能高达数百万美元。
时序抽象
时序抽象这一概念的提出正是为了满足这些需求,其基于以下核心设计原则构建:
- 分区数据:数据通过独特的时间分区策略与事件分组方法相结合进行分区,以高效管理突发工作负载并简化查询。
- 灵活存储 :该服务设计可与各种存储后端集成,包括 Apache Cassandra 和 Elasticsearch,使 Netflix 能够根据具体使用需求定制存储解决方案。
- 可配置性:时序抽象为每个数据集提供了多种可配置选项,提供了适应各种使用场景所需的灵活性。
- 可扩展性:该架构支持水平和垂直扩展,使系统能够随着 Netflix 用户基数和服务的扩大而处理不断增加的吞吐量和数据量。
- 分片基础设施:借助数据网关平台,可以部署单租户和/或多租户基础设施,并具备必要的访问和流量隔离功能。
下面我们深入探讨时序抽象的细节。
数据模型
我们采用了一种独特的事件数据模型,能够涵盖想要记录的所有事件数据,并且还能实现高效数据查询。

我们从抽象中最小的数据单元开始,然后逐步进行。
- 事件项(Event Item) :事件项是一个键值对,用来为特定事件存储数据。例如:
{"device_type": "ios"}
。 - 事件(Event) :事件是一个由一个或多个此类事件项组成的结构化集合。事件在特定时间点发生,并由客户端生成的时间戳和事件标识符(例如 UUID)来标识。事件时间(event_time) 与 事件标识符(event_id) 的这种组合也是事件唯一幂等性密钥的一部分,使用户能够安全的重试请求。
- 时间序列 ID :时间序列 ID(time_series_id) 是在数据集保留期内的一组一个或多个此类事件的集合。例如,设备 ID 会存储给定设备在保留期内发生的所有事件。所有事件都是不可变的,而时间序列服务只会将事件追加到给定时间序列 ID 上。
- 命名空间(Namespace):命名空间是一个时间序列 ID 和事件数据的集合,代表完整的时间序列数据集。用户可以为每个用例创建一个或多个命名空间。这种抽象在命名空间级别应用了各种可配置选项,我们将在探讨服务的控制平面时进一步讨论这些选项。
API
时序抽象提供了以下 API 来与事件数据交互。
WriteEventRecordsSync:批量写入事件并向客户端发回持久性确认,可在用户需要保证可用性的情况下使用。
WriteEventRecords:这是上述端点的"即刻执行、无需确认"的版本。该 API 将批量事件进行排队处理,但不进行持久性确认。这种模式常用于日志或跟踪等场景,这些场景的用户更关注处理速度,能够容忍少量数据丢失的情况。
json
{
"namespace": "my_dataset",
"events": [
{
"timeSeriesId": "profile100",
"eventTime": "2024-10-03T21:24:23.988Z",
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"eventItems": [
{
"eventItemKey": "ZGV2aWNlVHlwZQ==",
"eventItemValue": "aW9z"
},
{
"eventItemKey": "ZGV2aWNlTWV0YWRhdGE=",
"eventItemValue": "c29tZSBtZXRhZGF0YQ=="
}
]
},
{
"timeSeriesId": "profile100",
"eventTime": "2024-10-03T21:23:30.000Z",
"eventId": "123e4567-e89b-12d3-a456-426614174000",
"eventItems": [
{
"eventItemKey": "ZGV2aWNlVHlwZQ==",
"eventItemValue": "YW5kcm9pZA=="
}
]
}
]
}
ReadEventRecords:若提供命名空间、时间序列标识、时间间隔以及可选的事件过滤条件,则此端点将返回所有匹配的事件,并按照时间降序排列,且具有较低的毫秒级延迟。
json
{
"namespace": "my_dataset",
"timeSeriesId": "profile100",
"timeInterval": {
"start": "2024-10-02T21:00:00.000Z",
"end": "2024-10-03T21:00:00.000Z"
},
"eventFilters": [
{
"matchEventItemKey": "ZGV2aWNlVHlwZQ==",
"matchEventItemValue": "aW9z"
}
],
"pageSize": 100,
"totalRecordLimit": 1000
}
SearchEventRecords:根据搜索条件和时间区间,此端点会返回所有匹配的事件。该 API 可以满足最终一致性读取需求。
json
{
"namespace": "my_dataset",
"timeInterval": {
"start": "2024-10-02T21:00:00.000Z",
"end": "2024-10-03T21:00:00.000Z"
},
"searchQuery": {
"booleanQuery": {
"searchQuery": [
{
"equals": {
"eventItemKey": "ZGV2aWNlVHlwZQ==",
"eventItemValue": "aW9z"
}
},
{
"range": {
"eventItemKey": "ZGV2aWNlUmVnaXN0cmF0aW9uVGltZXN0YW1w",
"lowerBound": {
"eventItemValue": "MjAyNC0xMC0wMlQwMDowMDowMC4wMDBa",
"inclusive": true
},
"upperBound": {
"eventItemValue": "MjAyNC0xMC0wM1QwMDowMDowMC4wMDBa"
}
}
}
],
"operator": "AND"
}
},
"pageSize": 100,
"totalRecordLimit": 1000
}
AggregateEventRecords:根据搜索条件和聚合模式(例如"唯一聚合"),此端点会在指定时间间隔内执行所指定的聚合操作。与搜索端点类似,用户需要容忍最终一致性以及可能更高的延迟(以秒为单位)。
json
{
"namespace": "my_dataset",
"timeInterval": {
"start": "2024-10-02T21:00:00.000Z",
"end": "2024-10-03T21:00:00.000Z"
},
"searchQuery": {...some search criteria...},
"aggregationQuery": {
"distinct": {
"eventItemKey": "deviceType",
"pageSize": 100
}
}
}
接下来我们将讨论如何在存储层与数据进行交互。
存储层
时间序列的数据存储层包含一个主数据存储和一个可选的索引数据存储。主数据存储在写入操作期间确保数据的持久性,并用于主读取操作。而索引数据存储则用于搜索和聚合操作。在 Netflix 中,对于高吞吐量场景中的持久数据存储,Apache Cassandra 是首选方案,而 Elasticsearch 则是用于索引的首选数据存储。然而,与我们在 API 方面的方法类似,存储层并非与特定数据存储紧密耦合。相反,我们定义了必须满足的存储 API 合约,从而使我们能够根据需要替换底层数据存储。
主数据存储
本节将讨论如何利用 Apache Cassandra 实现时间序列用例。
分区方案
在 Netflix 这样的规模下,源源不断涌入的事件数据可能会迅速使传统数据库不堪重负。时间分区解决了这一难题,它根据时间间隔(如每小时、每天或每月的时段)将数据划分成易于管理的块。这种方法使用户能够高效查询特定时间范围,而无需扫描整个数据集。还使我们能够高效归档、压缩或删除较旧的数据,从而优化存储和查询性能。此外,这种分区策略还缓解了在 Cassandra 中通常与宽分区相关的性能问题。通过采用这种策略,减少了为合并操作预留大量磁盘空间的需求,能够实现更高的磁盘利用率,从而节省成本。
该方案如下所示:

时间切片(Time Slice) :时间切片是保留数据的单位,直接映射到 Cassandra 表。我们创建多个这样的时间切片,每个切片覆盖一个特定的时间间隔。事件根据 event_time 落在其中一个切片中。切片无缝连接在一起,操作包括开始时间但不包含结束时间,确保所有数据都位于其中一个片中。
为什么不使用基于行的生存时间(TTL)?
在单个事件上使用时间戳(TTL)会在 Cassandra 中生成大量墓碑(tombstones)记录,从而降低性能,尤其是在进行基于范围的扫描时。通过采用离散时间切片并将其删除,完全避免了这种问题。但代价是数据可能会比实际所需的保留时间稍长一些,因为整个表的时间范围必须完全超出保留窗口之外才能被删除。此外,TTL 很难在之后进行调整,而时间序列则可以通过单个控制平面操作立即扩展数据集的保留期限。
时间桶(Time Buckets):在一个时间切片内,数据会被进一步划分成时间桶,从而有助于针对给定查询范围锁定特定时间桶来实现有效的范围扫描。但代价是,如果用户想要读取整个长时间段内的所有数据,就必须扫描多个分区。我们通过并行扫描这些分区并在最后汇总数据来减少潜在延迟。在大多数情况下,针对较小的数据子集的优势超过了这些分散-聚集(scatter-gather)操作带来的读取放大效应。通常,用户会读取较小的数据子集,而非整个保留范围。
事件桶(Event Buckets):为了管理极高的写入操作(这些操作可能会导致在较短时间内针对某一时间序列出现大量写入操作),我们进一步将时间桶划分为事件桶,从而避免在给定的时间范围内对同一分区进行过度加载,并且还能进一步减小分区大小,缺点是会带来轻微的读取放大效应。
注意:自 Cassandra 4.x 版本起,在对大分区内的数据进行扫描时,性能有了显著提升。有关未来增强功能 的详细信息,请参阅文末动态事件分区部分,该功能旨在充分利用这一优势。
存储表
我们使用两种类型的表:
- 数据表:用于存储实际事件数据的时间切片。
- 元数据表:存储有关每个时间切片在每个命名空间中的配置方式的信息。
数据表

分区键能够根据 时间桶(time_bucket) 和 事件桶(event_bucket) 对 时间序列 ID(time_series_id) 所对应的事件进行分组,从而缓解热点分区问题。而聚类键则使我们能够将数据按我们通常希望的顺序(几乎总是这样的顺序)存放在磁盘上进行排序。元数据值(value_metadata) 列则存储了 事件项值(event_item_value) 的相关元数据,例如压缩信息。
写数据表:
用户所写内容会在特定时间切片、时间桶以及事件桶内生成,其生成依据是与该事件相关的 事件时间(event_time) 这一因素,而这一因素则由特定命名空间的控制面配置所决定。
例如:

在这一过程中,编写代码之前需要决定如何处理数据,比如是否对其进行压缩。而 value_metadata 列则记录了所有此类后续处理操作,从而确保用户能够准确解读数据。
读数据表:
下图从宏观角度展示了如何从多个分区中分散读取数据,并在最后将结果集进行合并以返回最终结果的过程。

元数据表:

请注意以下几点:
- 无时间间隔:给定时间切片的结束时间与下一个时间切片的开始时间重叠,确保所有事件都有其归属位置。
- 保留:该状态表明哪些表处于保留窗口之内,哪些在外面。
- 灵活:元数据可以根据每个时间切片进行调整,使我们能够根据当前时间切片中的观察到的数据模式来调整未来时间切片的分区设置。
还可以将大量其他信息存储到元数据列中(例如,表的压缩设置),但在此仅展示分区设置以保持简洁。
索引数据存储
为了通过非主键属性支持二级访问模式,我们将数据索引到 Elasticsearch 中。用户可以为每个命名空间配置希望搜索和/或汇总数据的属性列表。该服务在事件流进时从事件中提取字段,并将生成的文档索引到 Elasticsearch 中。根据吞吐量的不同,我们可能会将 Elasticsearch 用作反向索引,从 Cassandra 中检索完整数据,或者可能会直接将整个源数据存储在 Elasticsearch 中。
注意:用户不会直接接触 Elasticsearch,就像他们不会直接接触 Cassandra 一样。相反,用户 Search 和 Aggregate API 端点交互,后者将给定查询转换为底层数据存储所需的查询。
接下来我们将讨论如何为不同数据集配置数据存储。
控制面
数据面负责执行读取、写入操作,而控制面则配置命名空间行为的各个方面。数据面与时间序列控制栈进行通信,该栈负责管理配置信息。反过来,时间序列控制栈与共享的数据网关平台控制面进行交互,后者负责监督所有抽象以及命名空间的控制配置。

将数据面和控制面的职责分开有助于保持数据面的高可用性,因为控制面承担的任务可能需要从底层数据存储中获得某种形式的模式共识。
命名空间配置
以下配置片段展示了该服务极强的灵活性,以及如何通过控制面针对每个命名空间进行多项设置的调整。
json
"persistence_configuration": [
{
"id": "PRIMARY_STORAGE",
"physical_storage": {
"type": "CASSANDRA", // type of primary storage
"cluster": "cass_dgw_ts_tracing", // physical cluster name
"dataset": "tracing_default" // maps to the keyspace
},
"config": {
"timePartition": {
"secondsPerTimeSlice": "129600", // width of a time slice
"secondPerTimeBucket": "3600", // width of a time bucket
"eventBuckets": 4 // how many event buckets within
},
"queueBuffering": {
"coalesce": "1s", // how long to coalesce writes
"bufferCapacity": 4194304 // queue capacity in bytes
},
"consistencyScope": "LOCAL", // single-region/multi-region
"consistencyTarget": "EVENTUAL", // read/write consistency
"acceptLimit": "129600s" // how far back writes are allowed
},
"lifecycleCon..._tracing", // ES cluster name
"dataset": "tracing_default_useast1" // base index name
},
"config": {
"timePartition": {
"secondsPerSlice": "129600" // width of the index slice
},
"consistencyScope": "LOCAL",
"consistencyTarget": "EVENTUAL", // how should we read/write data
"acceptLimit": "129600s", // how far back writes are allowed
"indexConfig": {
"fieldMapping": { // fields to extract to index
"tags.nf.app": "KEYWORD",
"tags.duration": "INTEGER",
"tags.enabled": "BOOLEAN"
},
"refreshInterval": "60s" // Index related settings
}
},
"lifecycleConfigs": {
"lifecycleConfig": [
{
"type": "retention", // Index retention settings
"config": {
"close_after": "1296000s",
"delete_after": "1382400s"
}
}
]
}
}
]
配置基础设施
由于存在如此多不同的参数,我们需要自动化的配置流程来为特定工作负载确定最佳设置。当用户想要创建命名空间时,会指定一系列工作负载需求 ,而自动化系统会将其转化为具体的基础设施和相关的控制面配置。强烈建议观看我们一位杰出同事乔伊·林奇在 ApacheCon 演讲 中所介绍的关于如何实现这一目标的内容。
一旦系统提供了初始基础设施,就会根据用户工作负载进行扩展。下一节将介绍如何实现这一点。
可扩展性
用户在创建命名空间时可能仅具备有限的信息,这会导致在创建过程中只能进行尽力而为的资源分配估算。此外,不断变化的使用场景可能会随着时间的推移带来新的吞吐量需求。以下是我们的应对方式:
- 水平扩容 :时间序列服务器实例可根据扩容策略自动增加或减少规模,以满足流量需求。存储服务器的容量可以通过容量规划工具重新计算,以适应不断变化的需求。
- 垂直扩容:也可以选择垂直扩展时间序列服务器实例或存储实例,以获得更大的 CPU、内存和/或附加存储容量。
- 扩展磁盘 :如果容量规划工具更倾向于采用成本更低、存储容量更大的基础设施而非优化 SSD 延迟,那么可以附加 EBS 来存储数据。在这种情况下,当磁盘存储达到一定百分比阈值时,会部署作业来扩展 EBS 卷。
- 数据重分区:不准确的工作负载估计可能导致不同分区的数据集存储过载或不足。时间序列控制面可以在了解实际数据的性质(通过分区直方图)后,为下一个时间切片调整分区配置。未来我们计划支持对旧数据的重分区以及对当前数据的动态分区。
设计原则
到目前为止,我们已经了解了时间序列如何存储、配置以及与事件数据集进行交互。接下来,让我们看看如何运用不同的技术来提高运维效率,并提供更可靠的保障。
事件幂等性
我们倾向于在所有修改型端点中实现幂等性,以便用户能够安全的重试或进行风险规避操作。风险规避是指客户端向服务器发送完全相同的竞争请求,如果原始请求在预期时间内未得到响应,则客户端会根据最先完成的请求进行响应。这样做是为了使应用程序的尾部延迟保持在相对较低的水平。只有在这些修改是幂等的情况下,才能安全的进行这种操作。对于时间序列而言,事件时间(event_time) 、事件 ID(event_id) 和 事件项键(event_item_key) 共同构成了给定时间序列事件的幂等性键。
基于 SLO 的风险规避
我们在时间序列中为不同端点设定服务级别目标(SLO)指标,以此来表明这些端点在特定命名空间下的性能应达到何种水平。如果响应未能在给定时间内返回,就可以对请求进行保护。
json
"slos": {
"read": { // SLOs per endpoint
"latency": {
"target": "0.5s", // hedge around this number
"max": "1s" // time-out around this number
}
},
"write": {
"latency": {
"target": "0.01s",
"max": "0.05s"
}
}
}
部分返回
有时,客户可能会对延迟问题较为敏感,并且愿意接受部分结果集。这种情况的一个实际例子就是实时频率限制。在这种情况下,精度并非至关重要,但如果响应出现延迟,那么对于上游客户而言,响应实际上就毫无用处了。因此,客户更倾向于获得目前收集到的所有数据,而不是在等待所有数据时被超时终止。时间序列客户端为此提供了围绕服务级别协议(SLO)的部分返回支持。重要的是,我们仍然在这一部分获取数据时保持事件的最新顺序。
自适应分区
所有读操作都从默认的扇出因子开始,同时并行扫描 8 个分区桶。然而,如果服务层判定时间序列数据集较为密集(即大多数读取操作可以通过读取前几个分区桶来满足),就会动态调整未来读取操作的扇出因子,以减少底层数据存储上的读取放大现象。相反,如果数据集较为稀疏,则可能会适当提高限制值,并设置一个合理的上限。
限制写窗口
在大多数情况下,写入数据的有效范围要小于读取数据的有效范围 ------ 也就是说,我们希望尽可能快的使某个时间段的数据变得不可更改,以便能够在此基础上进行优化。我们通过可配置的 接受限制(acceptLimit) 参数来控制,该参数可防止用户写入早于此时间限制的事件。例如,acceptLimit 为 4 小时意味着用户不能写入早于现在 4 小时的事件。有时我们会提高这个限制以用于回填历史数据,但在常规写操作中会将其调回较低的值。一旦某个数据范围变得不可更改,就可以安全的对其进行诸如缓存、压缩和简化等操作以便用于读取。
写缓存
我们经常利用这项服务来处理突发性工作负载。我们不会一次性将负载全部施加到底层数据存储上,而是希望通过让事件在较短时间内(通常为几秒)聚集起来,以便更均匀的分配负载。事件会累积在每个实例的内存队列中,专门的消费者会持续从队列中提取数据,按照其分区键对事件进行分组,并将写入操作批量发送到底层数据存储中。

队列根据每个数据存储进行定制,其运行特性取决于所写入的具体数据存储。例如,向 Cassandra 写入数据的批处理大小明显小于对 Elasticsearch 进行索引操作的批处理大小,因此相关消费者会具有不同的处理速率和批处理大小。
虽然使用内存队列确实会增加 JVM 的垃圾回收量,但通过切换到 JDK 21 并采用 ZGC 技术,获得了显著改善。为了说明其效果,下图可以看到 ZGC 使尾延迟降低了令人瞩目的 86%:

由于我们使用的是内存队列,所以在实例崩溃的情况下,可能会丢失事件。因此,队列仅用于能够容忍一定数据丢失的场景,例如跟踪/日志记录。而对于需要保证持久性和/或读写一致性的情况,这些队列实际上会被禁用,并且写入操作会几乎立即被刷新到数据存储中。
动态压缩
一旦某个时间切片离开活跃写入窗口,就可以利用数据的不可变性来优化其读取性能。这一过程可能包括使用最优压缩策略对不可变数据进行重新压缩、动态缩小和/或拆分分片以优化系统资源,以及其他类似技术,以确保快速可靠的性能。
接下来我们看一下时间序列数据在实际应用中的表现情况。
真实性能
该服务可以以低个位数毫秒的延迟顺序写入数据,

同时始终保持稳定的读点延迟:

当前,该服务在全球范围内的不同数据集上的处理速度达到了每秒近 1500 万次事件。

Netflix 的时间序列用例
时间序列抽象在 Netflix 的关键服务中起着至关重要的作用。以下是一些有影响力的用例:
- 追踪与洞察:对 Netflix 内部所有应用程序和微服务的日志进行追踪,以了解服务之间的通信情况,协助问题的调试,并解答支持请求。
- 用户交互追踪:追踪数百万次用户交互行为 ------ 例如视频播放、搜索和内容参与 ------ 提供实时见解,以增强 Netflix 推荐算法,并改善整体用户体验。
- 功能推出与性能分析:追踪新产品功能的推出和性能情况,使 Netflix 工程师能够衡量用户对功能的使用情况,从而为未来改进提供基于数据的决策支持。
- 资产影响追踪与优化:追踪资产影响,确保内容和资产能够高效交付,并提供实时反馈以进行优化。
- 账单与订阅管理:存储与账单和订阅管理相关的历史数据,确保交易记录的准确性,并支持客户服务咨询。
等等......
下一步工作
随着使用场景不断发展,以及进一步提高抽象化处理成本效益的需求日益增强,计划在接下来的几个月里对服务进行多项改进,包括:
- 分层存储以实现成本效益:支持将较旧且访问较少的数据转移到成本更低、首次字节获取时间更短的对象存储中,这有可能为 Netflix 节省数百万美元。
- 动态事件分区:支持在事件流进时将键实时划分到最优大小的分区中,而不是在配置命名空间时采用某种相对静态的配置。这种策略的一个巨大优势在于不会对不需要分区的时间序列标识进行分区,从而节省了读取放大的整体成本。此外,对于 Cassandra 4.x 版本,我们注意到在宽分区中读取数据子集方面有重大改进,这可能使我们在提前对整个数据集进行分区时不再那么激进。
- 缓存:利用数据的不可变性,可以智能的对不同时间范围进行缓存。
- 计数和其他聚合:一些用户只对给定时间间隔内的事件计数感兴趣,而不想获取该时间段的所有事件数据。
结论
时间序列抽象是 Netflix 在线数据基础设施的重要组成部分,对于支持实时决策和长期决策都起着至关重要的作用。无论是监测高流量事件期间的系统性能,还是通过行为分析来优化用户参与度,时间序列抽象都能确保 Netflix 在全球范围内能够高效、顺畅的运行。
随着 Netflix 不断进行创新并拓展到新的领域,时间序列抽象技术仍将是平台的基石,帮助我们突破流媒体及其他领域的技术限制。
你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!