Elastic Stack梳理:北京空气质量数据分析实战之从数据建模到可视化洞察与NestJS集成方案

项目背景与数据准备


数据来源:美国大使馆公开的北京空气质量CSV数据集(2008–2017年),包含每小时记录的PM2.5浓度值(字段:city, parameter, date, year, month, day, hour, value
核心问题:

  1. 北京空气质量多年趋势是否改善?
  2. 2016年底公众感知的雾霾加剧与官方数据矛盾的原因

数据特点:

  • 时间粒度:每小时一条记录(需聚合到日/月/年维度分析)
  • 数据质量:包含缺失值(标记为-99),需过滤无效数据

1 ) 数据建模

  • 索引设计:

    • 索引名:airquality(原始小时数据)、airquality_days(聚合后的日维度数据)
    • 字段类型:value(浮点数)、date(日期类型),禁用分词器(因数值型数据无需文本分析)
  • 索引结构:字段包括city(城市)、parameter(参数类型,如PM2.5)、date(日期)、value(监测值)

  • 设计原则:禁用分词("index": false),数值型字段使用float类型,日期字段定义为date格式

索引定义示例

// Elasticsearch Mapping示例
PUT /air_quality
{
  "mappings": {
    "properties": {
      "date": { "type": "date" },
      "value": { "type": "float" },
      "city": { "type": "keyword" },
      "parameter": { "type": "keyword" }
    }
  }
}

2 ) 数据导入

  • 工具:filebeat + ingest pipeline
  • 关键处理:
    • 排除无效行(如以a/d/c开头的行)
    • 通过grok解析CSV字段:
      %{DATA:city},%{DATA:parameter},%{DATA:date},%{INT:year},%{INT:month},%{INT:day},%{INT:hour},%{NUMBER:value}
      
    • 生成唯一ID:city+date组合避免重复
    • 移除冗余字段(如year/month/day,由date派生)
  • 数据导入流程:
    • 使用Filebeat + Ingest Node管道处理CSV
    • 关键步骤:
      • 排除无效行(如以adc开头的行)
      • 生成唯一ID(city+date组合),避免重复导入
      • 转换时间戳格式(如date字段解析为ISO格式)
      • 移除冗余字段(如duration

或者参考 logstash 的方式

input {  
  file {  
    path => "/data/airquality.csv"  
    start_position => "beginning"  
    exclude => ["#*"]  # 跳过注释行  
  }  
}  
filter { csv { separator => "," } }  
output {  
  elasticsearch {  
    hosts => ["localhost:9200"]  
    index => "airquality"  
    pipeline => "airquality_pipeline"  
    document_id => "%{site}_%{date}"  # 防重复导入  
  }  
}  

3 ) Elasticsearch Ingest Pipeline 配置:

PUT _ingest/pipeline/airquality_pipeline  
{  
  "description": "Process Beijing air quality CSV",  
  "processors": [  
    {  
      "grok": {  
        "field": "message",  
        "patterns": [  
          "%{WORD:city},%{WORD:parameter},%{DATE:date},%{INT:year},%{INT:month},%{INT:day},%{INT:hour},%{NUMBER:value}"  
        ]  
      }  
    },  
    {  
      "set": {  
        "field": "_id",  
        "value": "{{city}}_{{date}}"  
      }  
    },  
    {  
      "date": {  
        "field": "date",  
        "formats": ["yyyy-MM-dd HH:mm:ss"],  
        "target_field": "@timestamp"  
      }  
    },  
    {  
      "remove": {  
        "field": ["message", "duration", "error"]  
      }  
    },  
    {  
      "convert": {  
        "field": "value",  
        "type": "float"  
      }  
    }  
  ]  
}  

数据聚合与分析策略

目标:将小时数据聚合为日维度(减少粒度,便于趋势分析)。
Python聚合脚本核心逻辑:

from elasticsearch import Elasticsearch
 
es = Elasticsearch()
query = {
  "query": {"range": {"value": {"gte": 1}}},  # 过滤无效值(如-99)
  "aggs": {
    "daily": {
      "date_histogram": {"field": "date", "calendar_interval": "1d"},
      "aggs": {"avg_pm25": {"avg": {"field": "value"}}}
    }
  }
}
res = es.search(index="air_quality", body=query)
 
# 写入新索引 air_quality_daily
for bucket in res['aggregations']['daily']['buckets']:
    doc = {
        "date": bucket['key_as_string'],
        "avg_pm25": bucket['avg_pm25']['value'],
        "year": datetime.strptime(bucket['key_as_string'], "%Y-%m-%d").year,
        "month": datetime.strptime(bucket['key_as_string'], "%Y-%m-%d").month
    }
    es.index(index="air_quality_daily", document=doc)

