wenmo8 发布的文章

系列文章

DateHistogram
用于根据日期或时间数据进行分桶聚合统计。它允许你将时间序列数据按照指定的时间间隔进行分组,从而生成统计信息,例如每小时、每天、每周或每月的数据分布情况。

Elasticsearch 就支持 DateHistogram 聚合,在关系型数据库中,可以使用
GROUP BY
配合日期函数来实现时间分桶。但是当数据基数特别大时,或者时间分桶较多时,这个聚合速度就非常慢了。如果前端想呈现一个时间分桶的 Panel,这个后端接口的响应速度将非常感人。

我决定用 Flink 做一个实时的 DateHistogram。

实验设计

场景就设定为从 Kafka 消费数据,由 Flink 做实时的时间分桶聚合,将聚合结果写入到 MySQL。

源端-数据准备

Kafka 中的数据格式如下,为简化程序,测试数据做了尽可能的精简:

testdata.json

{
	"gid" : "001254500828905",
	"timestamp" : 1620981709790
}

KafkaDataProducer
源端数据生成程序,要模拟数据乱序到达的情况:

package org.example.test.kafka;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Properties;
import java.util.Random;
import java.util.Scanner;

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;

import com.alibaba.fastjson.JSONObject;

public class KafkaDataProducer {

	private static final String TEST_DATA = "testdata.json";
	private static final String GID = "gid";
	private static final String TIMESTAMP = "timestamp";
	
	// 定义日志颜色
	public static final String reset = "\u001B[0m";
	public static final String red = "\u001B[31m";
	public static final String green = "\u001B[32m";
	
	public static void main(String[] args) throws InterruptedException {
		Properties props = new Properties();
		String topic = "trace-2024";
		props.put("bootstrap.servers", "127.0.0.1:9092");
		props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
		props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
		Producer<String, String> producer = new KafkaProducer<String, String>(props);
		
		InputStream inputStream = KafkaDataProducer.class.getClassLoader().getResourceAsStream(TEST_DATA);
		Scanner scanner = new Scanner(inputStream, StandardCharsets.UTF_8.name());
        String content = scanner.useDelimiter("\\A").next();
        scanner.close();
		JSONObject jsonContent = JSONObject.parseObject(content);
		
		int totalNum = 2000;
		Random r = new Random();
		for (int i = 0; i < totalNum; i++) {
			// 对时间进行随机扰动,模拟数据乱序到达
			long current = System.currentTimeMillis() - r.nextInt(60) * 1000;
			jsonContent.put(TIMESTAMP, current);
			producer.send(new ProducerRecord<String, String>(topic, jsonContent.toString()));
			// wait some time
			Thread.sleep(2 * r.nextInt(10));
			System.out.print("\r" + "Send " + green + (i + 1) + "/" + totalNum + reset + " records to Kafka");
		}
		Thread.sleep(2000);
		producer.close();
		System.out.println("发送记录总数: " + totalNum);
	}
}

目标端-表结构设计

MySQL 的表结构设计:

CREATE TABLE `flink`.`datehistogram` (
  `bucket` varchar(255) PRIMARY KEY,
  `count` bigint
);

bucket 列用于存储时间分桶,形如
[09:50:55 - 09:51:00]
,count 列用于存储对应的聚合值。

实现

maven 依赖:

<dependencies>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-runtime-web</artifactId>
        <version>${flink.version}</version>
    </dependency>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-streaming-java</artifactId>
        <version>${flink.version}</version>
    </dependency>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-connector-kafka</artifactId>
        <version>${flink.version}</version>
    </dependency>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-connector-jdbc</artifactId>
        <version>3.1.2-1.17</version>
    </dependency>
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-statebackend-rocksdb</artifactId>
        <version>${flink.version}</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.27</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.72</version>
    </dependency>
</dependencies>

BucketCount
类用于转换 Kafka 中的数据为时间分桶格式,并便于聚合:

package org.example.flink.data;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

public class BucketCount {

	private long timestamp;
	private String bucket;
	private long count;

	public BucketCount(long timestamp) {
		this.timestamp = timestamp;
		this.bucket = formatTimeInterval(timestamp);
		this.count = 1;
	}
	
	public BucketCount(String bucket, long count) {
		this.bucket = bucket;
		this.count = count;
	}
	
	/**
	 * 将时间戳格式化为时间区间格式
	 * 
	 * @param time
	 * @return 例如 [11:28:00 — 11:28:05]
	 */
	private String formatTimeInterval(long time) {
        // 定义输出的日期时间格式
        DateTimeFormatter outputFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");

        // 将时间戳转换为 LocalDateTime 对象
        LocalDateTime dateTime = Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault()).toLocalDateTime();

        // 提取秒数并计算区间开始时间
        int seconds = dateTime.getSecond();
        int intervalStartSeconds = (seconds / 5) * 5;

        // 创建区间开始和结束时间的 LocalDateTime 对象
        LocalDateTime intervalStartTime = dateTime.withSecond(intervalStartSeconds);
        LocalDateTime intervalEndTime = intervalStartTime.plusSeconds(5);

