告别ELK,APO提供基于ClickHouse开箱即用的高效日志方案——APO 0.6.0发布

ELK一直是日志领域的主流产品,但是ElasticSearch的成本很高,查询效果随着数据量的增加越来越慢。业界已经有很多公司,比如滴滴、B站、Uber、Cloudflare都已经使用ClickHose作为ElasticSearch的替代品,都取得了不错的效果,实现了降本增效,费用节约大多在50%以上。但是目前使用ClickHose作为日志方案,存在以下问题。

  • 主流的Vector+ClickHose并未实现开箱即用,有许多的管理配置工作
  • 绝大多数方案不支持近似全文检索的功能(该功能很重要)
  • 使用双数组或者Map的表结构查询效率不高
  • ClickVisual是最接近的开箱即用的日志方案,也存在以下问题:

○强依赖Kafka,对于某些中小用户而言方案不够灵活,不友好

○未引入Vector,原生的ClickHose Kafka引擎在大流量情况下可能导致ClickHose内存爆掉(感谢社区大佬 十四反馈)

主流的Vector+ClickHouse方案并未实现开箱即用

目前业界很多公司都是基于Vector+ClickHouse的方案来实现日志的采集和存储,该方案需要管理维护的工作量相对而言比较高,适用于动手能力强的公司。

维护工作:为每种日志手动维护一张表

每个公司的部门团队可能日志规范都不完全一致,如果需要对日志内容进行快速搜索定位故障,就需要提前想好ClickHouse的表结构,然后调整Vector的配置文件,最终实现Vector根据不同日志格式,parse成不同的日志表字段,写入不同的日志表。

比如每种日志都得建立以下类似的表结构,才能完成日志按照ip、url等字段的索引实现快速搜索。但是另外一个部门的日志也许就不需要IP和url字段,那么该部门得重新设计表结构。

CREATE TABLE log
(`ip` String,`time` Datetime,`url` String,`status` UInt8,`size` UInt32,`agent` String
)
ENGINE = MergeTree
ORDER BY date(time)

使用双数组或者Map的表结构查询效率不高

为了能够规避这些维护工作,所以很多公司对固定日志表结构进行了调整,常见的有两种方案,一种是双数组方案,另外一种就是Map方案。

Uber和Signoz的日志实现方案都是基于双数组

其日志表结构类似于下面这种

CREATE TABLE <table_name>
(//Common metadata fields._namespace             String,_timestamp              Int64,hostname               String,zone                   String,...//Raw log event._source                 String,//Type-specific field names and field values.string.names             Array(String),string.values            Array(String),number.names             Array(String), number.values            Array(Float64),bool.names               Array(String),bool.values              Array(UInt8),//Materialized fieldsbar.String,              Stringfoo.Number               Float64,...
)
...

滴滴、B站等日志实现是基于Map结构

引入Map结构能够动态实现日志关键字段搜索

CREATE TABLE ck_bamai_stream.cn_bmauto_local
(`logTime` Int64 DEFAULT 0, --Log打印的时间`logTimeHour` DateTime MATERIALIZED toStartOfHour(toDateTime(logTime / 1000)),--将Log`odinLeaf` String DEFAULT '',`uri` LowCardinality(String) DEFAULT '',`traceid` string DEFAULT '',`cspanid` String DEFAULT '',`dltag` String DEFAULT '',`spanid` String DEFAULT '',`message` String DEFAULT '',`otherColumn` Map<String,String>`_sys_insert_time` DateTime MATERIALIZED now()
)
ENGINE =MergeTree
PARTITION BY toYYYYMMDD(logTimeHour)
ORDER BY(logTimeHour,odinLeaf,uri,traceid)
TTL _sys_insert_time +toIntervalDay(7),_sys_insert_time + toIntervalDay(3)To VOLUME 'hdfs
SETTINGS index_granularity = 8192,min_bytes_for_wide_part=31457280
Create Table <log_app_name> ON CLUSTER ...
{_timestamp      Datetime64(3),`log,level`     String CODC(ZSTD(1)),`log.msg`       String CODC(ZSTD(1)),`log.trace_id`  String CODC(ZSTD(1)),...string_map MapV2(String, Nullable(String))CODEC(ZSTD(1))number_map MapV2(String, Nullable(Float64))CODEC(ZSTD(1))bool_map MapV2(String, Nullable(UInt8))
}
ENGIN = ReplicatedMergeTree(...)
PARTITION BY toYYYYMMDD(_timestamp)
ORDER BY timestamp
TTL  toDateTime(timestamp) + toIntervalDay(...),toDateTime(timestamp) + toIntervalDay(...) TO VOLUME `cold_volume`

