Apache Arrow 的列式存储格式是一种内存数据组织标准,它通过物理布局、Array(数组)、Schema(模式)和 RecordBatch(记录批次)等,优化了大数据的存储与处理。这种格式以列而非行来存储数据,从而提高了数据访问效率,支持跨平台和多种编程语言,且无需序列化开销,适应现代硬件架构,特别适合于高效的数据分析操作。
01 概述
下图描述了 Apache Arrow 中列式存储格式的主要组成部分,RecordBatch
是 Apache Arrow 中一组具有相同结构的数组 Array
或分块数组 ChunkedArray
构成的集合,它表示一个逻辑上相关联的数据集,例如数据库表的多行记录。Schema
定义了这些数组的组织结构,并且提供了一种方式来描述和管理这些数组的基本属性和附加信息;每个数组或分块数组在 Schema 中都有一个对应的字段 Field
,这个字段记录了数组的名称和数据类型 DataType
,这些名称和数据类型可以是用户指定的,也可以是从文件中读取得到的;多个这样的字段被组织在一个 Schema 结构中。
Apache Arrow 中自下而上主要的结构说明如下:
-
Physical Layout(物理布局):
Physical Layout
(或DataTypeLayout
) 定义了数据类型的内存布局,即如何在内存中表示和组织数据。它描述了数据类型的结构、大小和对齐方式,以及如何存储和访问数据的元数据信息。 -
DataType(数据类型):
DataType
是 Apache Arrow 中定义数据的类型和属性的对象,是一种逻辑类型。它描述了数据的基本类型(如整数、浮点数、字符串等),并可以包含其他的元数据信息(如字段名、单位、精度等)。每个DataType
都与特定的Physical Layout
关联,以确定数据在内存中的布局和表示方式。 -
Array(数组):
Array
(或Vector
) 是 Apache Arrow 中的数据容器,用于存储一系列具有相同数据类型的元素。它是在特定Physical Layout
的基础上创建的,按照特定的内存布局存储数据。Array
提供了对数据的高效访问和操作方法,并支持常见的数组操作,例如索引、切片、过滤等。 -
RecordBatch(记录批):
RecordBatch
是 Apache Arrow 中的数据集合,由一组具有相同结构的Array
组成。它表示一个逻辑上相关联的数据集,例如数据库表的多行记录。RecordBatch
中的每个Array
代表一个列,而RecordBatch
中的行数由Array
的长度确定。RecordBatch
提供了一种高效的方式来处理和传输数据集,支持批处理操作和列式访问模式。
02 物理布局 Physical Layout
Apache Arrow 中的物理布局是其数据组织格式的基础,它决定了数据在内存中的存储方式和组织结构,以实现高效的数据访问、处理和传输。通过优化物理布局,可以提高数据处理的速度和效率,同时支持跨系统和编程语言的数据交换和共享。
物理布局结构体DataTypeLayout
的 C++ 实现被定义在cpp\src\arrow\type.h
头文件中,其中定义了 4 种 BufferSpec :FixedWidth
(单值定长类型),VariableWidth
(变长类型),Bitmap
(位图记录空值),AlwaysNull
(始终为空)
结构体DataTypeLayout
还包含一个 BufferSpec 对象数组的 buffers
成员,用于存储每个预期缓冲区的布局规范;has_dictionary
成员表示该类型是否期望有一个关联的字典数组;variadic_spec
成员是一个可选的BufferSpec 对象,用于指定超出 buffers 大小下限的缓冲区的规范
struct ARROW_EXPORT DataTypeLayout {
enum BufferKind { FIXED_WIDTH, VARIABLE_WIDTH, BITMAP, ALWAYS_NULL };
/// Layout specification for a single data type buffer
struct BufferSpec {
BufferKind kind;
int64_t byte_width; // For FIXED_WIDTH
bool operator==(const BufferSpec& other) const {
return kind == other.kind &&
(kind != FIXED_WIDTH || byte_width == other.byte_width);
}
bool operator!=(const BufferSpec& other) const { return !(*this == other); }
};
static BufferSpec FixedWidth(int64_t w) { return BufferSpec{FIXED_WIDTH, w}; }
static BufferSpec VariableWidth() { return BufferSpec{VARIABLE_WIDTH, -1}; }
static BufferSpec Bitmap() { return BufferSpec{BITMAP, -1}; }
static BufferSpec AlwaysNull() { return BufferSpec{ALWAYS_NULL, -1}; }
/// A vector of buffer layout specifications, one for each expected buffer
std::vector<BufferSpec> buffers;
/// Whether this type expects an associated dictionary array.
bool has_dictionary = false;
/// If this is provided, the number of buffers expected is only lower-bounded by
/// buffers.size(). Buffers beyond this lower bound are expected to conform to
/// variadic_spec.
std::optional<BufferSpec> variadic_spec;
explicit DataTypeLayout(std::vector<BufferSpec> buffers,
std::optional<BufferSpec> variadic_spec = {})
: buffers(std::move(buffers)), variadic_spec(variadic_spec) {}
};
基于DataTypeLayout
结构体中的 4 种 BufferSpec 进行组合使用,Apache Arrow 提供了多种物理布局类型如下表所示:
Layout Type | Buffer 0 | Buffer 1 | Buffer 2 | Variadic Buffers |
---|---|---|---|---|
Primitive | validity | data | ||
Variable Binary | validity | offsets | data | |
Variable Binary View | validity | views | data | |
List | validity | offsets | ||
List View | validity | offsets | sizes | |
Fixed-size List | validity | |||
Struct | validity | |||
Sparse Union | type ids | |||
Dense Union | type ids | offsets | ||
Null | ||||
Dictionary-encoded | validity | data (indices) | ||
Run-end encoded |
- 固定大小的原始类型(Primitive (fixed-size)):具有相同字节或位宽的值序列
- 可变大小的二进制类型(Variable-size Binary):具有可变字节长度的值序列,支持使用 32 位和 64 位长度编码的两种变体
- 可变大小二进制的视图(View of Variable-size Binary):具有可变字节长度的值序列,与可变大小二进制不同,这种布局的值分布在可能多个缓冲区中,而不是密集且顺序地打包在单个缓冲区中
- 可变大小的列表(Variable-size List):一种嵌套布局,其中每个值是来自子数据类型的可变长度值序列,支持使用 32 位和 64 位长度编码的两种变体
- 可变大小列表的视图(View of Variable-size List):一种嵌套布局,其中每个值是来自子数据类型的可变长度值序列;这种布局与可变大小列表不同,它有一个额外的缓冲区包含每个列表值的大小。这消除了偏移量缓冲区必须有序的限制
- 固定大小的列表(Fixed-size List):一种嵌套布局,其中每个值具有相同数量的元素,来自子数据类型 child array
- 结构体(Struct):由具有相同长度但可能不同类型命名子字段的集合组成的嵌套布局
- 稀疏和密集联合(Sparse and Dense Union):一种嵌套布局,表示一系列值的序列,每个值的类型都可以从一系列子数组类型中选择
- 空值(Null):一系列全部为 null 的值序列
- 字典编码(Dictionary-Encoded):由整数序列组成的布局(任何位宽),这些整数代表索引到可能为任何类型的字典中
- 运行结束编码(Run-End Encoded, REE):由两个子数组组成的嵌套布局,一个表示值,一个表示相应值运行结束的逻辑索引
03 数据类型 DataType
基于物理布局 physical layout
,Arrow 提供了一套对应的逻辑类型 logical type
,即 DataType
根据物理布局中的定长类型(Fixed-size)和变长类型(Variable-size)等类型划分,Arrow 提供了如下种类的数据类型:
- 定长类型(FixedWidthType):
Boolean, Int, Floating Point, Decimal, Date, Time, Timestamp, Interval, Duration
等 - 嵌套类型(Nested Types):
List, Struct, Map, Union
等
这些类型的 C++ 实现被定义在cpp\src\arrow\type.h
头文件中,类型之间的派生关系如下(参考原文:Apache arrow 极致模块化、可组合的数据平台-CSDN博客)
/*
* ListType LargeListType FixedListType
* │ │ │
* │ ▼ │
* └───────► BaseListType◄────┘ StructType UnionType RunEndEncodedType
* │ │ │ │
* │ │ │ │
* └───────────────────┴───►NestedType◄────┴───────────────────┘ ExtensionType
* │ │
* │ │
* └──────────────► DataType ◄─────────────────────┘
* ▲
* │
* ┌────────────────┬──────────────────────┬────►FixedWidthType ◄───┬─────────────────────┬─────────────────┐
* │ │ │ │ │ │
* │ │ │ │ │ │
* │ │ │ │ │ │
* DictionaryType PrimitiveType ┌─►NumberType◄──┐ TemporaType FixedSizeBinaryType BaseBinaryType
* │ │ ▲ ▲ ▲ ▲
* │ │ │ │ │ │
* IntegerType FloatingPointType ┌─►DateType◄──┐ DecimalType StringType LargeStringType
* │ │ ▲ ▲
* │ │ │ │
* Date32Type Date64Type 128Type, 256Type
*/
Arrow 提供的数据类型详细如下表:(参考原文:Arrow Columnar Format — Apache Arrow v18.0.0.dev61)
Type | Type Parameters | Physical Layout |
---|---|---|
Null | Null | |
Boolean | Fixed-size Primitive | |
Int | - bit width - signedness | |
Floating Point | - precision | |
Decimal | - bit width - scale - precision | |
Date | - unit | |
Time | - bit width - unit | |
Timestamp | - unit - timezone | |
Interval | - unit | |
Duration | - unit | |
Fixed-Size Binary | - byte width | Fixed-size Binary |
Binary | Variable-size Binary with 32-bit offsets | |
Utf8 | ||
Large Binary | Variable-size Binary with 64-bit offsets | |
Large Utf8 | ||
Binary View | Variable-size Binary View | |
Utf8 View | ||
Fixed-Size List | - value type - list size | Fixed-size List |
List | - value type | Variable-size List with 32-bit offsets |
Large List | - value type | Variable-size List with 64-bit offsets |
List View | - value type | Variable-size List View with 32-bit offsets and sizes |
Large List View | - value type | Variable-size List View with 64-bit offsets and sizes |
Struct | - children | Struct |
Map | - children - keys sortedness | Variable-size List of Structs |
Union | - children - mode - type ids | Dense or Sparse Union |
Dictionary | - index type - value type - orderedness | Dictionary Encoded |
Run-End Encoded | - run end type - value type | Run-End Encoded |
04 数组 Array
Array 数据结构被定义为 ArrayData 包括如下主要属性:
type (DataType)
:指向 DataType 类型的共享指针,表示数组的数据类型length (int64_t)
:表示数组的长度null_count (int64_t)
:可变的atomic<int64_t>
类型,表示数组的空值计数offset (int64_t)
:表示逻辑上进入物理缓冲区 Buffer 的起始点buffers (vector<Buffer>)
:存储 Buffer 类型共享指针的向量,表示数组的缓冲区child_data (vector<ArrayData>)
:存储 ArrayData 类型共享指针的向量,表示数组的子数组dictionary (ArrayData)
:指向 ArrayData 类型的共享指针,表示数组的字典,仅用于字典类型
struct ARROW_EXPORT ArrayData {
// ...
std::shared_ptr<DataType> type;
int64_t length = 0;
mutable std::atomic<int64_t> null_count{0};
// The logical start point into the physical buffers (in values, not bytes).
// Note that, for child data, this must be *added* to the child data's own offset.
int64_t offset = 0;
std::vector<std::shared_ptr<Buffer>> buffers;
std::vector<std::shared_ptr<ArrayData>> child_data;
// The dictionary for this Array, if any. Only used for dictionary type
std::shared_ptr<ArrayData> dictionary;
};
不同数据类型的 Array 的物理布局也不一样,下面结合实例介绍几种常用物理布局类型的 Array 数组的数据组织方式(原文参考:《In-Memory Analytics with Apache Arrow》
,本节相关图片也来源于此)
4.1 Fixed-size Primitive Layout
Primitive 类型的数组表示一个值数组,每个值具有相同的物理插槽(physical slot)宽度(固定长度数据类型的所占内存空间大小),通常以字节为单位
例如:一个Int32
类型的数组 [1, null, 2, 4, 8]
,则该数组的 length = 5; null_count = 1
,
该数组的 buffers
中包含两个 Buffer:
Validity Bitmap Buffer
:与数组中值有效性相关联的位图,该 Bitmap 的内存空间是被连续分配,但是不需要和Value Buffer
在内存空间中相邻Value Buffer
:一个连续的内存缓冲区用于保存数组的值,其总大小至少与插槽宽度乘以数组长度一样大
Int32
类型数组例子的物理布局如下图所示:
4.2 Variable-size Binary Layout
Variable-size Binary Layout 是用于存储可变大小二进制数据类型的布局方式,可变大小二进制数据类型通常用于表示字符串、变长字节数组等可变长度的二进制数据
例如:一个字符串类型的数组 ['Water', 'Rising']
,则该数组的 length = 2; null_count = 0
,
该数组的 buffers
中包含三个 Buffer:
Validity Bitmap Buffer
:与数组中值有效性相关联的位图,该 Bitmap 的内存空间是被连续分配,但是不需要和Value Buffer
在内存空间中相邻Offsets Buffer
:偏移量缓冲区是一个整数数组,用于存储每个元素的起始偏移量;偏移量表示了每个元素在数据缓冲区中的起始位置,通过偏移量,可以快速定位和访问每个元素的数据;Offsets Buffer
的偏移量通常从0
开始以Value Buffer
的实际长度值为结束,例如本例子中偏移量缓冲区的值为[0, 5, 11]
分别对应了元素值Water
的起始位置 0 和元素值Rising
的起始位置 5,以及Value Buffer
的实际长度 11Value Buffer
:数据缓冲区是一个字节数组,用于存储实际的二进制数据;数据缓冲区包含了所有元素的二进制数据,通过偏移量缓冲区中的偏移量,可以确定每个元素在数据缓冲区中的位置
字符串类型数组例子的物理布局如下图所示:
4.3 Variable-size List Layout
List 是一种嵌套类型,其中每个值是来自子数据类型的可变长度值序列,在语义上类似于上面介绍的可变大小二进制数据类型 Variable-size Binary
例如:一个Int8
类型的 List 数组 [[12, -7, 25], null, [0, -127, 127, 50], []]
,则该数组的 length = 4; null_count = 1
,
该数组的 buffers
中包含两个 Buffer:
Validity Bitmap Buffer
:与数组中值有效性相关联的位图,该 Bitmap 的内存空间是被连续分配Offsets Buffer
:偏移量缓冲区和 Variable-size Binary 布局类型一样是一个整数数组,用于存储每个元素的起始偏移量;与前者不同的是其偏移量表示的是子数组(Child Array)中的数据缓冲区,而不是引用一个额外的数据缓冲区;例如本例子中偏移量缓冲区的值为[0, 3, 3, 7, 7]
分别对应了第一个数组[12, -7, 25]
的起始位置 0 ,第二个 NULL 值元素和第三个数组[0, -127, 127, 50]
起始位置 3,第四个空数组[]
的起始位置 7,以及子数组Value Buffer
的实际长度 7
该数组的数据放在嵌套结构child_data
中,其 Child Array 是一个Primitive Int8
类型的数组,该数组的 length = 7; null_count = 0
,包含不使用的 Validity Bitmap Buffer
和存储实际数据值的Value Buffer
两个缓冲区,其中上层的 Offsets Buffer
中的偏移量与 Child Array 中的 Value Buffer
对应
4.4 Fixed-Size List Layout
FixedSizeList 是一种嵌套布局,其每个数组槽都包含一个固定大小的值序列,这些值都具有相同的数据类型
例如:一个Int8
类型固定长度为 2 的 FixedSizeList 数组 [[10, null], null, [0, 5]]
,则该数组的 length = 3; null_count = 1
,
该数组的 buffers
中仅包含一个Validity Bitmap Buffer
表示与数组中值有效性相关联的位图,该 Bitmap 的内存空间是被连续分配
该数组的数据放在嵌套结构child_data
中,其 Child Array 是一个Primitive Int8
类型的数组,该数组的 length = 6; null_count = 3
,包含表示子数组中数据有效性的 Validity Bitmap Buffer
和存储实际数据值的Value Buffer
两个缓冲区
4.5 Struct Layout
Struct 是一种嵌套类型,它通常由多个字段(Field)组成,所以 Struct Layout 通常是具有相同长度但可能不同类型命名子字段的集合组成的嵌套布局
例如:包含两个属性的Struct<name: VarBinary, age: Int32>
结构体类型数组 [{'joe', 1}, {null, 2}, null, {'mark', 4}]
,则该数组的 length = 4; null_count = 1
,
该数组的 buffers
中仅包含一个Validity Bitmap Buffer
表示与数组中值有效性相关联的位图,该 Bitmap 的内存空间是被连续分配
这种类型的物理布局中,一个 Struct 数组的每个字段都有一个对应的子数组 Child Array,在本例中 Struct 的 name 和 age 两个字段的数据分别被存储在嵌套结构child_data
的两个 Child Array:Field-0 和 Field-1 中
Field-0
是一个Variable-size Binary Layout
物理布局的String
类型数组,其值为['joe', null, null, 'mark']
,该子数组的length = 4; null_count = 2
,buffers 中包含Validity Bitmap Buffer
,Offsets Buffer
和Value Buffer
三个缓冲区Field-1
是一个Fixed-size Primitive Layout
物理布局的Int32
类型数组,其值为[1, 2, null, 4]
,该子数组的length = 4; null_count = 1
,buffers 中包含Validity Bitmap Buffer
和Value Buffer
两个缓冲区
4.6 Union Layout
Union 是一种可以让同一数组中存储不同数据类型数据值的嵌套类型,它由有序的类型序列定义,Union 类型的数组中每一个物理插槽都是从这些类型中选择一个值;与其他数据类型不同,Union Layout 没有自己的有效性位图,其每个槽的空值由其按数据类型划分的子数组各自确定
针对不同的应用场景,Arrow 中提供了 Dense 和 Sparse 两种不同的 Union Layout,其中 Dense 使用了一个额外的类型标签缓冲区 Types Buffer
和一个偏移量数组 Offsets Buffer
来存储每个值的类型信息和偏移量信息;而 Sparse 则仅使用了一个额外的类型标签缓冲区 Types Buffer
来存储每个值的类型信息;数组实际的数据值被按数据类型划分存储在不同的子数组 Child Array 中
例如:包含两个数据类型的Union<f: Float, i: Int32>
联合类型数组 [{f=1.2}, null, {f=3.4}, {i=5}]
,则该数组的 length = 4; null_count = 0
如果该数组使用 Dense Union Layout 物理布局方式,则该数组的 buffers 字段包含一个额外的类型标签缓冲区 Types Buffer
和一个偏移量数组 Offsets Buffer
来存储每个值的类型信息和偏移量信息的两个缓冲区;该数组实际的数据值被按数据类型划分采用 Fixed-size Primitive Layout
布局方式的 Float 和 Int32 两个子数组 Child Array 各自存储,其物理布局如下图所示:
如果该数组使用 Sparse Union Layout 物理布局方式,则该数组的 buffers 字段仅包含一个存储每个值类型信息的类型标签缓冲区 Types Buffer
;该数组实际的数据值同样被按数据类型划分采用 Fixed-size Primitive Layout
布局方式的 Float 和 Int32 两个子数组 Child Array 各自存储,其物理布局如下图所示:
4.7 Dictionary-encoded Layout
Dictionary-encoded Layout 是用于存储字典编码数据类型的布局方式,任何数组都可以进行字典编码,当字段进行字典编码时,值由表示字典中值索引的非负整数数组表示;字典编码数组的内存布局与Primitive Int
布局的内存布局相同,字典作为具有自身相应布局的单独列式数组处理。
例如:一个 String 类型字典编码数组 ['foo', 'bar', 'foo', 'bar', null, 'baz']
,则该数组的 length = 6; null_count = 1
该数组的 buffers 与Primitive Int
布局一致包含两个 Buffer:
Validity Bitmap Buffer
:与数组中值有效性相关联的位图,该 Bitmap 的内存空间是被连续分配Value Buffer
:一个连续的内存缓冲区用于保存字典中值的索引,其总大小至少与插槽宽度乘以数组长度一样大
当数组使用字典编码时,使用dictionary
属性中的子数组 Dictionary Array 来保存压缩的数据值。例如本例中 Dictionary Array 是一个 Variable-size Binary Layout 物理布局的 String 类型数组,该数组的 length = 3; null_count = 0
,buffers 包含不使用的 Validity Bitmap Buffer
,保存数据值偏移量的Offsets Buffer
和存储实际数据值的Value Buffer
三个缓冲区
05 数据集合 RecordBatch
在 Apache Arrow 的列式格式中,RecordBatch 是序列化数据的基本单位,如下图所示,它由多个字段(Field)组成的有序集合构成,每个字段对应一个数组,而每个数组的长度彼此相同,但数据类型可能不同
每个字段 Field 代表了数据集中的一个列,它包含了该列的数据以及元数据信息,其中数据即为对应格式的 Array,数组 Array 中存储对应数据类型的单值 Scalar;每个字段 Field 都包含字段名称和字段的数据类型(DataType),这些字段的名称和类型共同构成了 RecordBatch 的模式 Schema
Schema 定义了 RecordBatch 中每个字段的名称、数据类型和其他属性,是数据集结构的元数据描述(RecordBatch 和 Schema 之间的关系可以类比为数据库中的表和表结构的关系;RecordBatch 就像是表中的部分数据,而 Schema 则是表的结构定义,包含了表中每个列的信息)
Arrow 使用 RecordBatch 将 record/row-oriented 的数据转换为 column-oriented 的列格式,在流数据 Data Stream 中 RecordBatch 消息按照 Schema 后面跟一个或多个 RecordBatch 的方式传输数据如下图所示
其中 RecordBatch 消息包含与由 Schema 确定的物理内存布局相对应的实际数据缓冲区 Buffer,每个 RecordBatch 消息的元数据提供了每个缓冲区的位置和大小,允许直接使用指针重建 Array 数据结构,因此无需进行内存复制
RecordBatch 的序列化形式如上图所示,包括 data header
和body
两个部分:
data header
:RecordBatch 消息的元数据,包括每个扁平化(Flattened)字段 Field 的长度和空计数,Body 中每个组成 Buffer 的内存偏移量和长度等信息
/// A data header describing the shared memory layout of a recordbatch.
table RecordBatch {
length: long; // number of records
nodes: [FieldNode]; // Nodes correspond to the pre-ordered flattened logical schema
buffers: [Buffer]; // Buffers correspond to the pre-ordered flattened buffer tree
compression: BodyCompression; // Optional compression of the message body
variadicBufferCounts: [long]; // some types use a variable number of buffers
}
body
:一个扁平的内存缓冲区序列,以端到端的方式写入,并进行适当的填充以确保至少 8 字节对齐
根据 RecordBatch 消息格式,我们可以得出上述例子的序列化形式,首先其 Schema strs: string ints: Int32 dbls: Float64
对应的扁平化形式为
FieldNode 0: String name='strs'
FieldNode 1: Int32 name='ints'
FieldNode 2: Float64 name='dbls'
其对应的 buffers 的序列化形式如下,Flatbuffers 值描述了内存片段的位置和大小
buffer 0: field 0 validity
buffer 1: field 0 offsets
buffer 2: field 0 data
buffer 3: field 1 validity
buffer 4: field 1 data
buffer 5: field 2 validity
buffer 6: field 2 data
对于某些类型(如 Utf8View)使用可变数量的缓冲区variadicBufferCounts
表示,那些预先排序的扁平化逻辑 Schema 中的每个此类字段,variadicBufferCounts 中将有一个条目,指示当前 RecordBatch 中属于该字段的可变参数缓冲区的数量
例如,如下 Schema,有两个具有可变参数缓冲区的字段 BinaryView
和Utf8View
col1: Struct<a: Int32, b: BinaryView, c: Float64>
col2: Utf8View
则 variadicBufferCounts 在每个 RecordBatch 中将有两个条目,对于具有 variadicBufferCounts = [3, 2] 的此模式的 RecordBatch,其扁平化缓冲区为
buffer 0: col1 validity
buffer 1: col1.a validity
buffer 2: col1.a values
buffer 3: col1.b validity
buffer 4: col1.b views
buffer 5: col1.b data
buffer 6: col1.b data
buffer 7: col1.b data
buffer 8: col1.c validity
buffer 9: col1.c values
buffer 10: col2 validity
buffer 11: col2 views
buffer 12: col2 data
buffer 13: col2 data
参考资料
Arrow Columnar Format — Apache Arrow v18.0.0.dev61
In-Memory Analytics with Apache Arrow (豆瓣) (douban.com)
Apache arrow 极致模块化、可组合的数据平台-CSDN博客
箭头列式格式 — Apache Arrow v16.1.0 - Arrow 中文
Arrays and tables in Arrow – Notes from a data witch (djnavarro.net)
Unpacking Arrow Datasets – Notes from a data witch (djnavarro.net)