        // 格式化区间开始和结束时间为字符串
        String startTimeString = intervalStartTime.format(outputFormatter);
        String endTimeString = intervalEndTime.format(outputFormatter);

        // 返回格式化后的时间区间字符串
        return startTimeString + "-" + endTimeString;
    }
    
    // 省略Getter, Setter
}

RealTimeDateHistogram
类完成流计算:

package org.example.flink;

import java.time.Duration;

import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.functions.ReduceFunction;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.connector.jdbc.JdbcConnectionOptions;
import org.apache.flink.connector.jdbc.JdbcExecutionOptions;
import org.apache.flink.connector.jdbc.JdbcSink;
import org.apache.flink.connector.kafka.source.KafkaSource;
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.contrib.streaming.state.EmbeddedRocksDBStateBackend;
import org.apache.flink.streaming.api.datastream.DataStreamSink;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.triggers.ProcessingTimeTrigger;
import org.example.flink.data.BucketCount;

import com.alibaba.fastjson.JSONObject;

public class RealTimeDateHistogram {

	public static void main(String[] args) throws Exception {
		// 1. prepare
		Configuration configuration = new Configuration();
		configuration.setString("rest.port", "9091");
		StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration);
		env.enableCheckpointing(2 * 60 * 1000);
		// 使用rocksDB作为状态后端
		env.setStateBackend(new EmbeddedRocksDBStateBackend());
		
		// 2. Kafka Source
		KafkaSource<String> source = KafkaSource.<String>builder()
			.setBootstrapServers("127.0.0.1:9092")
			.setTopics("trace-2024")
			.setGroupId("group-01")
		    .setStartingOffsets(OffsetsInitializer.latest())
		    .setProperty("commit.offsets.on.checkpoint", "true")
		    .setValueOnlyDeserializer(new SimpleStringSchema())
		    .build();

		DataStreamSource<String> sourceStream = env.fromSource(source, WatermarkStrategy.noWatermarks(),
				"Kafka Source");
		sourceStream.setParallelism(2);	// 设置source算子的并行度为2
		
		// 3. 转换为易于统计的BucketCount对象结构{ bucket: 00:00, count: 200 }
		SingleOutputStreamOperator<BucketCount> mapStream = sourceStream
			.map(new MapFunction<String, BucketCount>() {
				@Override
				public BucketCount map(String value) throws Exception {
					JSONObject jsonObject = JSONObject.parseObject(value);
					long timestamp = jsonObject.getLongValue("timestamp");
					return new BucketCount(timestamp);
				}
			});
		mapStream.name("Map to BucketCount");
		mapStream.setParallelism(2);	// 设置map算子的并行度为2
		
		// 4. 设置eventTime字段作为watermark,要考虑数据乱序到达的情况
		SingleOutputStreamOperator<BucketCount> mapStreamWithWatermark = mapStream
			.assignTimestampsAndWatermarks(
			    WatermarkStrategy.<BucketCount>forBoundedOutOfOrderness(Duration.ofSeconds(60))
					.withIdleness(Duration.ofSeconds(60))
					.withTimestampAssigner(new SerializableTimestampAssigner<BucketCount>() {
						@Override
						public long extractTimestamp(BucketCount bucketCount, long recordTimestamp) {
							// 提取eventTime字段作为watermark
							return bucketCount.getTimestamp();
						}
					}));
		mapStreamWithWatermark.name("Assign EventTime as Watermark");
		
		// 5. 滚动时间窗口聚合
		SingleOutputStreamOperator<BucketCount> windowReducedStream = mapStreamWithWatermark
			.windowAll(TumblingEventTimeWindows.of(Time.seconds(5L)))	// 滚动时间窗口
			.trigger(ProcessingTimeTrigger.create()) // ProcessingTime触发器
			.allowedLateness(Time.seconds(120))  	 // 数据延迟容忍度, 允许数据延迟乱序到达
			.reduce(new ReduceFunction<BucketCount>() {
				@Override
				public BucketCount reduce(BucketCount bucket1, BucketCount bucket2) throws Exception {
					// 将两个bucket合并,count相加
					return new BucketCount(bucket1.getBucket(), bucket1.getCount() + bucket2.getCount());
				}
			});
		windowReducedStream.name("Window Reduce");
		windowReducedStream.setParallelism(1);		// reduce算子的并行度只能是1
		
		// 6. 将结果写入到数据库
		DataStreamSink<BucketCount> sinkStream = windowReducedStream.addSink(
				JdbcSink.sink("insert into flink.datehistogram(bucket, count) values (?, ?) "
						+ "on duplicate key update count = VALUES(count);",
						(statement, bucketCount) -> {
							statement.setString(1, bucketCount.getBucket());
                            statement.setLong(2, bucketCount.getCount());
                        },
						JdbcExecutionOptions.builder()
                        		.withBatchSize(1000)
                        		.withBatchIntervalMs(200)
                        		.withMaxRetries(5)
                        		.build(), 
                        new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
                            	.withUrl("jdbc:mysql://127.0.0.1:3306/flink")
                            	.withUsername("username")
                            	.withPassword("password")
                            	.build())
				);
		sinkStream.name("Sink DB");
		sinkStream.setParallelism(1);				// sink算子的并行度只能是1
		