Map的动态字段搜索效率低

https://clickhouse.ac.cn/docs/knowledgebase/improve-map-performance

根据社区反馈,map底层实现为线性数组,map查询效率通常低于列查询3~10倍,特别是日志量规模越大,map查询效率越低。

同时支持Map类型的最低clickhosue版本为21.11

  • 列式存储优势:
    ClickHouse 的核心优势在于它是列式存储数据库,这意味着当执行查询时,只需要读取查询中涉及的列,而不必加载不相关的列。列式存储还能够通过数据类型特定的压缩技术显著减少 IO 操作,从而加快查询速度

  • 基于 Map 的查询:
    Map 是一种键值对数据结构,在查询时需要额外的开销来解析嵌套结构,并且无法像列式存储那样直接跳过不相关的数据。虽然 ClickHouse 对 Map 数据类型有一些优化,但它在处理复杂结构时往往会比简单的列查询慢

双数组的搜索效率也不高

虽然 ClickHouse 对Array有一定的优化,但双数组结构仍然比单纯的列查询开销大。

影响性能的因素:

  • 多级解析开销:查询双数组时,需要进行多层嵌套解析。例如,访问数组中的子数组意味着需要遍历父数组,然后进一步解析子数组的结构,这比单纯读取一个列复杂得多
  • 随机存取:双数组的访问模式往往比简单的列查询更加随机化。访问数组中的元素可能导致更多的跳转,影响缓存命中率,从而降低性能
  • 内存使用和数据存储:嵌套数组会使得 ClickHouse 的数据存储和内存管理更加复杂,因为数组中的数据长度不固定,导致压缩效果比单纯列差,数据块的大小也更加难以优化

性能上的差距取决于具体的查询模式和数据结构:

  • 在简单查询场景(例如,读取一个基本的列数据),单纯的列查询会比双数组快得多,特别是在处理大规模数据时。性能差距可能达到 数倍甚至十倍 以上,尤其是当查询不涉及嵌套结构时。
  • 在复杂查询场景(例如,查询涉及嵌套数组、需要频繁地进行数组拆解和操作),双数组的查询性能通常明显低于单纯的列查询。查询双数组的额外解析和处理开销,会使查询时间增加。根据不同的嵌套深度和数据量,性能可能下降 数倍。

ClickHouse的官方文档中日志方案也由于引入了Map效率不高

ClickHouse官方blog :

https://ClickHouse.com/blog/storing-log-data-in-ClickHouse-fluent-bit-vector-open-telemetry

提到有以下几种表结构:

OTEL的日志字段表

CREATE TABLE otel.otel_logs
(`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),`TraceId` String CODEC(ZSTD(1)),`SpanId` String CODEC(ZSTD(1)),`TraceFlags` UInt32 CODEC(ZSTD(1)),`SeverityText` LowCardinality(String) CODEC(ZSTD(1)),`SeverityNumber` Int32 CODEC(ZSTD(1)),`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),`Body` String CODEC(ZSTD(1)),`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),`LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),//数据索引INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_log_attr_value mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,INDEX idx_body Body TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1

Vector 字段表