或参考如下

目标:将小时数据聚合为日维度(计算日均值/最大值/最小值),存入新索引airquality_days

from elasticsearch import Elasticsearch  
 
es = Elasticsearch()  
 
# 聚合查询:按日分组,计算PM2.5统计值  
query = {  
  "size": 0,  
  "query": { "range": { "value": { "gte": 1 } } },  # 过滤无效值  
  "aggs": {  
    "days": {  
      "date_histogram": {  
        "field": "@timestamp",  
        "calendar_interval": "1d"  
      },  
      "aggs": {  
        "avg_value": { "avg": { "field": "value" } },  
        "max_value": { "max": { "field": "value" } },  
        "min_value": { "min": { "field": "value" } }  
      }  
    }  
  }  
}  
 
response = es.search(index="airquality", body=query)  
 
# 写入新索引  
for day in response["aggregations"]["days"]["buckets"]:  
    doc = {  
        "date": day["key_as_string"],  
        "year": day["key_as_string"][:4],  
        "month": day["key_as_string"][5:7],  
        "avg_value": day["avg_value"]["value"],  
        "max_value": day["max_value"]["value"],  
        "min_value": day["min_value"]["value"]  
    }  
    es.index(index="airquality_days", document=doc)  

数据分析与可视化实现


关键问题解答与数据聚合:

  1. 多年趋势分析:

    • 将小时数据聚合为日维度(计算每日PM2.5的maxminavg),使用Python脚本:
      from elasticsearch import Elasticsearch  
      es = Elasticsearch()  
      # 聚合查询:按天分组,计算PM2.5统计值  
      query = {  
          "size": 0,  
          "query": {"range": {"value": {"gte": 1}}},  # 过滤无效值(如-99)  
          "aggs": {  
              "days": {  
                  "date_histogram": {"field": "date", "calendar_interval": "1d"},  
                  "aggs": {"pm25_stats": {"stats": {"field": "value"}}}  
              }  
          }  
      }  
      response = es.search(index="airquality", body=query)  
      # 写入新索引 airquality_days  
      for bucket in response['aggregations']['days']['buckets']:  
          doc = {  
              "date": bucket['key_as_string'],  
              "year": bucket['key_as_string'][:4],  
              "month": bucket['key_as_string'][5:7],  
              "day": bucket['key_as_string'][8:10],  
              "pm25_max": bucket['pm25_stats']['max'],  
              "pm25_avg": bucket['pm25_stats']['avg']  
          }  
          es.index(index="airquality_days", document=doc)  
      
  2. Kibana可视化结论:

    • 趋势图表:堆叠柱状图展示不同空气质量等级(按AQI划分)的年度占比。
      • AQI分级:
        • Good (0–50):绿色
        • Moderate (51–100):黄色
        • Unhealthy (101–200):橙色
        • Very Unhealthy (>200):红色
      • 结论:2008–2017年,蓝天(AQI<150)占比从38%升至47%,整体改善。
    • 2016年底矛盾解析:
      • 冬季(2016年10月–2017年2月)雾霾天数(AQI>200)达60天(2015年仅45天),且PM2.5均值更高,导致公众感知恶化。

Kibana高级图表实现:

  • 动态字段计算(Scripted Field):
    def pm25 = doc['pm25_max'].value;  
    if (pm25 <= 50) return "1-Good";  
    else if (pm25 <= 100) return "2-Moderate";  
    else if (pm25 <= 150) return "3-Unhealthy";  
    else return "4-VeryUnhealthy";  
    
  • 时间对比(Offset):
    # 比较2016年与2015年冬季数据  
    es_query: {  
      "aggs": {  
        "2016": { "avg": { "field": "pm25_avg" } },  
        "2015": { "avg": { "field": "pm25_avg", "offset": "-1y" } }  
      }  
    }  
    

Kibana可视化分析


1 )核心问题1:空气质量长期趋势

分析结论:

  • 2008–2017年北京蓝天占比(AQI≤100)从32%升至47%,污染天数比例下降,整体持续改善。

可视化方案:

  1. 堆叠柱状图(按年统计AQI等级分布)

    • 关键配置:
      • Y轴:count
      • X轴:date_histogram(年间隔)
      • 拆分系列:scripted_field(AQI等级)
    • 等级计算脚本(Kibana Scripted Field):
      if (doc['max_value'].value <= 50) return "1_good";  
      else if (doc['max_value'].value <= 100) return "2_moderate";  
      else if (doc['max_value'].value <= 150) return "3_unhealthy_sensitive";  
      // ... 其他等级  
      
  2. 百分比面积图(简化空气质量趋势)

    • 三类分组:
      • Good (AQI≤50)
      • Unhealthy (AQI>100)
      • Very Unhealthy (AQI>200)
    • 配置要点:启用stacked as percentage模式。