		// 执行
		env.execute("Real-Time DateHistogram");
	}
}

几个关键点

window

Flink 的 window 将数据源沿着时间边界,切分成有界的数据块,然后对各个数据块进行处理。下图表示了三种窗口类型:

窗口划分策略比较
  • 固定窗口(又名滚动窗口)
    固定窗口在时间维度上,按照固定长度将无界数据流切片,是一种对齐窗口。窗口紧密排布,首尾无缝衔接,均匀地对数据流进行切分。

  • 滑动窗口
    滑动时间窗口是固定时间窗口的推广,由窗口大小和窗口间隔两个参数共同决定。当窗口间隔小于窗口大小时,窗口之间会出现重叠;当窗口间隔等于窗口大小时,滑动窗口蜕化为固定窗口;当窗口间隔大于窗口大小时,得到的是一个采样窗口。与固定窗口一样,滑动窗口也是一种对齐窗口。

  • 会话窗口
    会话窗口是典型的非对齐窗口。会话由一系列连续发生的事件组成,当事件发生的间隔超过某个超时时间时,意味着一个会话的结束。会话很有趣,例如,我们可以通过将一系列时间相关的事件组合在一起来分析用户的行为。会话的长度不能先验地定义,因为会话长度在不同的数据集之间永远不会相同。

EventTime

数据处理系统中,通常有两个时间域:

  • 事件时间:事件发生的时间,即业务时间。
  • 处理时间:系统发现事件,开始对事件进行处理的时间。

根据事件时间划分窗口
的方式在事件本身的发生时间备受关注时显得格外重要。下图所示为将无界数据根据事件时间切分成 1 小时固定时间窗口:

根据事件时间划分固定窗口

要特别注意箭头中所示的两个事件,两个事件根据处理时间所在的窗口,跟事件时间发生的窗口不是同一个。如果基于处理时间划分窗口的话,结果就是错的。只有基于事件时间进行计算,才能保证数据的正确性。

当然,天下没有免费的午餐。事件时间窗口功能很强大,但由于迟到数据的原因,窗口的存在时间比窗口本身的大小要长很多,导致的两个明显的问题是:

  • 缓存:事件时间窗口需要存储更长时间内的数据。
  • 完整性:基于事件时间的窗口,我们也不能判断什么时候窗口的数据都到齐了。Flink 通过 watermark,能够推断一个相对精确的窗口结束时间。但是这种方式并不能得到完全正确的结果。因此,Flink 还支持让用户能定义何时输出窗口结果,并且定义当迟到数据到来时,如何更新之前窗口计算的结果。

reduce

Reduce 算子基于 ReduceFunction 对集合中的元素进行滚动聚合,并向下游算子输出每次滚动聚合后的结果。常用的聚合方法如 average, sum, min, max, count 都可以使用 reduce 实现。

效果预览

从效果图中可以看出,Sum Panel 中的 stat value(为 DateHistogram 中每个 Bucket 对应值的加和)和 Kafka 端的数据跟进的非常紧,代表 Flink 的处理延迟非常低。向 Kafka 中总计压入的数据量和 Flink 输出的数据总数一致,代表数据的统计结果是准确的。此外,最近一段时间的柱状图都在实时变化,代表 Flink 对迟到的数据按照 EventTime 进行了准确处理,把数据放到了准确的 date bucket 中。

iOS
动态链接器
dyld
中有一个神秘的变量
__dso_handle
:

// dyld/dyldMain.cpp
static const MachOAnalyzer* getDyldMH()
{
#if __LP64__
    // 声明 __dso_handle
    extern const MachOAnalyzer __dso_handle;
    return &__dso_handle;
#else
    ...
#endif // __LP64__
}

这个函数内部声明了一个变量
__dso_handle
,其类型是
struct MachOAnalyzer

查看
struct MachOAnalyzer
的定义,它继承自
struct mach_header
:

image

struct mach_header
正是
XNU
内核里面,定义的
Mach-O
文件头:

// EXTENERL_HEADERS/mach-o/loader.h
struct mach_header {
	uint32_t	magic;		/* mach magic number identifier */
	cpu_type_t	cputype;	/* cpu specifier */
	cpu_subtype_t	cpusubtype;	/* machine specifier */
	uint32_t	filetype;	/* type of file */
	uint32_t	ncmds;		/* number of load commands */
	uint32_t	sizeofcmds;	/* the size of all the load commands */
	uint32_t	flags;		/* flags */
};

从上面函数
getDyldMH
的名字来看,它返回
dyld
这个
Mach-O
文件的文件头,而这确实也符合变量
__dso_handle
的类型定义。

但是奇怪的事情发生了,搜遍整个
dyld
源码库,都无法找到变量
__dso_handle
的定义。所有能搜到的地方,都只是对这个变量
__dso_handle
的声明。

众所周知,动态连接器
dyld
本身是静态链接的。

