主题色
250
壁纸模式
壁纸设置
特效设置
固定导航栏
字体选择
文章列表布局
1241 字
3 分钟
墓碑(Tombstone)
2026-05-08

一、为什么需要墓碑#

一个 LSM 树存储引擎,数据按层级写入只追加的 SSTable。你不能原地修改或删除一条记录,SSTable 是不可变的。那删除一条数据怎么办?直接跳过不管的话,读取时还会找到旧值。

分布式数据库中,删除操作需要传播到所有副本。如果节点 A 删除了一条数据,节点 B 还没收到删除消息,B 仍然会返回旧值。更危险的是:如果节点 C 宕机期间发生了删除,C 恢复后可能把旧数据当作 “新发现” 重新传播到整个集群,已删除的数据死而复生。

墓碑给出的方案是:不直接删除数据,而是写入一条特殊的 “墓碑” 记录来覆盖原始数据。读取时检查墓碑标记,将已标记的条目视为已删除。后台压缩进程随后物理删除墓碑和被覆盖的数据,真正回收空间。

二、现实类比#

图书馆里贴了「已下架」贴纸但还在书架上的书。读者看到就知道不能借了,图书管理员在月度整理时统一收走下架的书。不需要每本书一决定下架就立刻跑去书架抽出来,标记和清理是两步独立的操作。

三、核心思想#

墓碑将删除操作分为快速路径(标记删除)和慢速路径(回收空间),两者解耦。写入墓碑是 O(1) 的追加操作,不影响写入性能。空间回收由后台压缩进程异步完成。

flowchart TD subgraph 写入路径 D["delete('B')"] --> L["追加墓碑标记"] end subgraph 读取路径 G["get('B')"] --> Q["查找"] Q --> F["找到: B = tomb"] F --> N["返回 NOT FOUND"] end subgraph 压缩 L --> C["后台压缩"] C --> R["物理删除墓碑 + 原始数据"] end
属性值说明
删除O(1)仅追加墓碑标记
空间回收延迟后台压缩
读开销需检查墓碑每次读取需判断是否被标记
一致性墓碑必须传播到所有副本才能安全移除

墓碑累积问题:如果压缩落后或删除率很高,墓碑会在各层级堆积。读取时必须逐层检查墓碑,范围扫描尤其受影响,1000 万个墓碑意味着扫描要读取 1000 万条记录,逐一评估,最后返回零结果。解决方案包括更积极地触发压缩、使用范围墓碑(RocksDB 的 DeleteRange)或独立索引。

四、变体与对比#

模式与墓碑的关系适用场景
LSM 树LSM 树大量使用墓碑,压缩时清理只追加的存储引擎
MVCCMVCC 用墓碑标记旧版本以供垃圾回收多版本并发控制
空闲链表墓碑清理后,释放的槽位由空闲链表管理固定大小槽位的回收
引用计数引用计数确定何时可以安全回收被墓碑标记的对象共享资源的延迟回收
LRU 缓存分布式 LRU 缓存用墓碑标记已删除的条目分布式缓存一致性

五、多语言实现#

package tombstone
import (
"sync"
"time"
)
// Entry 存储条目,支持墓碑标记
type Entry struct {
Value string
Deleted bool
Timestamp int64
}
// TombstoneStore 带墓碑删除的键值存储
type TombstoneStore struct {
mu sync.RWMutex
store map[string]*Entry
tombstoneCount int
}
func NewTombstoneStore() *TombstoneStore {
return &TombstoneStore{
store: make(map[string]*Entry),
}
}
// Put 写入键值对
func (s *TombstoneStore) Put(key, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.store[key] = &Entry{
Value: value,
Deleted: false,
Timestamp: time.Now().UnixMilli(),
}
}
// Get 读取值,墓碑标记的条目视为不存在
func (s *TombstoneStore) Get(key string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, ok := s.store[key]
if !ok || entry.Deleted {
return "", false
}
return entry.Value, true
}
// Delete 墓碑删除:标记而非物理删除
func (s *TombstoneStore) Delete(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.store[key]
if !ok || entry.Deleted {
return false
}
entry.Deleted = true
entry.Value = "" // 释放值引用
entry.Timestamp = time.Now().UnixMilli()
s.tombstoneCount++
return true
}
// Compact 压缩:物理删除过期的墓碑
func (s *TombstoneStore) Compact(maxAgeMs int64) int {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().UnixMilli() - maxAgeMs
removed := 0
for key, entry := range s.store {
if entry.Deleted && entry.Timestamp < cutoff {
delete(s.store, key)
removed++
s.tombstoneCount--
}
}
return removed
}
// Size 返回活跃条目数量(不含墓碑)
func (s *TombstoneStore) Size() int {
s.mu.RLock()
defer s.mu.RUnlock()
count := 0
for _, entry := range s.store {
if !entry.Deleted {
count++
}
}
return count
}
// PendingTombstones 返回待压缩的墓碑数量
func (s *TombstoneStore) PendingTombstones() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.tombstoneCount
}

六、生产验证#

项目源码位置用途
LevelDBdbformat.h#L39-L43kTypeDeletion(值 0x0)在预写日志和 SSTable 中标记键已删除。压缩期间,当没有更早的快照引用该键时,墓碑被丢弃
Apache CassandraDeletionTime.java#L37-L99DeletionTime 用 markedForDeleteAt 时间戳表示墓碑。墓碑在 gc_grace_seconds(默认 10 天)期间传播到各副本,之后压缩才清除
RocksDBkTypeDeletionkTypeDeletion 和 kTypeSingleDeletion 墓碑,可配置压缩触发器,支持 DeleteRange 范围墓碑

七、小结#

何时使用:

  • LSM 树存储引擎,LevelDB、RocksDB、Cassandra 追加墓碑,压缩负责清理
  • 分布式数据库,墓碑在物理删除前将删除意图传播到所有副本
  • 应用层软删除,标记记录为已删除但保留审计记录,保留期后清除
  • 不可变/只追加日志,无法修改现有条目,删除需要影子记录
  • 并发数据结构,标记节点已删除以避免并发读取期间不安全的指针操作

何时不用:

  • 可原地修改的存储,哈希表、可变数组可以直接删除条目
  • 内存受限系统,墓碑在压缩前占用空间,空间紧张时立即删除更好
  • 无后台处理能力,压缩需要后台线程/进程,如不可用,墓碑会无限积累

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

赞助
墓碑(Tombstone)
https://blog.souloss.cn/posts/programming/memory/memory-tombstone/
作者
Tsukimi
发布于
2026-05-08
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时