2 ) 核心问题2:2016年底雾霾感知矛盾

分析结论:

  • 全年数据:2016年蓝天占比(47%)高于2015年(43%)。
  • 冬季数据:2016年冬季PM2.5均值比2015年高18%,雾霾天(AQI>200)占比达40%(2015年为27%)。

可视化对比:

  1. 时间序列对比图(2015 vs 2016冬季)

    // Kibana TSVB表达式  
    {  
      "expression": "es_index='airquality_days' | where max_value>150 | divide={math 'count()/total'} | multiply=100 | label='2016'",  
      "series": [  
        {  
          "expression": "offset=-1y",  
          "label": "2015"  
        }  
      ]  
    }  
    
  2. 每日AQI热力图

    • X轴:日期(日间隔)
    • Y轴:AQI等级(rate_level
    • 颜色:PM2.5浓度值(max_value

工程示例:1


1 ) 方案1:基础数据导入与查询服务

依赖安装:

npm install @nestjs/elasticsearch @elastic/elasticsearch  

NestJS模块配置(elastic.module.ts):

import { Module } from '@nestjs/common';  
import { ElasticsearchModule } from '@nestjs/elasticsearch';  
 
@Module({  
  imports: [  
    ElasticsearchModule.register({  
      node: 'http://localhost:9200',  
      auth: { username: 'elastic', password: 'your_password' }  
    }),  
  ],  
  exports: [ElasticsearchModule],  
})  
export class ElasticModule {}  

数据导入服务(air-import.service.ts):

import { Injectable } from '@nestjs/common';  
import { ElasticsearchService } from '@nestjs/elasticsearch';  
import * as fs from 'fs';  
import * as csv from 'csv-parser';  
 
@Injectable()  
export class AirImportService {  
  constructor(private readonly esService: ElasticsearchService) {}  
 
  async importCSV(filePath: string): Promise<void> {  
    const stream = fs.createReadStream(filePath).pipe(csv());  
    const bulkActions = [];  
 
    stream.on('data', (row) => {  
      bulkActions.push({ index: { _index: 'airquality' } });  
      bulkActions.push({  
        city: row['Site'],  
        date: `${row['Year']}-${row['Month']}-${row['Day']} ${row['Hour']}:00:00`,  
        value: parseFloat(row['Value']),  
        parameter: 'PM2.5'  
      });  
    });  
 
    stream.on('end', async () => {  
      await this.esService.bulk({ body: bulkActions });  
      console.log(`Imported ${bulkActions.length / 2} records`);  
    });  
  }  
}  

2 ) 方案2:聚合分析与API暴露
聚合查询服务(air-analysis.service.ts):

import { Injectable } from '@nestjs/common';  
import { ElasticsearchService } from '@nestjs/elasticsearch';  
 
@Injectable()  
export class AirAnalysisService {  
  constructor(private readonly esService: ElasticsearchService) {}  
 
  async getYearlyAQI(year: number): Promise<any> {  
    const response = await this.esService.search({  
      index: 'airquality_days',  
      body: {  
        size: 0,  
        query: { term: { year } },  
        aggs: {  
          aqi_levels: {  
            terms: { field: 'rate_level' }  // 使用Scripted Field  
          }  
        }  
      }  
    });  
    return response.aggregations.aqi_levels.buckets;  
  }  
}  

控制器(air.controller.ts):

import { Controller, Get, Param } from '@nestjs/common';  
import { AirAnalysisService } from './air-analysis.service';  
 
@Controller('air')  
export class AirController {  
  constructor(private readonly analysisService: AirAnalysisService) {}  
 
  @Get('yearly/:year')  
  async getYearlyData(@Param('year') year: number) {  
    return this.analysisService.getYearlyAQI(year);  
  }  
}  

3 )方案3:实时监控与告警系统
Elasticsearch Watcher 配置:

PUT _watcher/watch/pm25_alert  
{  
  "trigger": { "schedule": { "interval": "1h" } },  
  "input": {  
    "search": {  
      "request": {  
        "indices": ["airquality"],  
        "body": {  
          "query": {  
            "range": { "value": { "gte": 200 } }  // PM2.5 > 200触发告警  
          }  
        }  
      }  
    }  
  },  
  "actions": {  
    "email_alert": {  
      "email": {  
        "to": "admin@example.com",  
        "subject": "High PM2.5 Alert",  
        "body": "PM2.5 levels exceeded 200 at {{ctx.payload.hits.total.value}} locations."  
      }  
    }  
  }  
}  