也就是说,动态连接器
dyld
本身是不依赖任何其他动态库的。

因此,这个变量
__dso_handle
不可能定义在其他动态库。

既然这样,动态链接器
dyld
本身是如何静态链接通过的呢?

答案只可能是静态链接器
ld
在链接过程中做了手脚。

查看静态链接器
ld
的源码,也就是
llvm
的源码,可以找到如下代码:

// lld/MachO/SyntheticSections.cpp
void macho::createSyntheticSymbols() {
  // addHeaderSymbol 的 lamba 表达式
  auto addHeaderSymbol = [](const char *name) {
    symtab->addSynthetic(name, in.header->isec, /*value=*/0,
                         /*isPrivateExtern=*/true, /*includeInSymtab=*/false,
                         /*referencedDynamically=*/false);
  };

  ...

  // The Itanium C++ ABI requires dylibs to pass a pointer to __cxa_atexit
  // which does e.g. cleanup of static global variables. The ABI document
  // says that the pointer can point to any address in one of the dylib's
  // segments, but in practice ld64 seems to set it to point to the header,
  // so that's what's implemented here.
  addHeaderSymbol("___dso_handle");
}

上面代码定义了一个
addHeaderSymbol

lamda
表达式,然后使用它添加了一个符号,这个符号正是
__dso_handle

调用
addHeaderSymbol
上方的注释使用
chatGPT
翻译过来如下:

Itanium C++ ABI 要求动态库传递一个指向 __cxa_atexit 的指针,该函数负责例如静态全局变量的清理。ABI 文档指出,指针可以指向动态库的某个段中的任意地址,但实际上,ld64(苹果的链接器)似乎将其设置为指向头部,所以这里实现了这种做法。

注释中提到的
Itanium C++ ABI
最初是为英特尔和惠普联合开发的
Itanium
处理器架构设计的。

但其影响已经超过了最初设计的架构范围,并被广泛用于其他架构,比如
x86

x86-64
上的多种编译器,包括
GCC

Clang

而且,注释中还提到,
__dso_handle
在苹果的实现里,是指向了
Mach-O
的头部。

至此,谜底解开~。

概述

使用 explain 输出 SELECT 语句执行的详细信息,包括以下信息:

  • 表的加载顺序
  • sql 的查询类型
  • 可能用到哪些索引,实际上用到哪些索引
  • 读取的行数

Explain 执行计划包含字段信息如下:分别是 id、select_type、table、partitions、type、possible_keys、key、key_len、ref、rows、filtered、Extra 12个字段。

通过explain extended + show warnings可以在原本explain的基础上额外提供一些查询优化的信息,得到优化以后的可能的查询语句(不一定是最终优化的结果)。

测试环境:

