本项目是基于 Feldera 的 Z-Set 实现文章 和 数据库计算文章 的 Scala 实现。Z-Set(Z-集合)是一种带权重的集合数据结构,每个元素都有一个关联的权重,这使得它特别适合用于增量计算、DBSP(Dataset Stream Processing)和实时数据库操作。
Z-Set 最初由 Differential Dataflow 项目引入,并在 DBSP 论文 中得到理论化。Feldera 使用 Z-Set 作为其增量计算引擎的核心数据结构,实现了高效的流式数据库查询处理。
Z-Set(Z-集合)是一种特殊的数据结构,其中:
- 每个元素都有一个关联的权重(支持整数、长整数、大整数等)
- 权重为正数表示元素在集合中存在
- 权重为负数表示元素被"移除"或"撤销"
- 权重为零意味着元素实际上不在集合中
- 相同元素的权重会累加
- 数学上等价于
Map[Element, Weight],但只存储非零权重的元素
- 增量性: Z-Set 支持增量更新,可以高效地处理数据变化而无需重新计算
- 可撤销性: 通过负权重,可以"撤销"之前的操作,实现数据的回滚
- 数学封闭性: Z-Set 操作的结果仍然是 Z-Set,满足代数群结构
- 数据库兼容性: 自然映射到关系数据库操作(SELECT、JOIN、GROUP BY等)
- 流处理适配: 特别适合处理数据流的插入、删除和更新操作
Z-Set 在数学上形成一个阿贝尔群(Abelian Group),继承了整数的群结构:
- 结合律:
(A ∪ B) ∪ C = A ∪ (B ∪ C) - 交换律:
A ∪ B = B ∪ A - 单位元: 空集合
∅ - 逆元: 每个 Z-Set 都有对应的负权重 Z-Set
这些性质使得 Z-Set 特别适合并行和分布式计算,因为操作的顺序不影响最终结果。
-
WeightType[T]: 权重类型的抽象接口
- 定义了权重的基本运算(加法、乘法、取反、零值、单位值等)
- 提供了不同数值类型的实现(Int、Long、BigInt)
- 支持溢出检查,避免静默的计算错误
- 遵循数学群的代数结构
-
ZSet[Data, Weight]: 主要的 Z-Set 实现
- 使用
mutable.Map[Data, Weight]高效存储数据和权重 - 维护权重非零的不变性(零权重元素自动移除)
- 提供丰富的函数式操作API
- 支持类型安全的泛型操作
- 使用
-
ZDataset: 数据库风格的高级操作
- 实现 SQL 风格操作:JOIN、GROUP BY、SELECT、WHERE、UNION 等
- 支持聚合函数:COUNT、SUM、AVG 等
- 提供排序和分页功能(sortBy、take)
- 实现笛卡尔积和交集运算
- 类型安全: 利用 Scala 的强类型系统确保操作的正确性
- 性能优化:
- 及时清理零权重元素,避免内存泄漏
- 使用高效的 HashMap 实现
- 支持惰性计算和流式处理
- 函数式设计:
- 大部分操作返回新的 Z-Set 实例(不可变性)
- 支持链式调用和函数组合
- 提供 map、filter、flatMap 等高阶函数
- 可扩展性:
- 支持自定义权重类型
- 模块化设计,便于扩展新操作
- 与 Scala 生态系统良好集成
权重类型必须满足以下数学性质,形成一个带乘法的阿贝尔群:
trait WeightType[T] {
def zero: T // 加法单位元
def one: T // 乘法单位元
def add(x: T, y: T): T // 加法运算(群运算)
def negate(x: T): T // 取反运算(逆元)
def multiply(x: T, y: T): T // 乘法运算(用于 JOIN)
def isZero(x: T): Boolean // 零值判断
def isPositive(x: T): Boolean // 正值判断
}权重类型必须满足:
- 加法结合律:
add(add(a, b), c) = add(a, add(b, c)) - 加法交换律:
add(a, b) = add(b, a) - 加法单位元:
add(a, zero) = a - 加法逆元:
add(a, negate(a)) = zero - 乘法结合律:
multiply(multiply(a, b), c) = multiply(a, multiply(b, c)) - 乘法单位元:
multiply(a, one) = a - 分配律:
multiply(a, add(b, c)) = add(multiply(a, b), multiply(a, c))
IntegerWeight: 32位整数,带溢出检查(Math.addExact, Math.multiplyExact)LongWeight: 64位长整数,适合大量计算BigIntWeight: 任意精度整数,避免溢出问题
根据 Feldera 的实现经验,权重溢出是实际应用中的严重问题:
- 中间计算结果可能产生非常大的权重值
- 静默的溢出会导致错误的计算结果
- 因此建议使用带检查的运算或任意精度数值类型
Feldera 的核心思想是建立传统数据库查询与 Z-Set 操作之间的数学等价性。对于任何数据库查询 Q 和输入表 I,都存在对应的 Z-Set 查询 Q_Z,使得:
Q(I) = toTable(Q_Z(fromTable(I)))
这种等价性保证了:
- 正确性: Z-Set 计算结果与传统 SQL 查询完全一致
- 增量性: Z-Set 查询可以处理负权重,支持增量更新
- 组合性: 复杂查询可以通过组合基本 Z-Set 操作实现
| SQL 操作 | Z-Set 操作 | 说明 |
|---|---|---|
| SELECT * FROM T WHERE P | filter(P) | 谓词过滤 |
| SELECT f(T.*) FROM T | map(f) | 投影变换 |
| SELECT * FROM T1, T2 | multiply(combiner) | 笛卡尔积 |
| T1 UNION ALL T2 | union(T2) | 并集(保持重复) |
| T1 UNION T2 | union(T2).distinct | 并集(去重) |
| T1 EXCEPT T2 | T1.union(T2.negate).positive | 差集 |
| SELECT COUNT(*) FROM T | aggregate(count) | 聚合计数 |
| SELECT k, SUM(v) FROM T GROUP BY k | groupBy(k).map(sum) | 分组聚合 |
传统查询重新计算整个结果:
Result_new = Q(Data_old ∪ ΔData)
Z-Set 增量计算:
ΔResult = Q_Z(ΔData_as_ZSet)
Result_new = Result_old ∪ ΔResult
这种方法的优势:
- 计算复杂度从 O(n) 降低到 O(Δn)
- 支持批量更新和撤销操作
- 天然支持并行和分布式计算
-
零权重清理策略:
- 在每次权重更新后立即检查并移除零权重元素
- 使用
Map.merge操作的回调机制自动清理 - 避免内存泄漏和无效数据累积
-
权重合并算法:
def merger(oldWeight: Weight, newWeight: Weight): Option[Weight] = { val result = weightType.add(oldWeight, newWeight) if (weightType.isZero(result)) None else Some(result) }
-
类型特化优化:
- 针对常用权重类型(Int、Long)提供特化实现
- 避免装箱开销,提高数值计算性能
- 使用 Scala 的
@specialized注解
| 操作类型 | 时间复杂度 | 空间复杂度 | 备注 |
|---|---|---|---|
| append/getWeight | O(1) 平均 | O(1) | HashMap 查找 |
| union/difference | O(min(n,m)) | O(n+m) | 遍历较小集合 |
| map/filter | O(n) | O(n) | 线性遍历 |
| flatMap | O(n×k) | O(n×k) | k 为映射扩展因子 |
| join | O(n×m) | O(n×m) | 笛卡尔积 |
| groupBy | O(n) | O(n) | 单次遍历 |
| aggregate | O(n) | O(1) | 累积计算 |
当前实现基于可变的 HashMap,不是线程安全的。在并发环境中使用时:
- 外部同步: 使用锁机制保护 Z-Set 操作
- 不可变版本: 考虑实现基于不可变数据结构的版本
- 函数式操作: 大部分操作返回新实例,支持函数式编程风格
// 高效:重用权重类型实例
val weightType = IntegerWeight
val zset1 = ZSet.empty[String, Int](weightType)
val zset2 = ZSet.empty[String, Int](weightType)
// 低效:频繁创建小 Z-Set
val items = List("a", "b", "c")
val result = items.foldLeft(ZSet.empty[String, Int](IntegerWeight)) { (acc, item) =>
acc.add(ZSet.single(item, 1, IntegerWeight)) // 创建临时对象
}
// 高效:批量操作
val result = ZSet.fromIterable(items, IntegerWeight)append(value, weight): 添加元素及权重,支持权重累加getWeight(value): 获取元素权重,不存在则返回零contains(value): 检查元素是否存在(权重非零)isEmpty: 检查是否为空集合entryCount: 返回非零权重元素的数量
add(other): 并集操作(权重相加)subtract(other): 差集操作(权重相减)intersect(other): 交集操作(权重相乘)positive(): 获取正权重元素的 Z-Setdistinct: 将所有正权重规范化为1,得到标准集合
map(f): 元素转换,保持权重不变filter(predicate): 根据谓词过滤元素flatMap(f): 扁平化映射,支持一对多转换scale(factor): 缩放所有权重negate: 所有权重取反
ZSet.empty: 创建指定权重类型的空 Z-SetZSet.fromIterable: 从集合创建,每个元素权重为1ZSet.fromPairs: 从(元素,权重)对列表创建ZSet.single: 创建包含单个元素的 Z-Set
join(left, right): 两个 Z-Set 的自然连接groupBy(zset, keyFunc): 按键分组聚合select(zset, projection): 投影操作(SQL SELECT)where(zset, predicate): 条件过滤(SQL WHERE)sortBy(zset, keyFunc): 按指定键排序take(zset, n): 获取前 n 个元素count(zset): 计数聚合操作cartesianProduct(left, right): 笛卡尔积
Z-Set 的核心优势在于支持增量计算,避免重新计算整个数据集:
// 初始数据
val initial = ZSet.fromIterable(List("a", "b", "c"), IntegerWeight)
// 增量更新:添加元素
val update1 = ZSet.single("d", 1, IntegerWeight)
val result1 = initial.union(update1)
// 增量更新:移除元素
val update2 = ZSet.single("b", -1, IntegerWeight)
val result2 = result1.union(update2)使用 ZSetDataset 模拟复杂的数据库操作:
// 模拟表连接
val students = ZSet.fromPairs(List(
("Alice", Student("Alice", 20)),
("Bob", Student("Bob", 21))
), IntegerWeight)
val enrollments = ZSet.fromPairs(List(
("Alice", Course("Math")),
("Alice", Course("Physics")),
("Bob", Course("Math"))
), IntegerWeight)
// 执行连接操作
val joined = students.join(enrollments)Z-Set 特别适合处理数据流的插入、删除和更新事件:
// 处理事件流
val insertEvents = ZSet.fromPairs(List(("user1", 1), ("user2", 1)), IntegerWeight)
val deleteEvents = ZSet.fromPairs(List(("user1", -1)), IntegerWeight)
// 合并事件,得到最终状态
val finalState = insertEvents.add(deleteEvents)
// 结果:只包含 user2Z-Set 可以自然地表示多重集合(允许重复元素):
// 统计词频
val words = List("hello", "world", "hello", "scala")
val wordCount = ZSet.fromIterable(words, IntegerWeight)
println(wordCount.getWeight("hello")) // 输出: 2利用负权重实现操作的撤销:
// 原始操作
val operation1 = ZSet.single("data", 1, IntegerWeight)
val state1 = ZSet.empty[String, Int](IntegerWeight).union(operation1)
// 撤销操作
val undoOperation1 = ZSet.single("data", -1, IntegerWeight)
val state2 = state1.union(undoOperation1)
// 结果:回到空状态| 特性 | 传统Set | Z-Set |
|---|---|---|
| 元素唯一性 | 是 | 否(通过权重区分) |
| 删除操作 | 立即删除 | 可通过负权重"软删除" |
| 增量更新 | 需要重建 | 天然支持 |
| 撤销操作 | 困难 | 容易(负权重) |
| 数学性质 | 有限 | 丰富(群结构) |
Z-Set 在数学上形成一个阿贝尔群(Abelian Group):
- 结合律: (A ∪ B) ∪ C = A ∪ (B ∪ C)
- 交换律: A ∪ B = B ∪ A
- 单位元: 空集合
- 逆元: 每个 Z-Set 都有对应的负权重 Z-Set
这些性质使得 Z-Set 特别适合并行和分布式计算。
- 基本操作: O(1) 平均情况
- 并集/差集: O(n + m),其中 n, m 是两个集合的大小
- map/filter: O(n)
- flatMap: O(n × k),其中 k 是映射结果的平均大小
- 存储: O(n),只存储非零权重元素
- 操作: 大部分操作需要 O(n) 额外空间
- 即时清理: 权重变为零时立即移除元素
- 权重合并: 高效的权重累加逻辑
- 类型特化: 针对不同权重类型的优化实现
- 数学基础: 都基于相同的 Z-Set 理论和 DBSP 框架
- 权重抽象: 都提供了权重类型的抽象接口
- 操作等价性: 都实现了与 SQL 查询等价的 Z-Set 操作
- 增量计算: 都支持高效的增量更新机制
| 方面 | Feldera (Rust) | 本实现 (Scala) |
|---|---|---|
| 性能 | 零开销抽象,原生性能 | JVM 开销,但开发效率高 |
| 内存管理 | 手动/RAII | GC 自动管理 |
| 类型系统 | Trait + 特化 | 泛型 + @specialized |
| 并发模型 | 零成本并发原语 | JVM 线程模型 |
| 生态集成 | Rust 生态 | Scala/JVM 生态 |
选择 Feldera 的情况:
- 需要极致性能的生产环境
- 大规模流数据处理
- 系统级编程需求
- 内存使用敏感的应用
选择本实现的情况:
- 快速原型开发和学习
- 与现有 JVM 系统集成
- 需要丰富的 Scala 生态支持
- 重视开发速度和表达力
- 持久化支持: 添加序列化/反序列化功能
- 并行操作: 利用 Scala 的并行集合
- 惰性求值: 某些操作可以延迟执行
- 压缩存储: 对于稀疏 Z-Set 的优化存储
- 流式处理: 与 Akka Streams 等流处理框架集成
-
Differential Dataflow:
- Frank McSherry 的开创性工作
- 增量计算的理论基础
- 数据流处理的数学模型
-
DBSP (Dataset Stream Processing):
- CIDR 2023 论文
- 统一批处理和流处理的理论框架
- Z-Set 作为核心抽象
-
代数数据类型:
- 群论在计算机科学中的应用
- 可组合的计算模型
- 函数式编程的数学基础
-
分布式 Z-Set:
- 跨节点的权重分布策略
- 网络分区容错机制
- 一致性保证和性能权衡
-
时间感知 Z-Set:
- 时间维度的权重衰减
- 时间窗口聚合操作
- 流式时间序列分析
-
近似计算:
- 概率权重类型
- Sketch 数据结构集成
- 内存和精度的权衡
-
查询优化:
- Z-Set 查询计划生成
- 增量计算的物化视图
- 代价模型和统计信息
-
核心论文:
-
实现参考:
-
理论背景:
- Incremental computation and the incremental evaluation of functional programs
- Dataflow programming models and execution engines
- Algebraic structures in computer science
项目包含全面的单元测试,覆盖:
- 基本操作的正确性
- 边界条件处理
- 权重合并逻辑
- 不变性维护
- 性能基准测试
测试使用 MUnit 框架,可通过 scala-cli test . --test-only "*xxx*" 运行。