CREATE TABLE vector.vector_logs
(`file` String,`timestamp` DateTime64(3),`kubernetes_container_id` LowCardinality(String),`kubernetes_container_image` LowCardinality(String),`kubernetes_container_name` LowCardinality(String),`kubernetes_namespace_labels`  Map(LowCardinality(String), String),`kubernetes_pod_annotations`  Map(LowCardinality(String), String),`kubernetes_pod_ip` IPv4,`kubernetes_pod_ips` Array(IPv4),`kubernetes_pod_labels` Map(LowCardinality(String), String),`kubernetes_pod_name` LowCardinality(String),`kubernetes_pod_namespace` LowCardinality(String),`kubernetes_pod_node_name` LowCardinality(String),`kubernetes_pod_owner` LowCardinality(String),`kubernetes_pod_uid` LowCardinality(String),`message` String,`source_type` LowCardinality(String),`stream` Enum('stdout', 'stderr')
)
ENGINE = MergeTree
ORDER BY (`kubernetes_container_name`, timestamp)

fluent字段表

CREATE TABLE fluent.fluent_logs
(`timestamp` DateTime64(9),`log` String,`kubernetes` Map(LowCardinality(String), String),`host` LowCardinality(String),`pod_name` LowCardinality(String),`stream` LowCardinality(String),`labels` Map(LowCardinality(String), String),`annotations` Map(LowCardinality(String), String)
)
ENGINE = MergeTree
ORDER BY (host, pod_name, timestamp)

日志需要近似全文检索

基于ElasticSearch的日志方案,由于可以基于ElasticSearch实现的日志内容分词,所以很容易实现全文检索,但是基于ClickHouse就很难实现该功能。

那是不是基于ClickHouse的方案就完全没有办法呢?

ClickHouse的索引介绍

  • tokenbf_v1 按非字母数字字符(non-alphanumeric)拆分。相当于按符号分词,而通常日志中会有大量符号

在大牛的文章中,

https://juejin.cn/post/7130514546069864456

详细介绍了全文检索的实现,有兴趣的可以仔细看下大牛的文章。

最理想的日志方案应该满足什么条件?

我们认为理想的基于ClickHouse的日志方案应该满足以下几条:

  • 使用列来进行检索,而不是map或者双array,保证高效的查询效率
  • 用户不需要为了不同部门的日志内容,进行维护单独的表结构
  • 支持对原始日志内容进行近似的全文检索

我们调研了国内外几乎所有基于ClickHouse的日志方案,最后发现国内开源项目ClickVisual项目的思路最相近,ClickVisual几乎可以做到开箱即用。


ClickVisual的方案不足

ClickVisual工作原理:

  • 针对每种日志格式定义不同的parse规则,ClickVisual为每种规则生成一张新的日志表。该日志表存的就是解析之后的日志,之后的日志查询都是针对该日志表。因为解析之后的日志已经按照ClickHouse列存储了,所以关键字段查询是非常快的
  • 基于Kafka表引擎,读取Kafka原始日志,落库至临时表中
  • 魔术开始的地方:
    基于ClickHouse的物化视图,将原始日志中的新增日志_raw_log_内容按照日志解析规则parse成日志列格式,并将解析好的日志存入该规则对应的日志表中

临时表

CREATE TABLE default.test_stream
(`status` String,`timestamp` Float64,`message` String CODEC(ZSTD(1))
)
ENGINE = Kafka
SETTINGS kafka_broker_list = '127.0.0.1:9092',
kafka_topic_list = 'test',
kafka_group_name = 'default_test',
kafka_format = 'JSONEachRow', 
kafka_num_consumers = 1,
kafka_skip_broken_messages = 0

物化视图

CREATE MATERIALIZED VIEW default.test_view TO default.test
(`status` String,`_time_second_` DateTime,`_time_nanosecond_` DateTime64(9),`_raw_log_` String,// 日志表的列`level` Nullable(String)//根据需要调整列...
) AS
SELECTstatus,toDateTime(toInt64(timestamp)) AS _time_second_,fromUnixTimestamp64Nano(toInt64(timestamp * 1000000000)) AS _time_nanosecond_,message AS _raw_log_,// 物化视图处理成列toNullable(toString(replaceAll(JSONExtractRaw(message, 'level'), '"', ''))) AS level// 根据需要添加更多解析规则...
FROM default.test_stream
WHERE 1 = 1