NestJS 告警订阅服务:

import { Injectable } from '@nestjs/common';  
import { ElasticsearchService } from '@nestjs/elasticsearch';  
 
@Injectable()  
export class AlertService {  
  constructor(private readonly esService: ElasticsearchService) {}  
 
  async subscribeAlerts(): Promise<void> {  
    // 模拟Watcher回调(生产环境用Webhook)  
    setInterval(async () => {  
      const result = await this.esService.search({  
        index: 'airquality',  
        body: { query: { range: { value: { gte: 200 } } } }  
      });  
      if (result.hits.total.value > 0) {  
        this.sendAlert(result.hits.total.value);  
      }  
    }, 3600000); // 每小时检查  
  }  
 
  private sendAlert(count: number): void {  
    console.log(`ALERT: ${count} locations with PM2.5 > 200!`);  
    // 集成短信/邮件服务(如Nodemailer)  
  }  
}  

工程示例:2


1 ) 方案1:基础客户端调用

import { Injectable } from '@nestjs/common';
import { Client } from '@elastic/elasticsearch';
 
@Injectable()
export class ElasticService {
  private readonly client: Client;
 
  constructor() {
    this.client = new Client({ node: 'http://localhost:9200' });
  }
 
  async searchAirQuality(query: any) {
    return this.client.search({
      index: 'air_quality_daily',
      body: query 
    });
  }
}

2 ) 方案2:模块化封装(Repository模式)

// elastic.module.ts
import { Module } from '@nestjs/common';
import { ElasticsearchModule } from '@nestjs/elasticsearch';
 
@Module({
  imports: [ElasticsearchModule.register({ node: 'http://localhost:9200' })],
  exports: [ElasticsearchModule],
})
export class ElasticModule {}
 
// air-quality.repository.ts
import { Injectable } from '@nestjs/common';
import { ElasticsearchService } from '@nestjs/elasticsearch';
 
@Injectable()
export class AirQualityRepository {
  constructor(private readonly esService: ElasticsearchService) {}
 
  async getDailySummary(year: number) {
    return this.esService.search({
      index: 'air_quality_daily',
      body: { query: { match: { year } } }
    });
  }
}

3 ) 方案3:高级配置(动态索引+管道)

import { DynamicModule, Global } from '@nestjs/common';
import { Client, ClientOptions } from '@elastic/elasticsearch';
 
@Global()
@Injectable()
export class ConfigurableElasticService {
  private client: Client;
 
  init(options: ClientOptions) {
    this.client = new Client(options);
  }
 
  async createPipeline(id: string, pipelineConfig: any) {
    return this.client.ingest.putPipeline({ id, body: pipelineConfig });
  }
}
 
// 使用示例
const elasticService = new ConfigurableElasticService();
elasticService.init({ node: 'http://prod-es:9200' });
elasticService.createPipeline('air_quality_pipeline', { ... });

工程示例:3


1 ) 方案1:基础数据导入服务

import { Controller, Post } from '@nestjs/common';  
import { ElasticsearchService } from '@nestjs/elasticsearch';  
 
@Controller('data')  
export class DataImportController {  
  constructor(private readonly esService: ElasticsearchService) {}  
 
  @Post('import')  
  async importData() {  
    const csvData = await this.readCSV('airquality.csv');  
    const body = csvData.flatMap(doc => [  
      { index: { _index: 'airquality', _id: `${doc.site}_${doc.date}` } },  
      doc  
    ]);  
 
    await this.esService.bulk({ body });  
  }  
 
  private async readCSV(path: string): Promise<any[]> {  
    // 使用csv-parser实现(略)  
  }  
}  

2 ) 方案2:动态聚合查询API

import { Body, Query } from '@nestjs/common';  
 
@Get('aggregate')  
async aggregate(@Query() params: { level: string }) {  
  const query = {  
    aggs: {  
      yearly_stats: {  
        date_histogram: { field: "date", calendar_interval: "1y" },  
        aggs: { level_count: { filter: { range: { max_value: this.getRange(params.level) } } } }  
      }  
    }  
  };  
  return this.esService.search({ index: 'airquality_days', body: query });  
}  
 
private getRange(level: string) {  
  const ranges = {  
    good: { lte: 50 },  
    unhealthy: { gt: 100, lte: 200 }  
    // ...  
  };  
  return ranges[level];  
}  

3 ) 方案3:实时脚本字段处理

