Delta Lake 核心原理:ACID 事务、Time Travel 与数据治理实战
1. 项目概述为什么 Delta Table 不是“又一个文件格式”而是数据工程的底层操作系统在 Databricks 平台上你可能已经无数次点开过一个表看到它的类型写着DELTA也可能在写 SQL 时顺手加了OPTIMIZE或VACUUM却没细想背后发生了什么。但真正让我在凌晨三点盯着一个因并发写入失败而卡住的流水线、反复翻看 Delta Log 的 JSON 文件时才彻底明白Delta Table 不是 Databricks 的一个功能插件它是整个 Lakehouse 架构的基石性抽象——它把对象存储S3/ADLS变成了可事务、可版本、可审计、可回滚的“数据库式”数据层。这个认知转变直接决定了你是把 Databricks 当成高级 Spark Shell 用还是把它当成企业级数据平台来治理。核心关键词——Delta Table、Databricks、ACID 事务、Time Travel、Schema Enforcement、Optimize、Vacuum——不是孤立的技术名词而是一套环环相扣的工程契约。比如Schema Enforcement看似只是“拒绝坏数据”实则是在源头堵死下游数仓因字段错位导致的报表雪崩Time Travel表面是“查历史快照”本质是给数据科学家提供无风险的 A/B 实验沙盒而OPTIMIZE和VACUUM的组合拳根本不是“清理磁盘”而是对抗对象存储天然的高延迟与小文件病的系统性工程方案。我带过的三个团队里有两支在上线 Delta 后把 ETL 故障平均恢复时间从 47 分钟压到 90 秒关键不是用了新工具而是终于有了可预测、可追溯、可干预的数据状态机。适合谁读如果你还在用df.write.mode(overwrite).save(s3://...)脚本硬刷分区或者每次改表结构都要发邮件拉 DBA 开会审批那这篇就是你的“止损指南”。如果你已用上 Delta但DESCRIBE HISTORY输出的 JSON 还像天书VACUUM后发现某张关键表突然查不到昨天的数据那你需要的是“深度控制手册”。它不假设你懂分布式事务理论但要求你愿意拆开 Delta 的.delta目录亲手看看那些_commit_timestamp.json文件里到底写了什么。因为真正的掌控感永远来自对底层契约的亲手验证。2. 核心设计哲学与架构解构为什么 Delta 必须自己管理事务日志2.1 传统数据湖的“三宗罪”与 Delta 的针对性破局要理解 Delta 的设计必须先看清它要解决的旧世界顽疾。我在金融风控团队做过三年离线特征计算当时的数据湖是典型的“Hive on S3”架构每天凌晨跑完 200 个 Spark 作业结果永远像开盲盒第一宗罪写入不一致No ACID两个作业同时向同一张表的dt20240501分区写入Spark 默认行为是“后写覆盖前写”但实际执行中A 作业写完_SUCCESS文件B 作业正在写 Parquet 数据块时集群重启——结果分区里混着 A 的元数据和 B 的残缺数据块。下游调度系统检测到_SUCCESS就触发报表报表直接报错java.io.FileNotFoundException: ...part-00001.snappy.parquet。这不是 Bug是对象存储最终一致性模型下的必然结果。Delta 的解法极其朴素所有写入操作INSERT/UPDATE/DELETE/MERGE都必须先写入一个中心化的事务日志Transaction Log只有日志提交成功数据文件的变更才对查询可见。这个日志本身存于_delta_log/目录下是纯文本 JSON 文件序列每条记录代表一次原子操作。第二宗罪模式裸奔Schema Wild West市场部临时提需求让用户画像表加个is_premium_user字段ETL 工程师用df.withColumn(is_premium_user, lit(None))补空值但忘了设nullableFalse。三天后 BI 报表里出现大量NULL业务方质疑“你们数据不准”而工程师翻代码发现上游某个 Kafka 消费作业漏传了该字段且已运行两周。Delta 的Schema Enforcement强制所有写入必须严格匹配表定义ALTER TABLE ... ADD COLUMN会自动更新元数据并校验存量数据可选。更狠的是Schema Evolution当新数据含新增字段时Delta 可自动扩展表结构需显式开启但旧数据对应字段为NULL——这比 Hive 的ADD COLUMNS仅修改元数据不校验数据安全十倍。第三宗罪查询不可逆No Time Machine某次UPDATE语句少写了WHERE条件全表statusarchived变成statusdeleted。Hive 下只能靠 S3 版本控制或冷备恢复耗时 6 小时。Delta 的TIME TRAVEL让你用SELECT * FROM table_name VERSION AS OF 123或SELECT * FROM table_name TIMESTAMP AS OF 2024-05-01T12:00:00Z一秒钟回到事故前。其原理是事务日志里每条记录都包含version递增整数和timestamp而 Delta 的读取引擎会根据指定版本精准加载该版本对应的全部数据文件列表通过_delta_log/00000000000000000123.json中的add和remove字段解析。提示Delta 的事务日志不是数据库的 WALWrite-Ahead Log它不记录数据变更内容只记录“哪些文件被添加/删除/更新”。因此日志体积极小通常 KB 级且可被任意计算引擎读取——这是它实现跨引擎互操作Spark/Flink/Presto的基础。2.2 Delta Lake 的三层物理结构从文件系统视角看“表即目录”Delta 表在文件系统中就是一个普通目录但内部结构高度标准化。以路径s3://my-bucket/delta/customers/为例其典型布局如下customers/ ├── _delta_log/ # 事务日志核心目录 │ ├── 00000000000000000000.json # 初始创建日志 │ ├── 00000000000000000001.json # 第一次写入日志 │ ├── 00000000000000000002.json # UPDATE 操作日志 │ └── ... ├── part-00000-1a2b3c4d-...-c000.snappy.parquet # 数据文件Parquet 格式 ├── part-00001-5e6f7g8h-...-c000.snappy.parquet └── _SUCCESS # 兼容 Hive 的标记文件非必需_delta_log/目录Delta 的“大脑”。每个 JSON 文件命名规则为00000000000000000000.json128 位零填充数字代表事务版本号。文件内容是 JSON 数组每项是一个操作事件。例如00000000000000000001.json可能包含[ { protocol: {minReaderVersion: 1, minWriterVersion: 2}, metaData: { id: a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8, format: {provider: parquet, options: {}}, schemaString: {\type\:\struct\,\fields\:[{\name\:\id\,\type\:\long\,\nullable\:false,\metadata\:{}},{\name\:\name\,\type\:\string\,\nullable\:true,\metadata\:{}}]}, partitionColumns: [region], configuration: {} } }, { add: { path: part-00000-1a2b3c4d-...-c000.snappy.parquet, partitionValues: {region: US}, size: 123456, modificationTime: 1714567890000, dataChange: true, stats: {\numRecords\:1000,\minValues\:{\id\:1},\maxValues\:{\id\:1000}} } } ]这里protocol定义兼容性协议metaData是表元数据含 Schemaadd事件声明新增了一个数据文件。关键洞察Delta 表的“存在”不依赖 Hive Metastore只依赖_delta_log/目录的存在和完整性。这意味着你可以用CREATE TABLE ... LOCATION s3://...直接挂载任意 Delta 目录为表无需注册元数据。数据文件纯 Parquet 文件无任何 Delta 特有格式。这意味着任何支持 Parquet 的引擎Presto、Trino、Redshift Spectrum都能直接读取这些文件只是无法享受 ACID 和 Time Travel——它们绕过了 Delta 的读取引擎。这也是 Delta “兼容性优先”设计的体现不绑架数据格式只增强访问协议。_SUCCESS文件纯粹为了向后兼容 Hive 生态Databricks 本身并不依赖它。当你用spark.read.format(delta).load(...)时Delta 引擎完全忽略此文件只解析_delta_log/。2.3 与 Iceberg、Hudi 的本质差异Delta 的“务实主义”选择常有人问“Delta、Iceberg、Hudi 该怎么选” 我的答案很直接看你的技术栈重心和治理成熟度。三者都解决 ACID 问题但哲学不同维度Delta Lake (Databricks)Apache IcebergApache Hudi核心驱动力商业产品倒逼开源标准Databricks 主导社区驱动强调开放标准Netflix/Airbnb社区驱动聚焦实时增量Uber事务日志JSON 文件序列人类可读易调试Avro Metadata 文件二进制高效Timeline .hoodie 目录混合Schema 演化强制 Enforce 自动 Evolution需配置强 Schema Evolution默认支持支持但需谨慎处理兼容性实时能力依赖 Streaming Delta Sink微批原生支持 Flink Streaming 写入原生支持 Spark Structured Streaming云原生优化深度集成 Databricks Runtime如 Photon通用性强各云厂商适配中通用性强但部分云服务支持弱我的实操经验是如果团队主力是 Spark Databricks且需要快速落地数据治理如 GDPR 删除、合规审计Delta 是最短路径。我们曾用 Delta 的DELETE FROM customers WHERE user_id IN (...)一条命令在 2TB 表上完成 GDPR “被遗忘权”请求耗时 8 分钟含 OPTIMIZE而同等 Hive 表需重跑全量 ETL。Iceberg 在 Presto/Trino 场景下性能更优但初期学习成本高Hudi 对 Kafka 实时链路友好但运维复杂度陡增。没有银弹只有适配。Delta 的优势在于它把复杂性封装在 Databricks UI 和 SQL 语法里让你用VACUUM customers RETAIN 168 HOURS这样的命令就完成了底层小文件合并与过期日志清理——这种“魔法感”正是它被广泛采用的原因。3. 核心功能实操详解从建表到生产治理的完整闭环3.1 创建 Delta 表三种方式的本质区别与选型建议Delta 表创建绝非简单CREATE TABLE方式选择直接影响后续治理成本。我总结出三条黄金路径方式一SQL DDL 创建推荐新手 标准化场景CREATE TABLE IF NOT EXISTS sales_orders ( order_id STRING, customer_id STRING, order_date DATE, amount DECIMAL(10,2) ) USING DELTA PARTITIONED BY (order_date) LOCATION s3://my-bucket/delta/sales_orders/;优势语法简洁自动注册到 Hive Metastore支持DESCRIBE TABLE查看元数据权限管理GRANT/REVOKE无缝对接。注意点LOCATION必须是绝对路径且该路径不能已存在否则报错。若需复用现有数据必须用方式二。我的经验在数据平台初始化阶段所有核心维度表、事实表均用此方式创建确保元数据统一纳管。Databricks SQL Editor 里右键表名即可生成此 DDL效率极高。方式二从现有数据转换推荐迁移场景# 读取现有 Parquet/Hive 表 df spark.read.format(parquet).load(s3://old-bucket/parquet/sales/) # 转换为 Delta 并保存 df.write.format(delta).mode(overwrite).save(s3://my-bucket/delta/sales_orders/) # 可选注册为托管表 spark.sql(CREATE TABLE sales_orders USING DELTA LOCATION s3://my-bucket/delta/sales_orders/)关键动作mode(overwrite)会清空目标目录并写入新 Delta 文件同时生成初始_delta_log/00000000000000000000.json。避坑提示切勿在已有 Delta 表上用mode(overwrite)这会破坏事务日志连续性。正确做法是MERGE INTO或DELETE/INSERT。我们曾因误用 overwrite 导致一张表DESCRIBE HISTORY显示版本从 0 跳到 1丢失所有历史变更记录只能从备份恢复。方式三直接路径挂载推荐探索性分析 跨引擎协作-- 不注册元数据直接查询 SELECT * FROM delta.s3://my-bucket/delta/sales_orders/; -- 或创建临时视图 CREATE TEMP VIEW sales_temp AS SELECT * FROM delta.s3://my-bucket/delta/sales_orders/;适用场景数据科学家临时分析某份原始数据或 Presto 用户需要读取 Delta 表Presto 7.0 支持deltaconnector。限制无法使用VACUUM、OPTIMIZE、RESTORE等 Delta 特有命令且不参与 Databricks 权限体系。我的心得在数据质量探查阶段我习惯先用此方式快速预览数据分布确认无敏感字段后再走正式建表流程。既避免污染生产元数据又保留 Delta 的读取能力。注意无论哪种方式首次写入后务必立即执行DESCRIBE DETAIL table_name。它会返回表的关键信息{ format: delta, location: s3://my-bucket/delta/sales_orders/, sizeInBytes: 123456789, numFiles: 42, partitionColumns: [order_date], createdAt: 2024-05-01T10:20:30Z, lastModified: 2024-05-01T10:20:30Z, properties: {delta.minReaderVersion: 1, delta.minWriterVersion: 2} }这里的properties是治理基线——minWriterVersion若为 2说明支持UPDATE/DELETE若为 1则只能INSERT。升级版本需ALTER TABLE ... SET TBLPROPERTIES (delta.minWriterVersion 2)。3.2 数据写入与更新ACID 事务的实操边界与性能调优Delta 的 ACID 保证并非免费午餐其性能与正确性高度依赖写入模式。以下是我在高频交易日志场景中验证过的最佳实践INSERT最安全但需防小文件# 危险单条记录写入产生海量小文件 for record in records: spark.createDataFrame([record]).write.format(delta).mode(append).save(...) # 正确批量写入控制文件大小 df.coalesce(10).write.format(delta).mode(append).save(s3://...)原理coalesce(10)将 DataFrame 重分区为 10 个分区每个分区写入一个 Parquet 文件。Delta 默认目标文件大小为 128MB但小文件病仍常见。我的参数公式目标文件数 总数据量GB× 1024 / 128 ≈ 总数据量 × 8。例如 50GB 数据设repartition(400)比coalesce(400)更均匀repartition触发 shufflecoalesce不触发。UPDATE/DELETE强一致性保障但代价明确-- 高频场景慎用会重写整个分区 UPDATE customers SET status inactive WHERE last_login 2023-01-01; -- 更优用 MERGE 实现 Upsert MERGE INTO customers t USING updates s ON t.customer_id s.customer_id WHEN MATCHED THEN UPDATE SET t.status s.status, t.updated_at current_timestamp() WHEN NOT MATCHED THEN INSERT *;性能真相UPDATE语句会扫描匹配分区的所有文件将满足条件的行标记为remove再将新数据写入新文件。这意味着 10GB 分区中更新 100 行也会重写整个 10GB。而MERGE可复用未变更的数据文件仅重写变更部分。实测数据在 1TB 用户表上MERGE比UPDATE快 3.2 倍且产生的垃圾文件少 87%。并发写入锁机制与冲突规避Delta 使用乐观并发控制Optimistic Concurrency Control。当两个作业同时尝试提交版本 N1 时后提交者会收到ConcurrentModificationException。解决方案重试机制在应用层捕获异常等待 1-3 秒后重试Databricks Runtime 自动重试 3 次。降低写入频率将微批1分钟改为小批5分钟减少冲突概率。分区隔离确保并发作业写入不同分区如按hour分区作业 A 写hour10作业 B 写hour11。提示DESCRIBE HISTORY table_name是诊断并发问题的利器。若看到连续多个版本的operation为WRITE且operationParameters中mode为Overwrite说明存在高频覆盖写入需重构逻辑。3.3 Time Travel 与数据回滚从“后悔药”到“合规武器”Time Travel 是 Delta 最具颠覆性的能力但很多人只用它救火没把它变成日常治理工具。以下是三个真实场景的深度用法场景一秒级故障恢复救火-- 查看历史版本 DESCRIBE HISTORY sales_orders; -- 回滚到上一版本假设 version42 是正常状态 RESTORE TABLE sales_orders TO VERSION AS OF 42; -- 或按时间戳回滚 RESTORE TABLE sales_orders TO TIMESTAMP AS OF 2024-05-01T12:00:00Z;关键细节RESTORE是原子操作会生成新版本如 43原版本42依然可用。VACUUM不会删除已回滚的版本除非显式指定RETAIN时间。场景二数据质量对比探查-- 比较版本 40 和 42 的订单量差异 SELECT v40 as version, COUNT(*) as cnt FROM sales_orders VERSION AS OF 40 UNION ALL SELECT v42 as version, COUNT(*) as cnt FROM sales_orders VERSION AS OF 42;价值当新版本数据量突降 30%可快速定位是 ETL 逻辑错误还是上游断流无需翻日志。场景三GDPR 合规删除治理-- 删除特定用户的所有记录物理删除 DELETE FROM sales_orders WHERE customer_id user_123; -- 立即 VACUUM 清理否则文件仍存在 VACUUM sales_orders RETAIN 0 HOURS; -- 验证检查版本 43 的 stats 是否包含该用户 SELECT * FROM sales_orders VERSION AS OF 43 WHERE customer_id user_123;合规要点VACUUM是物理删除必须配合RETAIN 0 HOURS或更短才能确保数据不可恢复。Databricks 默认RETAIN 7 DAYS这是安全底线切勿关闭。3.4 OPTIMIZE 与 VACUUM对抗对象存储熵增的系统工程小文件和过期日志是数据湖的“熵增定律”Delta 提供了两把手术刀OPTIMIZE智能小文件合并-- 基础用法合并小文件 OPTIMIZE sales_orders; -- 指定分区避免全表扫描 OPTIMIZE sales_orders WHERE order_date 2024-05-01; -- Z-Ordering大幅提升范围查询性能 OPTIMIZE sales_orders ZORDER BY (customer_id, order_date);Z-Ordering 原理将多维数据如customer_id,order_date编码为一维空间曲线Z-order curve使相关数据在物理文件中聚集。实测在WHERE customer_id IN (...) AND order_date BETWEEN ...查询中性能提升 5-8 倍。但代价是 OPTIMIZE 时间增加 30%-50%需权衡。VACUUM安全清理的精确制导-- 查看哪些文件可被清理 VACUUM sales_orders DRY RUN; -- 清理 7 天前的文件默认 VACUUM sales_orders; -- 清理 1 小时前的文件激进仅用于测试 VACUUM sales_orders RETAIN 1 HOURS;生死线RETAIN参数定义了“可被删除的文件最后修改时间”。VACUUM不会删除当前版本及RETAIN时间内被引用的文件。例如RETAIN 168 HOURS7天则版本 42时间戳为 5天前的文件仍受保护。我的铁律生产环境RETAIN不得小于 168 HOURS且必须与DESCRIBE HISTORY中的timestamp对齐。注意OPTIMIZE和VACUUM都是写入操作会生成新版本。执行前务必确认无长时查询正在读取该表否则可能因版本切换导致查询失败。4. 生产环境避坑指南那些文档不会写的血泪教训4.1 常见故障速查表与根因分析现象可能原因排查命令解决方案org.apache.spark.sql.delta.DeltaInvariantViolationException: Check constraint violated写入数据违反CHECK约束如amount 0DESCRIBE DETAIL table查看constraints修复数据或ALTER TABLE DROP CONSTRAINTjava.lang.IllegalArgumentException: Path does not exist: s3://.../_delta_log/表目录被手动删除或_delta_log损坏ls s3://.../_delta_log/从备份恢复_delta_log/或重建表ConcurrentModificationException频发高频并发写入同一分区DESCRIBE HISTORY table查看operation频率改用MERGE、增加分区粒度、引入写入队列VACUUM后TIME TRAVEL失效RETAIN时间设置过短删除了历史版本文件VACUUM table DRY RUN重新设置RETAIN从 S3 版本控制恢复文件查询性能骤降未OPTIMIZE导致小文件过多或缺失ZORDERDESCRIBE DETAIL table查看numFiles执行OPTIMIZE对高频查询字段ZORDER4.2 我踩过的五个深坑与独家解决方案坑一OVERWRITE模式摧毁事务连续性早期我习惯用df.write.mode(overwrite).format(delta).save(...)更新维度表结果某天发现DESCRIBE HISTORY版本号从 100 跳到 1所有中间版本消失。根因overwrite会删除整个目录并重建相当于新建表。解决方案强制团队使用MERGE INTO或INSERT OVERWRITEDelta 2.0 支持并在 CI/CD 流水线中加入 DDL 检查脚本拦截mode(overwrite)。坑二VACUUM误删导致合规事故为节省成本将VACUUMRETAIN设为 24 小时。某次 GDPR 请求需回溯 36 小时数据VACUUM已清理文件无法RESTORE。教训RETAIN必须大于等于最大合规保留期。我们现行政策RETAIN 168 HOURS7天为底线金融类表RETAIN 365 DAYS。坑三ZORDER过度使用拖垮集群为追求极致性能对 10 个字段做ZORDEROPTIMIZE耗时从 2 分钟飙升至 47 分钟占满集群资源。数据验证ZORDER对 2-3 个高频过滤字段收益最大超过 4 个收益递减且成本剧增。现在策略仅对WHERE子句中AND连接的前 3 个字段ZORDER。坑四跨区域同步忽略RETAIN配置用 AWS S3 Replication 同步 Delta 表到另一区域但未同步_delta_log/中的checkpoint文件导致目标端DESCRIBE HISTORY只显示最近 10 个版本。解决方案Replication 规则必须包含_delta_log/*.json和_delta_log/_*checkpoint 文件且目标端需执行FSCK REPAIR TABLE重建日志索引。坑五权限继承导致VACUUM失败表路径s3://bucket/delta/tbl/的 S3 权限为bucket-owner-full-control但_delta_log/目录权限为privateVACUUM时因无DELETE权限报错。排查技巧在 Databricks Notebook 中运行dbutils.fs.ls(s3://bucket/delta/tbl/_delta_log/)观察是否报AccessDenied。修复统一 S3 目录 ACL或使用 IAM Role 精确授权s3:DeleteObject到_delta_log/前缀。4.3 生产环境黄金配置清单附参数依据以下是我团队在 3 个 PB 级数据湖中验证的最小可行配置已沉淀为 Terraform 模块配置项推荐值依据与说明delta.autoOptimize.optimizeWrite.enabledtrue自动合并小文件减少OPTIMIZE手动干预。实测写入吞吐提升 40%文件数减少 60%。delta.autoOptimize.autoCompact.enabledtrue自动执行COMPACT轻量级OPTIMIZE适用于流式写入场景。delta.deletedFileRetentionDurationinterval 7 days控制VACUUM默认保留期与RETAIN保持一致避免意外删除。delta.logRetentionDurationinterval 30 days事务日志保留期确保TIME TRAVEL可用。金融行业建议interval 365 days。delta.checkpointInterval10每 10 个版本生成一个 checkpoint 文件_delta_log/00000000000000000010.checkpoint.parquet加速DESCRIBE HISTORY加载。提示这些配置可通过SET命令在会话级生效或在表级别ALTER TABLE ... SET TBLPROPERTIES持久化。强烈建议在建表时统一设置避免后期补救。5. 进阶场景与未来演进从 Lakehouse 到 Unified Governance5.1 Delta Sharing打破数据孤岛的零信任协议当你的数据需要共享给外部合作伙伴如供应商、监管机构传统方式是导出 CSV 或开通数据库账号安全与审计形同虚设。Delta Sharing 提供了一种革命性方案不移动数据只分享访问凭证不暴露原始文件只提供时间点快照。核心机制创建一个Share逻辑容器添加 Delta 表创建Recipient外部账户授予SELECT权限Recipient 用databricks-connect或开源delta-sharing-spark连接获取一个Share URL查询时Delta Sharing Server 动态生成该时刻的只读快照所有访问经由统一网关审计。我的实战案例为满足央行《金融数据安全分级指南》我们将客户脱敏表customers_anonymized通过 Delta Sharing 推送至监管沙箱。监管方无需任何基础设施只需一个 Spark 会话执行df spark.read.format(deltaSharing).load(https://sharing-server.com/share/cb-share) df.filter(report_date 2024-05-01).show()所有查询被记录在 Databricks Audit Log 中包含recipient_id、query_text、data_volume完美满足“谁、何时、查了什么、多少数据”的审计要求。5.2 Unity Catalog 集成从表级治理到全域资产地图Unity Catalog 是 Databricks 的统一元数据与权限中心它将 Delta 表的治理提升到组织级三级命名空间catalog.schema.table替代 Hive 的database.table细粒度权限SELECT、MODIFY、OWN可授予用户、组、服务主体数据血缘自动捕获CREATE TABLE AS SELECT、MERGE INTO的上下游关系行级/列级安全RLS/CLS基于用户属性动态过滤行或屏蔽列。部署要点启用 Unity Catalog 后所有 Delta 表必须在catalog.schema下创建LOCATION路径需指向 UC 管理的存储凭证Storage Credential。我们迁移时发现的最大挑战是旧有s3://路径需映射为 UC 的external_location且VACUUM等命令需在 UC 上下文中执行。经验分阶段迁移先将新表纳入 UC旧表逐步替换避免一刀切。5.3 个人体会Delta 的终极价值不在技术而在“确定性”写完这篇长文我重新打开那个凌晨三点的故障工单。当时的问题是一个MERGE作业因网络抖动失败重试时DESCRIBE HISTORY显示版本 101 和 102 的operationMetrics中numTargetRowsInserted为 0但numTargetRowsUpdated为 100 万——这说明数据已更新但日志