按照日志解析规则将_raw_log_parse成新的真实日志表

CREATE TABLE default.test
(`status` String,`_time_second_` DateTime,`_time_nanosecond_` DateTime64(9),`_raw_log_` String CODEC(ZSTD(1)),// 该列通过物化视图解析得到`level` Nullable(String),// 根据需要添加更多列...INDEX idx_raw_log _raw_log_ TYPE tokenbf_v1(30720, 2, 0) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(_time_second_)
ORDER BY _time_second_
TTL toDateTime(_time_second_) + toIntervalDay(1)
SETTINGS index_granularity = 8192

每当需要分析新的索引字段,clickvisual会执行Add Colum为日志表添加新的列,同时更新物化视图添加新的解析处理规则。

ClickVisual的不足:

根据上述的原理:

  • 不支持高效的近似全文检索,工作原理可以看出由于ClickVisual并未对_raw_log_进行跳数索引,所以也就导致ClickVisual不能高效的支持近似的对原始日志全文检索
  • 由于ClickVisual完全依赖Kafka表引擎来实现日志的摄入,虽然ClickVisual也支持引入ClickHouse的已有日志表结构进行查询,但是很可能并不是直接针对列查询,而是针对map数据查询,只有通过Kafka引擎来摄入的日志才能生成新日志表结构,最终查询才是针对列式查询,才能有较高的查询效率
  • Kafka表引擎读写日志速度无法控制,如果日志量非常多,导致ClickHouse物化视图工作过程中内存爆掉

APO 日志设计方案

ClickVisual已经非常接近理想日志方案了,只是我们需要对ClickVisual的逻辑进行调整。

  1. 不使用ClickHouse的Kafka表引擎来完成日志的摄取工作,而是改成Vector的方式完成日志的摄取工作。这样就不再依赖Kafka,对于中小用户日志规模没有那么大的用户,可以直接使用,而不需要维护Kafka。虽然去掉了Kakfa,同时增加了Vector,但是Vector的运维工作相比kafka而言,Vector几乎不需要运维
  2. 引入了Vector之后,可以通过配置Vector来调整参数,确保在大量日志洪锋的时候,也不至于将ClickHouse内存打爆
  3. 用户如果真的需要引入Kafka,也有已经维护好的Kafka,完全可以使用Vector先将原始日志写入Kafka,然后使用Vector从Kafka读取出来,继续实现后续的日志处理
  4. APO引入了ClickHouse null表引擎,来实现原始日志(从Vector写入的) 转换成按照日志解析格式解析之后的真实日志表。
  5. 所有的查询都是针对真实日志表的列查询,所以性能比较高
  6. 在真实日志表中,额外存储了_raw_log_,配合跳数索引完成 近似日志全文检索。

欢迎使用APO全量日志功能
在这里插入图片描述


APO v0.6.0更新日志:

新增功能

  • 支持全量日志的采集、处理与展示功能

缺陷修复

  • 修复服务端点存在特殊字符时,无法获取到依赖延时曲线的问题
  • 修复无故障场景下频繁采集故障现场数据的问题
  • 修复部分场景下数据库调用无指标的问题
  • 修复传统服务器场景下,网络质量状态无法关联到告警的问题
  • 修复传统服务器场景下,OneAgent配置注入失败的问题

其他

  • 允许在创建 ClickHouse 表时选择是否创建副本
  • 向下兼容 ClickHouse 版本,当前支持最低版本为 22.8.x

APO介绍:

国内开源首个 OpenTelemetry 结合 eBPF 的向导式可观测性产品

apo.kindlingx.com

https://github.com/CloudDetail/apo

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

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

相关文章

【Echarts 实战指南】解锁动态历史曲线之谜

在工作中&#xff0c;大家是否曾遇到过这样一种需求呢&#xff1f;需获取设备最近 10 分钟的历史数据。设备实时数据每 2 秒推送一次&#xff0c;且要把历史数据曲线变成动态变化的状态。倘若设备最近 10 分钟的历史数据为 20 个点&#xff0c;那么现在每 2 秒就要将最前面的点…

计算机毕业设计Python深度学习房价预测 房源可视化 房源爬虫 二手房可视化 二手房爬虫 递归决策树模型 机器学习 深度学习 大数据毕业设计

温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 房地产是促进我国经济持续增…

车辆管理新篇章:SpringBoot技术解析

2相关技术 2.1 MYSQL数据库 MySQL是一个真正的多用户、多线程SQL数据库服务器。 是基于SQL的客户/服务器模式的关系数据库管理系统&#xff0c;它的有点有有功能强大、使用简单、管理方便、安全可靠性高、运行速度快、多线程、跨平台性、完全网络化、稳定性等&#xff0c;非常…

电感的学习

电感是表示电路中电流变化对电压影响的物理量&#xff0c;常用符号 LLL 表示。电感的基本公式可以从其定义和基本特性中得出&#xff0c;主要包括以下几个方面&#xff1a; 1. 电感的定义 2. 电感能量存储 3. 自感与互感 自感&#xff1a;电感器自身的电感&#xff0c;表示电…

stata基本操作

文章目录 数据导入及存储变量的标签、审视数据变量的标签审视数据数据删除数据排序 画图直方图使用帮助文件散点图 统计分析描述性分析频数分析相关分析 生成新变量、计算器、终止命令生成新变量设置哑变量修改变量名更改变量内容调用命令和终止命令 日志命令库更新、学习资源 …

从零开始学PHP之helloworld

前言 每一门编程语言的第一个程序就是输出hell world&#xff08;别杠&#xff0c;杠就是你对&#xff09; 开始 上一篇讲完了开发环境的安装&#xff0c;这次讲编辑器的安装&#xff0c;顺带完成上一篇的作业&#xff08;输出hello world&#xff09; 安装PHPstorm 我用的…

基于SpringBoot+Vue的旅游服务平台【提供源码+答辩PPT+参考文档+项目部署】

&#x1f4a5; ① 前言&#xff1a;这两年毕业设计和毕业答辩的要求和难度不断提升&#xff0c;传统的JavaWeb项目缺少创新和亮点&#xff0c;往往达不到毕业答辩的要求&#xff01; ❗② 如何解决这类问题&#xff1f; 让我们能够顺利通过毕业&#xff0c;我也一直在不断思考、…

用自己的数据集复现YOLOv5

yolov5已经出了很多版本了&#xff0c;这里我以目前最新的版本为例&#xff0c;先在官网下载源码&#xff1a;GitHub - ultralytics/yolov5: YOLOv5 &#x1f680; in PyTorch > ONNX > CoreML > TFLite 然后下载预训练模型&#xff0c;需要哪个就点击哪个模型就行&am…

机器视觉入门基础相关概念一 ——单目相机模型

机器视觉入门基础相关概念 相机模型 引言介绍&#xff1a;如果只是希望获取图像上的一些信息&#xff08;例如特征提取、拟合等&#xff09;&#xff0c;那么我们不会对三维空间中相机的位置有所要求。但如果希望通过二维的图像去理解三维空间中摄像机的信息&#xff0c;或者是…

【Qt】控件——Qt按钮类控件、常用的按钮类控件、按钮类控件的使用、Push Button、Radio Button、Check Box

文章目录 Qt3. Qt按钮类控件Push ButtonRadio ButtonCheck Box Qt 3. Qt按钮类控件 Push Button 使用 QPushButton 表示一个按钮。当点击按钮时可以触发各种事件。QPushButton 继承自 QAbstractButton。这个类是一个抽象类。是其他按钮的父类。 PushButton和QAbstractButton的…

阿里云linux系统扩容分区

系统扩容需要进行三步操作&#xff1a;①服务器扩容云盘 ② 扩容分区 ③ 扩容文件系统 参考&#xff1a;https://help.aliyun.com/zh/ecs/user-guide/extend-the-partitions-and-file-systems-of-disks-on-a-linux-instance?spma2c4g.11186623.0.0.6a094862DCMnnM#de3365e1d4l…

国内如何下载谷歌浏览器(chrome浏览器)历史版本和chromedriver驱动,长期更新,建议收藏

众所周知&#xff0c;google是一直被国内屏蔽的&#xff0c;有时候想要下载个chrome浏览器都要去外网&#xff0c;或者到处去搜索才能下载到。因为下载chrome浏览器的这个网址&#xff1a;google.com/chrome/ 在国内是一直被屏蔽掉的。 今天主要讲解的是国内ChromeDriver 的下…

mac安装brew时踩坑解决方案

安装包 mac上如果按照git等工具可能会使用brew&#xff0c;例如使用&#xff1a;$ brew install git命令&#xff0c;如果电脑没有按照brew&#xff0c;则会提示&#xff1a;zsh: command not found: brew 解决方案 需要我们打开brew的官网https://brew.sh/&#xff0c;复制…

spring |Spring Security安全框架 —— 认证流程实现

文章目录 开头简介环境搭建入门使用1、认证1、实体类2、Controller层3、Service层3.1、接口3.2、实现类3.3、实现类&#xff1a;UserDetailsServiceImpl 4、Mapper层3、自定义token认证filter 注意事项小结 开头 Spring Security 官方网址&#xff1a;Spring Security官网 开…

Java @RequestPart注解:同时实现文件上传与JSON对象传参

RequestPart注解&#xff1a;用于处理multipart/form-data请求的一部分&#xff0c;通常用于文件上传或者处理表单中的字段。 java后端举例&#xff1a; PostMapping("/fileTest")public AjaxResult fileTest(RequestPart("file") MultipartFile file,Req…

2023年五一杯数学建模C题双碳目标下低碳建筑研究求解全过程论文及程序

2023年五一杯数学建模 C题 双碳目标下低碳建筑研究 原题再现&#xff1a; “双碳”即碳达峰与碳中和的简称&#xff0c;我国力争2030年前实现碳达峰&#xff0c;2060年前实现碳中和。“双碳”战略倡导绿色、环保、低碳的生活方式。我国加快降低碳排放步伐&#xff0c;大力推进…

32. 问题 - EQ工具不能用

1. 概述 按照工具要求,修改代码中的宏,开启EQ调试功能

位运算题目-Java实现-LeetCode题解:判断字符是否唯一-丢失的数字-两整数之和-只出现一次的数字 II-消失的两个数字

这里是Themberfue 上一篇文章讲完了常见位运算的技巧以及总结 那么本章则通过五道题来运用这些技巧 判定字符是否唯一 题目解析 本题要求判断给定字符串中的字符是否唯一&#xff0c;也就是每个字符是否只出现一次 算法讲解 本题用哈希表遍历每一个字符也可以解决 如果这题使…

网络空间安全之一个WH的超前沿全栈技术深入学习之路(二:渗透测试行业术语扫盲)作者——LJS

欢迎各位彦祖与热巴畅游本人专栏与博客 你的三连是我最大的动力 以下图片仅代表专栏特色 [点击箭头指向的专栏名即可闪现] 专栏跑道一 ➡️网络空间安全——全栈前沿技术持续深入学习 专栏跑道二 ➡️ 24 Network Security -LJS ​ ​ ​ 专栏跑道三 ➡️ MYSQL REDIS Advan…

Java Memory Model

内存模型 JMM(Java 内存模型)主要定义了对于一个共享变量&#xff0c;当另一个线程对这个共享变量执行写操作后&#xff0c;这个线程对这个共享变量的可见性。 Java Memory Model&#xff08;JMM&#xff09;&#xff0c;本身是一种抽象的概念&#xff0c;实际上并不存在&…