// 在NestJS中通过Elasticsearch动态更新Mapping  
async setScriptedField() {  
  await this.esService.indices.putMapping({  
    index: 'airquality_days',  
    body: {  
      properties: {  
        rate_level: {  
          type: "keyword",  
          script: {  
            source: `  
              def val = doc['max_value'].value;  
              if (val <= 50) return "1_good";  
              else if (val <= 100) return "2_moderate";  
              // ...  
            `  
          }  
        }  
      }  
    }  
  });  
}  

技术细节与最佳实践


  1. 性能优化:
  • 索引分片:按时间范围分片(如yearly-2016),提升查询效率
  • 冷热架构:将历史数据迁移至冷节点(使用ILM策略)
  • 聚合时优先使用filter替代query缩小数据集范围。
  • 对于时间序列数据,启用index.sort加速范围查询:
    PUT /airquality_days/_settings  
    { "index": { "sort.field": ["date"], "sort.order": ["desc"] } }  
    
  1. 数据准确性:
    • 使用Pipeline错误处理(on_failure回调),记录导入失败数据
    • 数据校验:在NestJS服务层添加Joi验证
  2. 数据一致性
    • 使用document_id防止重复导入(如site_date组合)。
    • 管道中remove无用字段减少存储开销。
  3. 可扩展性:
    • 微服务集成:通过Kafka将数据流同步至Elasticsearch
    • 集群部署:配置3节点ES集群(1主 + 2数据节点)
  4. Kibana高级技巧
    • 动态对比:利用offset=-1y自动对比去年同期数据
    • 百分比模式:在面积图中启用stack as percentage直观显示占比变化
    • 热力图配置:Y轴使用terms分桶(AQI等级),X轴用date_histogram(日粒度)

Kibana仪表板实现关键技术


  1. 堆叠柱状图(年度趋势)

    • 配置要点:
      • Metrics:按AQI等级拆分(Filters Aggregation)。
      • Bucket:按年聚合(Date Histogram,间隔1年)。
      • 堆叠模式:Percentage(显示占比趋势)。
  2. 时间对比(YoY分析)

    • Offset应用:直接对比同年期数据。
      "aggs": {
        "current_year": { "avg": { "field": "value" } },
        "previous_year": { "avg": { "field": "value", "offset": "-1y" } }
      }
      

环境配置与优化建议


  1. Elasticsearch配置:
    • 启用索引生命周期管理(ILM),按时间滚动存储(如按月分片)。
    • 设置refresh_interval: 30s提升写入性能。
  2. NestJS最佳实践:
    • 使用拦截器统一处理ES请求异常。
    • 环境变量管理ES连接参数(通过ConfigModule)。

学习资源与延伸实践


资源类别推荐内容
官方文档Elasticsearch Aggregations
数据集Kaggle空气质量数据集
实战案例使用ecommerce样例数据构建商品销售分析看板
社区支持Elastic中文社区(搜索@rockbean提问)

关键建议:

  • 内外兼修:深入理解DSL查询而不仅依赖Kibana界面操作
  • 版本迭代:关注ES 7.x+的SQL查询特性

延伸学习资源


  1. 官方文档:Elasticsearch Docs
  2. 数据集:
  3. 性能优化:
    • 冷热数据分层(Hot-Warm架构)
    • 向量化查询(knn_search

核心总结:通过数据粒度聚合、动态字段计算、时间偏移分析,验证了北京空气质量长期改善与短期波动并存的现象。技术关键在于精准的管道设计与Kibana可视化深度配置。

总结与学习路径


核心结论:

  • 北京空气质量整体改善(2008–2017年蓝天占比↑),但2016年冬季雾霾加剧(连续污染天数增多)
  • 技术验证:Elastic Stack可高效处理时序数据分析,结合NestJS构建生产级应用

通过本项目,我们实现了:

  1. 数据全链路处理:原始CSV → ES索引 → 聚合二次存储 → Kibana可视化。
  2. 矛盾问题解析:结合长期趋势与季节差异,解释公众感知与官方数据的偏差。
  3. 工程化落地:提供NestJS与ES集成的三种生产级方案。

更多学习资源:

  1. Elastic官方文档
  2. Elastic中文社区(用户@rockbean)
  3. 公开数据集:KaggleUCI Machine Learning Repository

关键建议:

  • 内外兼修:深入理解ES原理(如倒排索引、分片机制),而非仅关注可视化
  • 实践驱动:使用公开数据集复现分析流程,并扩展至电商日志、用户行为等场景
  • 版本迭代:ES 6.x至8.x的核心原理不变,关注新特性(如SQL查询、向量检索)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wang's Blog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值