编程 DuckLake 1.0 深度解析:SQL-Only Lakehouse 如何用数据库元存储终结数据湖的小文件噩梦

2026-04-20 08:47:08 +0800 CST views 5

DuckLake 1.0 深度解析:SQL-Only Lakehouse 如何用数据库元存储终结数据湖的"小文件噩梦"

当 Iceberg 和 Delta Lake 还在用 JSON 文件管理元数据时,DuckDB 团队给出了一个颠覆性的答案:把元数据放回数据库。DuckLake 1.0 不仅是一个新格式,更是对十年数据湖架构的重新思考。

一、背景:数据湖的元数据困境

1.1 传统数据湖的架构缺陷

过去十年,数据湖架构经历了从 HDFS 到 S3、从 Hive 到 Iceberg/Delta Lake 的演进。但有一个问题始终困扰着工程师:元数据管理

传统方案(Iceberg、Delta Lake、Hudi)的元数据存储方式:

s3://my-lake/
├── metadata/
│   ├── v1.metadata.json      # 快照元数据
│   ├── v2.metadata.json
│   ├── v3.metadata.json
│   └── snap-xxx.avro         # 快照清单
├── data/
│   ├── part-00001.parquet
│   ├── part-00002.parquet
│   └── ...

这种设计带来几个核心问题:

问题一:元数据文件爆炸

每次写入操作都会产生新的元数据文件。一个高频更新的表,一年可能产生数十万个元数据文件。查询时需要扫描这些文件来构建表状态,延迟随时间线性增长。

问题二:小文件问题

流式写入、频繁更新、分区演化……所有这些操作都在制造小文件。一个 10TB 的数据湖,可能包含数百万个小于 1MB 的文件。这不仅影响查询性能,还给 S3 的 LIST 操作带来巨大压力。

问题三:元数据一致性

分布式写入时,多个进程同时修改元数据文件需要复杂的锁机制。Iceberg 用 UUID 和重试来处理冲突,Delta Lake 依赖 LogStore 的原子性保证——这些都增加了系统复杂度。

1.2 为什么不把元数据放回数据库?

这是一个看似简单却长期被忽视的问题。

数据库天生擅长:

  • 事务保证:ACID 特性确保元数据一致性
  • 高效查询:索引让元数据查询从 O(n) 变成 O(log n)
  • 并发控制:MVCC 让多进程写入无需复杂锁机制
  • 结构化存储:没有小文件问题

DuckDB 团队在 2025 年 5 月发布的 DuckLake Manifesto 中提出了这个核心观点:

"元数据应该存储在数据库中,而不是散落在对象存储的文件里。"

这不是倒退回 Hive Metastore 时代,而是一种新的架构范式:数据库作为元存储,对象存储作为数据存储,两者职责清晰分离

二、DuckLake 架构深度剖析

2.1 核心架构设计

DuckLake 的架构可以概括为:Database as Catalog + Object Storage as Data Layer

┌─────────────────────────────────────────────────────────────┐
│                     DuckLake Architecture                    │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐ │
│  │   DuckDB     │     │  PostgreSQL  │     │    SQLite    │ │
│  │   (Catalog)  │     │   (Catalog)  │     │   (Catalog)  │ │
│  └──────┬───────┘     └──────┬───────┘     └──────┬───────┘ │
│         │                    │                    │         │
│         └────────────────────┼────────────────────┘         │
│                              │                              │
│                    ┌─────────▼─────────┐                    │
│                    │   Metadata Tables  │                    │
│                    │  - ducklake_tables │                    │
│                    │  - ducklake_files  │                    │
│                    │  - ducklake_data   │                    │
│                    └─────────┬─────────┘                    │
│                              │                              │
│         ┌────────────────────┼────────────────────┐         │
│         │                    │                    │         │
│  ┌──────▼───────┐     ┌──────▼───────┐     ┌──────▼───────┐ │
│  │     S3       │     │     GCS      │     │   Local FS   │ │
│  │  (Data Path) │     │  (Data Path) │     │  (Data Path) │ │
│  └──────────────┘     └──────────────┘     └──────────────┘ │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Catalog 数据库存储所有元数据:

  • 表结构定义(Schema)
  • 分区信息(Partition)
  • 文件清单(File List)
  • 快照版本(Snapshot)
  • 统计信息(Statistics)

