变更跟踪
这一页解释 SqlSharp 最关键的运行时能力:为什么改一个属性就能只更新一列。
适用场景
- 想理解
saveChanges()到底做了什么 - 想知道哪些对象会被跟踪,哪些不会
- 需要处理跨上下文更新、并发更新、只读查询
核心问题
SqlSharp 的 ChangeTracker 做三件事:
- 实体进入上下文时拍快照
- 保存时对比快照和当前值
- 只为真正变化的字段生成 SQL
这意味着你不需要手写 UPDATE name, price WHERE id = ...——改哪个属性,框架就只更新哪一列。
快速开始
最短路径:add → 改属性 → saveChanges:
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() 背后发生了什么:
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():
state getter
│
├─ 状态不是 Unchanged/Modified? → 直接返回当前状态
├─ 实体未持久化(主键无效)? → 直接返回
│
└─ 当前 Unchanged → 逐属性对比,任一属性变了就切换到 Modified
当前 Modified → 逐属性对比,全部回到原值就切换到 Unchanged你改属性后不需要手动通知 SqlSharp —— 下次访问 state 或调用 saveChanges() 时,变更会自动被发现。
因此改回原值也不会生成多余的 UPDATE:
p.price = 200 // Unchanged → Modified
p.price = 100 // 改回原值 → detectChanges() 发现全部匹配 → 切回 Unchanged
ctx.saveChanges() // 不会生成 UPDATE内部实现上,ChangeTracker 维护了一个 HashMap<IEntity, EntityEntry>,每个 EntityEntry 持有原始值副本(_originalValues)。detectChanges() 就是逐个属性对比 _originalValues 和当前值。
三句话总结:
EntityEntry:内存里的跟踪记录,持有原始值快照和当前状态detectChanges():对比快照和当前值,自动判定Unchanged/ModifiedsaveChanges():按状态翻译成 INSERT / UPDATE / DELETE
常见用法
显式 update()
跨 DbContext 场景里,如果你手上只有主键和新值,也可以直接更新:
let detached = Product(id: knownId, name: "new", price: 999)
ctx.update(detached) // 状态变为 Modified,所有非主键列标记为已修改
ctx.saveChanges() // 生成完整 UPDATE(所有非主键列)这种方式会更新所有非主键列,因为没有原始值快照可以对比。适合明确知道要全量覆盖的场景。
entry() 和状态控制
add()、update()、remove() 本质都是语法糖——内部通过 ctx.entry(entity).state 设置状态:
// 以下两组写法完全等价
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也可以直接读写状态:
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:
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 中移除:
ctx.detach(product) // 等价于 ctx.entry(product).state = EntityState.Detached
// 后续对 product 的修改不会再被 saveChanges() 感知asNoTracking()
只读查询不需要进入 ChangeTracker,可以显式关闭跟踪:
let list = ctx.products
.asNoTracking()
.whereIf("price > @min")
.params(p)
.toList()
// 结果处于 Detached 状态,不会进入 ChangeTracker并发更新
@ConcurrencyCheck 字段会在更新和删除时带上原始值条件,避免后提交覆盖先提交。
@Entity
@Serialization
public class Product {
private var _id: Int64 = 0
private var _name: String = ""
@ConcurrencyCheck
private var _version: Int64 = 0
}生成的 UPDATE 会变成:
UPDATE product SET name = @name, version = @newVersion
WHERE id = @id AND version = @oldVersion如果保存时影响行数为 0,说明并发冲突,会抛 DbUpdateConcurrencyException。
与其他模块组合
- 并发字段的定义来自 定义实体
- 默认跟踪策略可以在 DbContext 配置 中统一设置
- 读多写少的列表查询,通常和 Queryable 查询 配合使用
asNoTracking()
生产建议
- 写操作优先使用被跟踪的实体,减少手工拼更新逻辑
- 跨上下文
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 中被跟踪。