Skip to content

变更跟踪

这一页解释 SqlSharp 最关键的运行时能力:为什么改一个属性就能只更新一列。

适用场景

  • 想理解 saveChanges() 到底做了什么
  • 想知道哪些对象会被跟踪,哪些不会
  • 需要处理跨上下文更新、并发更新、只读查询

核心问题

SqlSharp 的 ChangeTracker 做三件事:

  1. 实体进入上下文时拍快照
  2. 保存时对比快照和当前值
  3. 只为真正变化的字段生成 SQL

这意味着你不需要手写 UPDATE name, price WHERE id = ...——改哪个属性,框架就只更新哪一列。

快速开始

最短路径:add → 改属性 → saveChanges:

cangjie
try (ctx = AppDbContext()) {
    let p = Product(name: "A", price: 100)
    ctx.add(p)            // 状态变为 Added,进入 ChangeTracker
    ctx.saveChanges()     // 生成 INSERT,状态变为 Unchanged

    p.price = 200         // 状态自动变为 Modified
    ctx.saveChanges()     // 只生成 UPDATE price 这一列

    ctx.remove(p)         // 状态变为 Deleted
    ctx.saveChanges()     // 生成 DELETE
}

每次 saveChanges() 背后发生了什么:

text
saveChanges()

    ├─ 遍历 ChangeTracker 中所有 EntityEntry

    ├─ Added    → 生成 INSERT
    ├─ Modified → 对比原始值,只生成变化列的 UPDATE
    ├─ Deleted  → 生成 DELETE
    └─ Unchanged/Detached → 跳过

核心概念

实体状态

状态含义saveChanges() 行为
Detached未跟踪跳过
Added新增对象生成 INSERT
Unchanged已跟踪但未变化跳过
Modified属性发生变化生成 UPDATE(仅变化列)
Deleted已标记删除生成 DELETE

detectChanges() 的工作原理

EntityEntry.state 的 getter 会主动调用 detectChanges()

text
state getter

    ├─ 状态不是 Unchanged/Modified? → 直接返回当前状态
    ├─ 实体未持久化(主键无效)? → 直接返回

    └─ 当前 Unchanged → 逐属性对比,任一属性变了就切换到 Modified
       当前 Modified  → 逐属性对比,全部回到原值就切换到 Unchanged

你改属性后不需要手动通知 SqlSharp —— 下次访问 state 或调用 saveChanges() 时,变更会自动被发现。

因此改回原值也不会生成多余的 UPDATE:

cangjie
p.price = 200   // Unchanged → Modified
p.price = 100   // 改回原值 → detectChanges() 发现全部匹配 → 切回 Unchanged
ctx.saveChanges()  // 不会生成 UPDATE

内部实现上,ChangeTracker 维护了一个 HashMap<IEntity, EntityEntry>,每个 EntityEntry 持有原始值副本(_originalValues)。detectChanges() 就是逐个属性对比 _originalValues 和当前值。

三句话总结:

  • EntityEntry:内存里的跟踪记录,持有原始值快照和当前状态
  • detectChanges():对比快照和当前值,自动判定 Unchanged / Modified
  • saveChanges():按状态翻译成 INSERT / UPDATE / DELETE

常见用法

显式 update()

DbContext 场景里,如果你手上只有主键和新值,也可以直接更新:

cangjie
let detached = Product(id: knownId, name: "new", price: 999)
ctx.update(detached)   // 状态变为 Modified,所有非主键列标记为已修改
ctx.saveChanges()      // 生成完整 UPDATE(所有非主键列)

这种方式会更新所有非主键列,因为没有原始值快照可以对比。适合明确知道要全量覆盖的场景。

entry() 和状态控制

add()update()remove() 本质都是语法糖——内部通过 ctx.entry(entity).state 设置状态:

cangjie
// 以下两组写法完全等价
ctx.add(entity)                              // ctx.entry(entity).state = EntityState.Added
ctx.update(entity)                           // ctx.entry(entity).state = EntityState.Modified
ctx.remove(entity)                           // ctx.entry(entity).state = EntityState.Deleted

也可以直接读写状态:

cangjie
let entry = ctx.entry(product)
println(entry.state)                         // 当前状态(getter 会自动触发 detectChanges)
entry.state = EntityState.Modified           // 手动标记为已修改
entry.state = EntityState.Unchanged          // 手动标记为未修改(下次 saveChanges 跳过)
entry.state = EntityState.Detached           // 手动移除跟踪

attach():附加已有实体

跨上下文场景中,如果你有一个已知主键的实体(比如从缓存反序列化得到的),用 attach() 纳入跟踪,状态为 Unchanged

cangjie
let cached = Product(id: 5, name: "OldName", price: 100)
let entry = ctx.attach(cached)               // 状态 = Unchanged,进入 ChangeTracker
cached.price = 150                           // 状态自动变为 Modified
ctx.saveChanges()                            // 只更新 price 列

update() 的区别:attach() 有快照,后续改属性能精确到列;update() 无快照,更新所有非主键列。

detach():移除跟踪

把实体从 ChangeTracker 中移除:

cangjie
ctx.detach(product)                          // 等价于 ctx.entry(product).state = EntityState.Detached
// 后续对 product 的修改不会再被 saveChanges() 感知

asNoTracking()

只读查询不需要进入 ChangeTracker,可以显式关闭跟踪:

cangjie
let list = ctx.products
    .asNoTracking()
    .whereIf("price > @min")
    .params(p)
    .toList()
// 结果处于 Detached 状态,不会进入 ChangeTracker

并发更新

@ConcurrencyCheck 字段会在更新和删除时带上原始值条件,避免后提交覆盖先提交。

cangjie
@Entity
@Serialization
public class Product {
    private var _id: Int64 = 0
    private var _name: String = ""
    @ConcurrencyCheck
    private var _version: Int64 = 0
}

生成的 UPDATE 会变成:

sql
UPDATE product SET name = @name, version = @newVersion
WHERE id = @id AND version = @oldVersion

如果保存时影响行数为 0,说明并发冲突,会抛 DbUpdateConcurrencyException

与其他模块组合

生产建议

  • 写操作优先使用被跟踪的实体,减少手工拼更新逻辑
  • 跨上下文 update() 要谨慎 —— 它不知道哪些字段真正改过,会更新所有非主键列
  • 对关键业务表尽量设计并发检查列
  • 列表查询、报表、导出等只读场景务必加 asNoTracking()

常见问题

改了字段值,saveChanges() 没生成 UPDATE

最常见原因是对象没有被当前 DbContext 跟踪。先 find() 出来再改,或者显式调用 update() / ctx.entry(entity).state = EntityState.Modified

为什么列表查询建议 asNoTracking()

因为它不需要为每条记录建立跟踪状态(拍快照、维护 _originalValues),读取性能和内存占用会更好。

update()add() 后改属性有什么区别?

add() 后实体会进入 ChangeTracker 并拍快照,后续改属性能精确到列。update() 没有原始快照,会更新所有非主键列。如果你需要精确更新,先用 find() 查出实体再改。

同一实体被两个 DbContext 跟踪会怎样?

ChangeTracker.startTracking() 内部通过 refrenceEquals 检查实体引用。同一个实体实例不能被重复跟踪;但不同实例(即使主键相同)可以分别在不同 DbContext 中被跟踪。