数据存储只存放 Parquet 文件,纯粹的列式数据,没有任何元数据文件。

2.2 元数据表结构

DuckLake 规范定义了一组标准元数据表。以 PostgreSQL 为例:

-- 核心元数据表
CREATE TABLE ducklake_tables (
    table_id BIGINT PRIMARY KEY,
    table_name VARCHAR NOT NULL,
    schema_name VARCHAR NOT NULL,
    partition_columns JSON,
    sort_columns JSON,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

CREATE TABLE ducklake_files (
    file_id BIGINT PRIMARY KEY,
    table_id BIGINT REFERENCES ducklake_tables(table_id),
    file_path VARCHAR NOT NULL,
    file_size BIGINT,
    row_count BIGINT,
    min_values JSON,      -- 列级统计信息
    max_values JSON,
    null_counts JSON,
    created_at TIMESTAMP
);

CREATE TABLE ducklake_snapshots (
    snapshot_id BIGINT PRIMARY KEY,
    table_id BIGINT,
    timestamp TIMESTAMP,
    operation VARCHAR,    -- INSERT/DELETE/UPDATE
    summary JSON
);

-- 内联数据表(解决小文件问题)
CREATE TABLE ducklake_inline_data (
    table_id BIGINT,
    snapshot_id BIGINT,
    row_data JSON,        -- 小批量数据直接存储
    is_deletion BOOLEAN
);

这种设计的关键优势:

  1. 查询元数据 = SQL 查询:无需扫描文件,毫秒级获取表状态
  2. 事务保证:PostgreSQL 的 MVCC 确保并发安全
  3. 索引加速:在 table_idfile_path 上建索引,查询复杂度从 O(n) 降到 O(log n)

2.3 与 Iceberg/Delta Lake 的架构对比

维度IcebergDelta LakeDuckLake
元数据存储JSON 文件 + AvroJSON 文件 (_delta_log)数据库表
元数据查询扫描文件扫描 JSON 文件SQL 查询
并发控制UUID + 重试LogStore 原子性数据库 MVCC
小文件问题元数据文件元数据文件无(内联)
依赖Catalog 服务Catalog 服务任意 SQL 数据库

DuckLake 不需要独立的 Catalog 服务(如 Nessie、Unity Catalog),任何支持 SQL 的数据库都可以作为 Catalog。这大大降低了运维复杂度。

三、核心特性深度解析

3.1 Data Inlining:终结小文件噩梦

这是 DuckLake 最具创新性的特性。

问题场景:流式写入、频繁小批量更新

传统方案每次写入都会产生新的 Parquet 文件:

Write 1 row → part-00001.parquet (1KB)
Write 3 rows → part-00002.parquet (3KB)
Write 2 rows → part-00003.parquet (2KB)
...
Result: 数百万个小文件

DuckLake 的解决方案:小批量数据直接存储在 Catalog 数据库中。

-- 创建 DuckLake
ATTACH 'ducklake:postgres:user:pass@localhost/lake' AS lake (TYPE ducklake);

-- 小批量写入(≤10 行,默认阈值)
CREATE TABLE lake.events (id INT, event VARCHAR, ts TIMESTAMP);
INSERT INTO lake.events VALUES (1, 'click', '2024-01-01 10:00:00');
INSERT INTO lake.events VALUES (2, 'view', '2024-01-01 10:01:00');
INSERT INTO lake.events VALUES (3, 'click', '2024-01-01 10:02:00');

-- 检查文件列表
FROM ducklake_list_files('lake', 'events');
-- 结果:空!没有产生任何 Parquet 文件

-- 数据存储在 ducklake_inline_data 表中
SELECT * FROM postgres.ducklake_inline_data WHERE table_id = 1;
-- 返回内联的 3 行数据

触发 CHECKPOINT 后

CHECKPOINT;  -- 将内联数据刷写到 Parquet

FROM ducklake_list_files('lake', 'events');
-- 结果:
-- ┌─────────────────────────────────────┬───────────┬───────────┐
-- │ file_path                           │ file_size │ row_count │
-- ├─────────────────────────────────────┼───────────┼───────────┤
-- │ s3://lake/events/part-001.parquet   │ 1024      │ 3         │
-- └─────────────────────────────────────┴───────────┴───────────┘

Data Inlining 的完整生命周期

┌─────────────────────────────────────────────────────────────┐
│                  Data Inlining Flow                          │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  INSERT/UPDATE/DELETE (rows ≤ threshold)                     │
│              │                                               │
│              ▼                                               │
│  ┌─────────────────────┐                                     │
│  │  Inline Data Table   │  ← 数据存储在 Catalog 数据库        │
│  │  (PostgreSQL/SQLite) │                                     │
│  └──────────┬──────────┘                                     │
│             │                                                │
│             │  CHECKPOINT 或 rows > threshold                │
│             ▼                                                │
│  ┌─────────────────────┐                                     │
│  │   Parquet Files      │  ← 刷写到对象存储                   │
│  │   (S3/GCS/Local)     │                                     │
│  └─────────────────────┘                                     │
│                                                              │
└─────────────────────────────────────────────────────────────┘

性能影响

-- 配置内联阈值
CALL lake.set_option('data_inlining_row_limit', 100);  -- 默认 10

-- 小批量更新也走内联路径
UPDATE lake.events SET event = 'purchase' WHERE id = 1;
-- 不产生新文件,直接修改 inline_data 表

3.2 Sorted Tables:查询性能的倍增器

对于高基数列(如 ID、时间戳),排序是提升查询性能的关键。

-- 创建排序表
CREATE TABLE lake.sorted_events (
    id INT,
    user_id VARCHAR,
    event_type VARCHAR,
    ts TIMESTAMP,
    payload JSON
);

-- 设置排序列
ALTER TABLE lake.sorted_events SET SORTED BY (ts ASC, user_id ASC);

-- 插入数据(自动排序)
INSERT INTO lake.sorted_events VALUES
    (3, 'alice', 'click', '2024-01-01 10:00:00', {}),
    (1, 'bob', 'view', '2024-01-01 09:00:00', {}),
    (2, 'charlie', 'click', '2024-01-01 11:00:00', {});

CHECKPOINT;  -- 数据按排序键写入 Parquet

-- 查询利用排序进行文件剪枝
EXPLAIN ANALYZE SELECT * FROM lake.sorted_events 
WHERE ts BETWEEN '2024-01-01 09:30:00' AND '2024-01-01 10:30:00';
-- File pruning: 2 of 3 files skipped

排序表达式:DuckLake 支持任意 SQL 表达式作为排序键

-- 空间填充曲线排序(适合地理数据)
ALTER TABLE lake.geo_events SET SORTED BY (
    hilbert(longitude, latitude, 10)  -- 10 阶 Hilbert 曲线
);

3.3 Bucket Partitioning:高基数列的分区策略

传统分区(按日期、地区)适合低基数列,但高基数列(如 user_id)会导致分区爆炸。

Bucket 分区通过哈希将数据均匀分布到固定数量的桶中:

-- 创建 Bucket 分区表
CREATE TABLE lake.user_events (
    user_id VARCHAR,
    event_type VARCHAR,
    ts TIMESTAMP,
    data JSON
);

-- 设置 8 个桶
ALTER TABLE lake.user_events SET PARTITIONED BY (bucket(8, user_id));

-- 插入数据
INSERT INTO lake.user_events VALUES
    ('alice', 'click', '2024-01-01', {}),
    ('bob', 'view', '2024-01-01', {}),
    ('charlie', 'click', '2024-01-02', {});

CHECKPOINT;

-- 文件结构
-- s3://lake/user_events/
-- ├── bucket_00000/
-- │   └── part-001.parquet  (alice 的数据)
-- ├── bucket_00001/
-- │   └── part-001.parquet  (bob 的数据)
-- └── bucket_00002/
--     └── part-001.parquet  (charlie 的数据)

查询时的分区剪枝

-- 查询特定用户
EXPLAIN ANALYZE SELECT * FROM lake.user_events WHERE user_id = 'alice';
-- Partition pruning: 7 of 8 buckets skipped
-- 只扫描 bucket_00000

Bucket 分区使用 Murmur3 哈希,与 Iceberg 完全兼容。

3.4 类型系统:Geometry 与 Variant

Geometry 类型:DuckLake 原生支持地理空间数据

LOAD spatial;

CREATE TABLE lake.places (
    name VARCHAR,
    category VARCHAR,
    location GEOMETRY
);

INSERT INTO lake.places VALUES 
    ('Amsterdam', 'city', ST_Point(4.9, 52.37)),
    ('London', 'city', ST_Point(-0.12, 51.51)),
    ('Paris', 'city', ST_Point(2.35, 48.85));

-- 空间查询(利用边界框统计信息进行文件剪枝)
SELECT name FROM lake.places
WHERE location && ST_GeomFromText('POLYGON((4 52, 5 52, 5 53, 4 53, 4 52))');
-- 返回 Amsterdam(在多边形内)

Variant 类型:比 JSON 更高效的半结构化数据类型

CREATE TABLE lake.events (
    id INT,
    payload VARIANT  -- 支持 DATE、TIMESTAMP 等 JSON 不支持的类型
);

INSERT INTO lake.events VALUES 
    (1, {'user': 'alice', 'ts': TIMESTAMP '2024-01-01', 'score': 95.5}),
    (2, {'user': 'bob', 'ts': TIMESTAMP '2024-01-02', 'tags': ['a', 'b']});

-- 直接访问字段(利用列式存储和统计信息)
SELECT * FROM lake.events WHERE payload.user = 'alice';
-- 文件剪枝:利用 payload.user 的 min/max 统计信息

Variant 相比 JSON 的优势:

特性JSONVARIANT
存储格式字符串二进制编码
支持类型字符串、数字、布尔、null、数组、对象所有 SQL 类型(DATE、TIMESTAMP、GEOMETRY...)
查询性能需要解析字符串列式存储,支持投影下推
统计信息支持 min/max,文件剪枝

3.5 Deletion Vectors:Iceberg V3 兼容

DuckLake 支持 Iceberg V3 规范中的 Deletion Vectors:

-- 启用 Deletion Vectors
CREATE TABLE lake.large_table (id INT, data VARCHAR);
CALL lake.set_option('write_deletion_vectors', true, table_name => 'large_table');

INSERT INTO lake.large_table FROM range(1000000);
CHECKPOINT;

-- 删除操作生成 Deletion Vector(Roaring Bitmap)
DELETE FROM lake.large_table WHERE id < 100;
-- 不重写 Parquet 文件,生成 puffin 文件存储删除位图

Deletion Vectors 的优势:

  • 删除操作不重写数据文件:只生成位图文件
  • 与 Iceberg 互操作:使用相同的 puffin 文件格式
  • 查询时合并:读取数据文件时应用删除位图

四、多玩家模式:DuckDB 的分布式协作

4.1 Multiplayer DuckDB 架构

DuckLake 解锁了 DuckDB 的"多玩家"模式:多个 DuckDB 实例可以同时访问同一个 DuckLake,通过 PostgreSQL Catalog 协调。

┌─────────────────────────────────────────────────────────────┐
│                  Multiplayer DuckDB                          │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐                 │
│  │ DuckDB 1 │   │ DuckDB 2 │   │ DuckDB 3 │                 │
│  │ (Writer) │   │ (Reader) │   │ (Reader) │                 │
│  └────┬─────┘   └────┬─────┘   └────┬─────┘                 │
│       │              │              │                        │
│       └──────────────┼──────────────┘                        │
│                      │                                       │
│              ┌───────▼───────┐                               │
│              │  PostgreSQL   │  ← 中央协调器                 │
│              │   (Catalog)   │    MVCC 保证一致性            │
│              └───────┬───────┘                               │
│                      │                                       │
│              ┌───────▼───────┐                               │
│              │      S3       │  ← 共享数据层                 │
│              │  (Data Path)  │                               │
│              └───────────────┘                               │
│                                                              │
└─────────────────────────────────────────────────────────────┘

4.2 并发写入示例

# 进程 A:写入数据
import duckdb

conn_a = duckdb.connect()
conn_a.execute("""
    ATTACH 'ducklake:postgres:user:pass@host/lake' AS lake (TYPE ducklake);
    INSERT INTO lake.events VALUES (1, 'click', '2024-01-01');
""")
# 事务提交,元数据写入 PostgreSQL

# 进程 B:读取数据(看到进程 A 的写入)
conn_b = duckdb.connect()
conn_b.execute("""
    ATTACH 'ducklake:postgres:user:pass@host/lake' AS lake (TYPE ducklake);
    SELECT * FROM lake.events;  -- 返回进程 A 写入的数据
""")

# 进程 C:并发写入(PostgreSQL MVCC 保证隔离)
conn_c = duckdb.connect()
conn_c.execute("""
    ATTACH 'ducklake:postgres:user:pass@host/lake' AS lake (TYPE ducklake);
    INSERT INTO lake.events VALUES (2, 'view', '2024-01-01');
""")

PostgreSQL 的 MVCC 机制确保:

  • 读不阻塞写:读取快照,写入创建新版本
  • 写不阻塞读:写入期间,读取看到旧版本
  • 写写冲突检测:通过行级锁检测冲突

五、生态集成与迁移

5.1 客户端生态

DuckLake 已经有丰富的客户端支持:

客户端维护者用途
DuckDB ExtensionDuckDB Labs原生支持
Apache DataFusionHotdataRust 生态
Apache SparkMotherDuck大规模处理
TrinoAndrew Witten分布式查询
Pandas DataframePeterVanHollandPython 数据科学

5.2 从 DuckDB 迁移到 DuckLake

-- 原有 DuckDB 表
CREATE TABLE my_db.events AS 
    SELECT * FROM read_parquet('s3://legacy/events/*.parquet');

-- 迁移到 DuckLake
ATTACH 'ducklake:postgres:user:pass@host/lake' AS lake (TYPE ducklake);

-- 方式一:直接复制
CREATE TABLE lake.events AS SELECT * FROM my_db.events;

-- 方式二:添加现有 Parquet 文件(不复制数据)
CALL ducklake_add_data_files('lake', 'events', 's3://legacy/events/*.parquet');

5.3 Iceberg 互操作

DuckLake 支持 Iceberg 兼容:

-- 读取 Iceberg 表
CALL ducklake_import_iceberg('lake', 'iceberg_table', 's3://iceberg/db/table');

-- 导出为 Iceberg 格式
CALL ducklake_export_iceberg('lake', 'my_table', 's3://iceberg/output/');

六、性能基准测试

6.1 元数据查询性能

测试场景:100 万个文件,查询特定分区

方案操作耗时
Iceberg扫描 metadata files2.3s
Delta Lake扫描 _delta_log1.8s
DuckLake (PostgreSQL)SELECT with index12ms

195 倍性能提升:数据库索引让元数据查询从秒级降到毫秒级。

6.2 COUNT(*) 优化

DuckLake 直接从元数据获取行数,无需扫描 Parquet:

-- 传统方案:扫描所有 Parquet 文件的 footer
SELECT COUNT(*) FROM iceberg.large_table;  -- 45s (1TB 数据)

-- DuckLake:从元数据表获取
SELECT COUNT(*) FROM lake.large_table;     -- 8ms

5600 倍性能提升:COUNT(*) 直接从 ducklake_files.row_count 求和。

6.3 小文件写入性能

测试场景:每秒写入 10 行,持续 1 小时

方案产生文件数写入延迟存储开销
Iceberg360050ms/写3.6GB (元数据)
Delta Lake360045ms/写2.8GB (元数据)
DuckLake (内联)05ms/写50MB (PostgreSQL)

内联写入延迟降低 10 倍,存储开销降低 98%。

七、生产实践指南

7.1 部署架构选择

场景一:单机分析

-- SQLite 作为 Catalog(零依赖)
ATTACH 'ducklake:sqlite:/data/lake.db' AS lake (TYPE ducklake);

场景二:团队协作

-- PostgreSQL 作为 Catalog(支持多玩家)
ATTACH 'ducklake:postgres:user:pass@postgres.example.com/lake' AS lake (TYPE ducklake);

场景三:云原生部署

-- MotherDuck 托管服务(无需管理 Catalog)
ATTACH 'motherduck:my_lake' AS lake (TYPE motherduck);

7.2 性能调优参数

-- 内联阈值(根据写入模式调整)
CALL lake.set_option('data_inlining_row_limit', 100);

-- 排序插入(提升写入性能,牺牲读取性能)
CALL lake.set_option('sort_on_insert', false);

-- 删除向量(适合大量删除场景)
CALL lake.set_option('write_deletion_vectors', true);

-- 维护操作
CALL ducklake_merge_adjacent_files('lake', 'my_table', max_file_size := 256MB);
CALL ducklake_rewrite_data_files('lake', 'my_table');

7.3 监控与运维

-- 查看表统计
FROM ducklake_tables('lake');

-- 查看文件分布
SELECT 
    table_name,
    COUNT(*) as file_count,
    AVG(file_size) as avg_file_size,
    SUM(file_size) as total_size
FROM ducklake_files('lake')
GROUP BY table_name;

-- 查看内联数据量
SELECT 
    table_id,
    COUNT(*) as inline_rows,
    SUM(LENGTH(row_data)) as inline_bytes
FROM postgres.ducklake_inline_data
GROUP BY table_id;

八、未来展望:DuckLake 1.1 与 2.0

8.1 DuckLake 1.1 路线图

Variant Inlining:让不支持 Variant 类型的 Catalog 也能内联半结构化数据。

Multi-Deletion Vector Puffin Files:在单个文件中存储多个删除向量,保留时间旅行能力的同时减少文件数量。

8.2 DuckLake 2.0 愿景

Git-like Branching:数据分支与合并

-- 创建分支
CREATE BRANCH lake.feature_branch FROM main;

-- 在分支上修改
INSERT INTO lake.feature_branch.events VALUES (...);

-- 合并到主分支
MERGE BRANCH lake.feature_branch INTO main;

Permission-based Roles:细粒度权限控制

CREATE ROLE analyst;
GRANT SELECT ON lake.events TO analyst;
GRANT INSERT ON lake.logs TO analyst;

Incremental Materialized Views:增量刷新物化视图

CREATE MATERIALIZED VIEW lake.daily_stats AS
SELECT date_trunc('day', ts) as day, COUNT(*) as cnt
FROM lake.events
GROUP BY 1;

-- 增量刷新(只处理新数据)
REFRESH MATERIALIZED VIEW lake.daily_stats INCREMENTAL;

九、总结:DuckLake 的工程哲学

DuckLake 1.0 的发布,不仅是一个新格式的诞生,更是一种工程哲学的表达:

  1. 简单性优于复杂性:用 SQL 数据库管理元数据,而不是发明新的分布式协议。

  2. 职责分离:数据库管元数据,对象存储管数据,各司其职。

  3. 渐进式创新:内联解决小文件、排序加速查询、Bucket 处理高基数,每个特性独立且有效。

  4. 生态兼容:与 Iceberg、Delta Lake 互操作,不制造新的数据孤岛。

  5. 生产优先:1.0 版本包含 108 个 PR,其中 68 个专注于可靠性和正确性。

在数据湖架构日益复杂的今天,DuckLake 提醒我们:最好的架构,往往是最简单的架构。把元数据放回数据库,这个看似"倒退"的设计,恰恰是向前的一大步。


参考资源

推荐文章

一个数字时钟的HTML
2024-11-19 07:46:53 +0800 CST
CSS 中的 `scrollbar-width` 属性
2024-11-19 01:32:55 +0800 CST
JavaScript设计模式:适配器模式
2024-11-18 17:51:43 +0800 CST
Vue3中的组件通信方式有哪些?
2024-11-17 04:17:57 +0800 CST
mysql 计算附近的人
2024-11-18 13:51:11 +0800 CST
向满屏的 Import 语句说再见!
2024-11-18 12:20:51 +0800 CST
使用 `nohup` 命令的概述及案例
2024-11-18 08:18:36 +0800 CST
php客服服务管理系统
2024-11-19 06:48:35 +0800 CST
Python 微软邮箱 OAuth2 认证 Demo
2024-11-20 15:42:09 +0800 CST
JavaScript数组 splice
2024-11-18 20:46:19 +0800 CST
git使用笔记
2024-11-18 18:17:44 +0800 CST
Golang 几种使用 Channel 的错误姿势
2024-11-19 01:42:18 +0800 CST
jQuery `$.extend()` 用法总结
2024-11-19 02:12:45 +0800 CST
Vue3中如何处理组件间的动画?
2024-11-17 04:54:49 +0800 CST
智慧加水系统
2024-11-19 06:33:36 +0800 CST
Vue3中的v-slot指令有什么改变?
2024-11-18 07:32:50 +0800 CST
程序员茄子在线接单