CREATE TABLE `blog` (
  `blog_id` int NOT NULL AUTO_INCREMENT COMMENT '唯一博文id--主键',
  `blog_title` varchar(255) NOT NULL COMMENT '博文标题',
  `blog_body` text NOT NULL COMMENT '博文内容',
  `blog_time` datetime NOT NULL COMMENT '博文发布时间',
  `update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  `blog_state` int NOT NULL COMMENT '博文状态--0 删除 1正常',
  `user_id` int NOT NULL COMMENT '用户id',
  PRIMARY KEY (`blog_id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8

CREATE TABLE `user` (
  `user_id` int NOT NULL AUTO_INCREMENT COMMENT '用户唯一id--主键',
  `user_name` varchar(30) NOT NULL COMMENT '用户名--不能重复',
  `user_password` varchar(255) NOT NULL COMMENT '用户密码',
  PRIMARY KEY (`user_id`),
  KEY `name` (`user_name`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8

CREATE TABLE `discuss` (
  `discuss_id` int NOT NULL AUTO_INCREMENT COMMENT '评论唯一id',
  `discuss_body` varchar(255) NOT NULL COMMENT '评论内容',
  `discuss_time` datetime NOT NULL COMMENT '评论时间',
  `user_id` int NOT NULL COMMENT '用户id',
  `blog_id` int NOT NULL COMMENT '博文id',
  PRIMARY KEY (`discuss_id`)
) ENGINE=InnoDB AUTO_INCREMENT=61 DEFAULT CHARSET=utf8

id

表示查询中执行select子句或者操作表的顺序,id的值越大,代表优先级越高,越先执行

explain select discuss_body 
from discuss 
where blog_id = (
    select blog_id from blog where user_id = (
        select user_id from user where user_name = 'admin'));

三个表依次嵌套,发现最里层的子查询 id最大,最先执行。

select_type

表示 select 查询的类型,主要是用于区分各种复杂的查询,例如:普通查询、联合查询、子查询等。

  • SIMPLE:表示最简单的 select 查询语句,在查询中不包含子查询或者交并差集等操作。
  • PRIMARY:查询中最外层的SELECT(存在子查询的外层的表操作为PRIMARY)。
  • SUBQUERY:子查询中首个SELECT。
  • DERIVED:被驱动的SELECT子查询(子查询位于FROM子句)。
  • UNION:在SELECT之后使用了UNION

table

查询的表名,并不一定是真实存在的表,有别名显示别名,也可能为临时表。当from子句中有子查询时,table列是<derivenN>的格式,表示当前查询依赖 id为N的查询,会先执行 id为N的查询。

partitions

查询时匹配到的分区信息,对于非分区表值为NULL,当查询的是分区表时,partitions显示分区表命中的分区情况。

type

查询使用了何种类型,它在 SQL优化中是一个非常重要的指标

访问效率:const > eq_ref > ref > range > index > ALL

system

当表仅有一行记录时(系统表),数据量很少,往往不需要进行磁盘IO,速度非常快。比如,Mysql系统表proxies_priv在Mysql服务启动时候已经加载在内存中,对这个表进行查询不需要进行磁盘 IO。

const

单表操作的时候,查询使用了主键或者唯一索引。

eq_ref

多表关联查询的时候,主键和唯一索引作为关联条件。如下图的sql,对于user表(外循环)的每一行,user_role表(内循环)只有一行满足join条件,只要查找到这行记录,就会跳出内循环,继续外循环的下一轮查询。

ref

查找条件列使用了索引而且不为主键和唯一索引。虽然使用了索引,但该索引列的值并不唯一,这样即使使用索引查找到了第一条数据,仍然不能停止,要在目标值附近进行小范围扫描。但它的好处是不需要扫全表,因为索引是有序的,即便有重复值,也是在一个非常小的范围内做扫描。

ref_or_null

类似 ref,会额外搜索包含NULL值的行

index_merge

使用了索引合并优化方法,查询使用了两个以上的索引。新建comment表,id为主键,value_id为非唯一索引,执行explain select content from comment where value_id = 1181000 and id > 1000;,执行结果显示查询同时使用了id和value_id索引,type列的值为index_merge。

range

有范围的索引扫描,相对于index的全索引扫描,它有范围限制,因此要优于index。像between、and、>、<、in和or都是范围索引扫描。

index

index包括select索引列,order by主键两种情况。

order by主键。这种情况会按照索引顺序全表扫描数据,拿到的数据是按照主键排好序的,不需要额外进行排序。

select索引列。type为index,而且extra字段为using index,也称这种情况为索引覆盖。所需要取的数据都在索引列,无需回表查询。

all

全表扫描,查询没有用到索引,性能最差。

possible_keys

此次查询中可能选用的索引。但这个索引并不定一会是最终查询数据时所被用到的索引。

key

此次查询中确切使用到的索引

ref

ref 列显示使用哪个列或常数与key一起从表中选择数据行。常见的值有const、func、NULL、具体字段名。当 key 列为 NULL,即不使用索引时。如果值是func,则使用的值是某个函数的结果。

以下SQL的执行计划ref为const,因为使用了组合索引(user_id, blog_id),where user_id = 13中13为常量

mysql> explain select blog_id from user_like where user_id = 13;
+----+-------------+-----------+------------+------+---------------+------+---------+-------+------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+------+---------+-------+------+----------+-------------+
|  1 | SIMPLE      | user_like | NULL       | ref  | ul1,ul2       | ul1  | 4       | const |    2 |   100.00 | Using index |
+----+-------------+-----------+------------+------+---------------+------+---------+-------+------+----------+-------------+

而下面这个SQL的执行计划ref值为NULL,因为key为NULL,查询没有用到索引。

mysql> explain select user_id from user_like where status = 1;
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | user_like | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    6 |    16.67 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+

rows

估算要找到所需的记录,需要读取的行数。评估SQL 性能的一个比较重要的数据,mysql需要扫描的行数,很直观的显示 SQL 性能的好坏,一般情况下 rows 值越小越好

filtered

存储引擎返回的数据在经过过滤后,剩下满足条件的记录数量的比例

extra

表示额外的信息说明。为了方便测试,这里新建两张表。

CREATE TABLE `t_order` (
  `id` int NOT NULL AUTO_INCREMENT,
  `user_id` int DEFAULT NULL,
  `order_id` int DEFAULT NULL,
  `order_status` tinyint DEFAULT NULL,
  `create_date` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_userid_order_id_createdate` (`user_id`,`order_id`,`create_date`)
) ENGINE=InnoDB AUTO_INCREMENT=99 DEFAULT CHARSET=utf8

CREATE TABLE `t_orderdetail` (
  `id` int NOT NULL AUTO_INCREMENT,
  `order_id` int DEFAULT NULL,
  `product_name` varchar(100) DEFAULT NULL,
  `cnt` int DEFAULT NULL,
  `create_date` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_orderid_productname` (`order_id`,`product_name`)
) ENGINE=InnoDB AUTO_INCREMENT=152 DEFAULT CHARSET=utf8

using where

表示在查询过程中使用了WHERE条件进行数据过滤。当一 个查询中包含WHERE条件时,MySQL会根据该条件过滤出满足条件的数据行,然后再进行后续的操作。这个过程 就被称为"Using Where”。

表示查询的列未被索引覆盖,,且where筛选条件是索引列前导列的一个范围,或者是索引列的非前导列,或者是非索引列。对存储引擎返回的结果进行过滤(Post-filter,后过滤),一般发生在MySQL服务器,而不是存储引擎层,因此需要回表查询数据。

using index

查询的列被索引覆盖,并且where筛选条件符合最左前缀原则,通过索引查找就能直接找到符合条件的数据,
不需要回表
查询数据。

Using where&Using index

查询的列被索引覆盖,但无法通过索引查找找到符合条件的数据,不过可以通过索引扫描找到符合条件的数据,也不需要回表查询数据。

包括两种情况(组合索引为(user_id, orde)):

where筛选条件不符合最左前缀原则

where筛选条件是索引列前导列的一个范围

null

查询的列未被索引覆盖,并且where筛选条件是索引的前导列,也就是用到了索引,但是部分字段未被索引覆盖,必须回表查询这些字段,Extra中为NULL。

using index condition

索引下推(index condition pushdown,ICP),先使用where条件过滤索引,过滤完索引后找到所有符合索引条件的数据行,随后用 WHERE 子句中的其他条件去过滤这些数据行。

对于联合索引(a, b),在执行 select * from table where a > 1 and b = 2 语句的时候,只有 a 字段能用到索引,那在联合索引的 B+Tree 找到第一个满足条件的主键值(ID 为 2)后,还需要判断其他条件是否满足(看 b 是否等于 2),那是在联合索引里判断?还是回主键索引去判断呢?
MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在联合索引遍历过程中,对联合索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

不使用ICP的情况(set optimizer_switch='index_condition_pushdown=off'),如下图,在步骤4中,没有使用where条件过滤索引:

使用ICP的情况(set optimizer_switch='index_condition_pushdown=on'):

下面的例子使用了ICP:

explain select user_id, order_id, order_status from t_order where user_id > 1 and user_id < 5\G;

关掉ICP之后(set optimizer_switch='index_condition_pushdown=off'),可以看到extra列为using where,不会使用索引下推。

using temporary

使用了临时表保存中间结果,常见于 order by 和 group by 中。典型的,当group by和order by同时存在,且作用于不同的字段时,就会建立临时表,以便计算出最终的结果集

filesort

文件排序。表示无法利用索引完成排序操作,以下情况会导致filesort:

  • order by 的字段不是索引字段
  • select 查询字段不全是索引字段
  • select 查询字段都是索引字段,但是 order by 字段和索引字段的顺序不一致

using join buffer

Block Nested Loop,需要进行嵌套循环计算。两个关联表join,关联字段均未建立索引,就会出现这种情况。比如内层和外层的type均为ALL,rows均为4,需要循环进行
4*4
次计算。常见的优化方案是,在关联字段上添加索引,避免每次嵌套循环计算。

面试题专栏

Java面试题专栏
已上线,欢迎访问。

  • 如果你不知道简历怎么写,简历项目不知道怎么包装;
  • 如果简历中有些内容你不知道该不该写上去;
  • 如果有些综合性问题你不知道怎么答;

那么可以私信我,我会尽我所能帮助你。

如果你做过个人博客网站,那么一定对静态网站生成器不陌生。无论是 Ruby 语言的 Jekyll、Go 语言的 Hugo、还是基于 React 的 Gatsby,这些工具都有庞大的用户群体。对于喜欢的人来说,它们是无可替代的神器,而对于不喜欢的人,则可能难以“下咽”。正如俗话所说,“萝卜青菜,各有所爱”,没有最好用的工具,只有最适合自己的。

比如,上周热门的开源项目 zola,它的诞生就是因为作者不喜欢 Hugo 的模板引擎,同时为了追求更简洁的使用体验。他选择用 Rust 开发了 zola 这款静态网站生成器,提供独立的可执行文件和更简单易用的模版语言。同样备受关注的还有 dockur/macos,它可以让用户在 Docker 中体验 macOS 系统。用于备份 QQ 空间说说的 GetQzonehistory,虽看似简单,但凭借切中用户痛点和开箱即用的特点,让它在短时间内获得上千 Star 的关注。

  • 本文目录
    • 1. 热门开源项目
      • 1.1 Rust 的静态网站生成器:zola
      • 1.2 Linux 平台的 GDB 图形化增强工具:Seer
      • 1.3 在 Docker 中体验 macOS 系统:macos
      • 1.4 QQ 空间说说备份工具:GetQzonehistory
      • 1.5 利用家用设备打造低成本的 AI 集群:exo
    • 2. HelloGitHub 热评
      • 2.1 开源的网络钓鱼平台:Gophish
      • 2.2 Rust 驱动的 HTTP 压测工具:oha
    • 3. 结尾

1. 热门开源项目

1.1 Rust 的静态网站生成器:zola

主语言:Rust

Star:14k

周增长:200

该项目是用 Rust 语言编写的静态网站生成器,其方便的可执行文件使得安装过程简单快捷。它采用了更易于使用的 Tera 模版引擎,并提供了一体化的静态网站解决方案,内置全面的功能模块,包括内容管理、语法高亮、检查外部链接、快速预览、搜索和多语言等,适用于快速搭建技术博客、产品文档和公司官网等网站。

zola init my_site
zola check
zola serve
zola build

GitHub 地址→
github.com/getzola/zola

1.2 Linux 平台的 GDB 图形化增强工具:Seer

主语言:C++

Star:2.8k

周增长:600

这是一款专为 Linux 系统设计的工具,提供了 GDB 的图形化用户操作界面。它为 GNU 调试器 GDB 提供了友好的图形化界面,包括代码管理、变量/寄存器信息、断点管理等多个功能视图,支持变量跟踪、回放指令、内存、结构体和数组可视化等功能。

GitHub 地址→
github.com/epasveer/seer

1.3 在 Docker 中体验 macOS 系统:macos

主语言:Python

Star:8.3k

周增长:7k

该项目允许用户在 Docker 容器中运行 macOS 系统,实现在非苹果硬件上体验 macOS。它基于 Docker 和 KVM 的虚拟化技术,简化了 macOS 的安装过程。用户可以通过浏览器使用 macOS 系统,支持调整 CPU、内存和硬盘大小,并兼容从 macOS 11 到 macOS 15 的多个版本,适合在 Linux 和 Windows 环境中测试 macOS 应用。

GitHub 地址→
github.com/dockur/macos

1.4 QQ 空间说说备份工具:GetQzonehistory

主语言:Python

Star:5.7k

周增长:2.6k

这是一个用于获取个人 QQ 空间历史说说的工具。它通过模拟登录 QQ 空间,可以自动获取个人账号下所有发布过的说说,并导出成 Excel 文件。用户只需执行几个简单的步骤,即可轻松备份个人的所有历史说说。

GitHub 地址→
github.com/LibraHp/GetQzonehistory

1.5 利用家用设备打造低成本的 AI 集群:exo

主语言:Python

Star:14k

周增长:3k

该项目能够利用日常家用设备(如手机、笔记本电脑、台式机等)来搭建家庭 AI 集群。它通过整合现有的设备,无需昂贵硬件,即可构建一个低成本、可扩展的 GPU 计算集群,支持动态模型分区、自动发现设备、ChatGPT API、P2P 连接和多种推理引擎。

GitHub 地址→
github.com/exo-explore/exo

2. HelloGitHub 热评

在此章节中,我们将为大家介绍本周 HelloGitHub 网站上的热门开源项目,我们不仅希望您能从中收获开源神器和编程知识,更渴望“听”到您的声音。欢迎您与我们分享使用这些
开源项目的亲身体验和评价
,用最真实反馈为开源项目的作者注入动力。

2.1 开源的网络钓鱼平台:Gophish

主语言:Go

该项目提供了一个开箱即用的网络钓鱼平台,可用于模拟钓鱼攻击。它拥有友好的 Web 管理后台,支持邮件模板、批量发送邮件、网站克隆和数据可视化,适用于企业安全培训和渗透测试等场景。

项目详情→
hellogithub.com/repository/6f6ec956985847f6a133ee5daafae964

2.2 Rust 驱动的 HTTP 压测工具:oha

主语言:Rust

这是一个用 Rust 开发的 HTTP 请求压测工具,它操作简单、带 TUI 动画界面,支持生成请求延迟、吞吐量等指标的报告,以及动态 URL 和更灵活的请求间隔(burst-delay)等功能。

项目详情→
hellogithub.com/repository/98b46ea0d7d84f4c944d0a35a9d2d140

3. 结尾

以上就是本期「GitHub 热点速览」的全部内容,希望你能够在这里找到自己感兴趣的开源项目,如果你有其他好玩、有趣的 GitHub 开源项目想要分享,欢迎来
HelloGitHub
与我们交流和讨论。

往期回顾

交互类组件

Web
应用程序中至关重要,它们允许用户与应用进行实时互动,能够显著提升用户体验。

用户不再只是被动地接收信息,而是可以主动地输入数据、做出选择或触发事件,从而更加深入地参与到应用中来。

此外,对于某些复杂的任务或操作,
交互类组件
可以将其分解成一系列简单的步骤或选择,从而降低用户的认知负担和学习成本

。这使得用户能够更轻松地完成这些任务,提高应用的易用性。

Streamlit
中交互类组件有很多,本篇介绍其中最常用的几种,这也是
Web
页面中最常见到的几种组件。

  • st.text_input
    :允许用户输入文本,用于收集用户输入的字符串信息。
  • st.button
    :提供一个可点击的按钮,用户点击后触发特定操作或事件。
  • st.selectbox
    :显示下拉列表,让用户从预设选项中选择一个。
  • st.multiselect
    :提供下拉多选功能,允许用户从预设选项中选择多个。
  • st.radio
    :显示单选按钮组,让用户从多个选项中选择一个。
  • st.checkbox
    :提供复选框,让用户选择或取消选择特定选项。

1. 组件概述

1.1. st.text_input

用于输入普通文本或者密码,类似于HTML中的
<input type="text">

核心的参数有:

名称 类型 说明
label str 输入框前面的标签
key str 唯一标识此输入框的键,可用于在回调中引用
value str 输入框的初始值
type str 输入类型,可以是"default"(默认)或者"password"(密码)

1.2. st.button

提供一个按钮用来出发特定事件,类似于HTML中的
<button>

核心的参数有:

名称 类型 说明
label str 按钮上的文字
key str 唯一标识此按钮的键
help str 按钮旁边的帮助文本

1.3. st.selectbox

下拉选择框,类似于HTML中的
<select>

核心的参数有:

名称 类型 说明
label str 下拉框前面的标签
key str 唯一标识此下拉框的键
options list 下拉框中的选项列表
index int 初始选中项的索引

1.4. st.multiselect

可以多选的下拉选择框,类似于HTML中的
<select multiple>

核心的参数有:

名称 类型 说明
label str 多选框前面的标签
key str 唯一标识此多选框的键
options list 多选框中的选项列表
default list 默认选中的选项列表

1.5. st.radio

单选按钮组,类似于HTML中的
<input type="radio">

核心的参数有:

名称 类型 说明
label str 单选按钮组前面的标签
key str 唯一标识此单选按钮组的键
options list 单选按钮组中的选项列表
index int 初始选中项的索引

1.6. st.checkbox

复选框,类似于HTML中的
<input type="checkbox">

核心的参数有:

名称 类型 说明
label str 复选框旁边的标签
key str 唯一标识此复选框的键
value bool 复选框的初始状态(选中或未选中)

2. 组件使用示例

下面通过从实际场景中简化而来的示例,来看看如何使用
Streamlit
的交互类组件。

2.1. "用户偏好调查"示例

这个示例模拟了一个真实的用户调查场景,

通过
Streamlit
的交互式组件,用户可以轻松地输入和选择信息,并提交给应用进行处理和显示。

import streamlit as st

# 标题
st.title("用户偏好调查")

# 文本输入框:收集用户名
username = st.text_input("请输入您的姓名:")

# 下拉单选框:选择性别
gender = st.selectbox("请选择您的性别:", ["男", "女", "其他"])

# 下拉多选框:选择兴趣爱好
hobbies = st.multiselect(
    "请选择您的兴趣爱好:", ["阅读", "运动", "旅行", "音乐", "电影"]
)

# 单选按钮组:选择喜欢的颜色
favorite_color = st.radio("请选择您喜欢的颜色:", ["红色", "蓝色", "绿色", "黄色"])

# 复选框:是否同意接收推送
accept_push = st.checkbox("您是否同意接收推送消息?")

# 按钮:提交调查
if st.button("提交调查"):
    # 收集所有输入信息并显示
    user_info = {
        "姓名": username,
        "性别": gender,
        "兴趣爱好": ", ".join(hobbies),
        "喜欢的颜色": favorite_color,
        "是否同意接收推送": "是" if accept_push else "否",
    }

    st.subheader("您的调查信息如下:")
    st.write(user_info)

2.2. "数据分析项目仪表板"示例

这个示例模拟了一个数据分析项目的仪表板,

通过
Streamlit
的交互式组件,用户可以轻松地与数据进行交互,选择他们感兴趣的分析方式,并查看和下载分析结果。

import streamlit as st
import pandas as pd
import numpy as np

# 假设数据集
data = pd.DataFrame(
    {
        "日期": pd.date_range(start="2023-01-01", periods=100, freq="D"),
        "类别": np.random.choice(["A", "B", "C"], 100),
        "销售额": np.random.randint(100, 1000, 100),
        "利润": np.random.randint(10, 100, 100),
    }
)

# 标题
st.title("数据分析项目仪表板")

# 文本输入框:输入项目名称
project_name = st.text_input("请输入项目名称:")

# 下拉单选框:选择分析类别
analysis_category = st.selectbox("请选择分析类别:", data["类别"].unique())

# 下拉多选框:选择显示的列
display_columns = st.multiselect("请选择要显示的列:", data.columns)
selected_data = data[display_columns]

# 单选按钮组:选择汇总方式
agg = st.radio("请选择汇总方式:", ["总和", "平均值", "最大值", "最小值"])
agg_dict = {
    "总和": "sum",
    "平均值": "mean",
    "最大值": "max",
    "最小值": "min",
}

# 复选框:是否按类别汇总
group_by_category = st.checkbox("是否按类别汇总?")

# 按钮:执行分析
if st.button("执行分析"):
    # 根据用户选择进行分析
    if group_by_category:
        grouped_data = (
            selected_data.groupby("类别")
            .agg({col: agg_dict[agg] for col in selected_data.columns if col != "类别"})
            .reset_index()
        )
    else:
        grouped_data = (
            selected_data.agg({col: agg_dict[agg] for col in selected_data.columns})
            .to_frame()
            .T
        )

    # 显示分析结果
    st.subheader("分析结果:")
    st.dataframe(grouped_data)

3. 总结

总之,这些交互式组件使得用户可以通过文本输入、选择、勾选等方式与应用进行互动,从而根据用户需求动态地展示和分析数据。

它们极大地增强了应用的灵活性和用户体验,使得数据分析、数据可视化等任务变得更加直观和便捷。