纲要
- 一致性级别概述
- 强一致性:要求各节点数据实时统一,常见于金融场景
- 弱一致性:允许短暂不一致,适合社交、内容平台
- 最终一致性:数据最终收敛到一致,常用于数据同步、日志统计
- 弱一致性下的缓存更新策略
- 更新缓存 vs 删除缓存的取舍
- 先删除缓存再更新数据库的风险
- 延迟双删方案及其局限性
- 先更新数据库再删除缓存的优势与极端问题
- 最终一致性的工程实践
- 基于消息队列异步删除缓存
- 基于数据库增量日志(如
canal)监听实现零侵入
- 强一致性的保障手段
- 串行化请求(如 Kafka 顺序消息)
- 分布式锁(读/写共用锁)
- 开关控制(类似分布式锁的变体)
go-zero缓存机制源码分析- 模型层缓存配置与对象创建
- 读操作流程:
FindOne如何利用缓存 - 写操作流程:
Update如何先写库再删缓存 - 内部分析:
CacheNode的Take与DelCache
- 与项目结合:用户服务的缓存实践
引言
在前面的文章中,我们完成了用户服务的注册、登录、搜索等功能,并通过 goctl model 生成了数据模型。生成代码会默认引入 go-zero 的缓存层,以 redis 作为媒介缓存数据库查询结果。这自然引出一个关键问题:缓存与数据库之间如何维持一致性? 本文将从理论到实践,分析常见的一致性方案,并结合 go-zero@latest 源码探究其内部缓存机制的设计思想。
一致性级别概述
分布式中,缓存与数据库的一致性可分为三个级别:
| 级别 | 特点 | 典型场景 |
|---|---|---|
| 强一致性 | 任意时刻所有节点看到的数据完全相同 | 金融交易、在线支付 |
| 弱一致性 | 允许短暂的不一致,不同线程可能看到新旧数据 | 社交动态、内容展示 |
| 最终一致性 | 数据随时间推移最终收敛到一致 | 日志统计、异步同步 |
互联网业务大多采用弱一致性或最终一致性,权衡性能与数据准确性。
弱一致性下的缓存更新策略
缓存更新核心围绕两个动作:更新缓存 与 删除缓存。两者优劣对比如下:
| 策略 | 优点 | 缺点 |
|---|---|---|
| 更新缓存 | 命中率高,减少数据库压力 | 并发写入容易造成数据覆盖,复杂度高 |
| 删除缓存 | 实现简单,避免并发写冲突 | 可能造成缓存击穿,增大数据库瞬时压力 |
实际业务中更倾向删除缓存,本文后续策略均基于此。
先删除缓存,再更新数据库
问题:在线程1更新完成前,线程2可能将旧数据回填至缓存,导致脏数据长期存在。
改进方案:延迟双删
线程1在更新数据库后,休眠一段时间,再执行一次缓存删除。
该方案仍可能因第二次删除失败而残留旧数据,通常配合重试机制,此时已偏向最终一致性。
先更新数据库,再删除缓存
优势在于更新数据库和删除缓存是一个原子任务,若删除失败可内部重试,不会出现线程2回填旧数据的问题。但存在极端情况:
- 线程2 在缓存未命中时读取数据库(此时线程1 尚未更新数据库),读到了旧值。
- 线程1 更新数据库并删除缓存,但删除缓存发生在线程2 回写缓存之前。
- 线程2 将旧值回写到缓存,导致缓存为旧数据。
该场景严格条件下才会出现,但概率较低。实践中多采用先更新数据库再删除缓存,配合重试或消息队列。
最终一致性的工程实践
基于消息队列
优点:解耦写入与缓存操作,失败可重复消费。缺点:存在短暂延迟,代码有侵入(需主动发送 MQ 消息)。
基于数据库增量日志
利用 Canal 等工具监听 MySQL binlog,数据变更后自动触发缓存更新/删除,实现零业务侵入。
该方式解耦彻底,扩展性强,但需额外维护中间件。
强一致性的保障手段
串行化
将并行的读写请求通过消息队列(如 Kafka 分区顺序)串行执行,保证同一数据的操作有序。
分布式锁
读写操作均需获取同一把锁,保证数据强一致,但吞吐量下降明显。
go-zero 缓存机制源码分析
回到我们项目中通过 goctl model 生成的数据模型,它内置了缓存能力。在 common/model/usermodel.go 中可以看到类似代码:
var (
cacheUserPrefix = "cache:user:"
)
type (
defaultUserModel struct {
table string
conn sqlx.SqlConn
cache cache.CacheNode
}
UserModel interface {
Insert(ctx context.Context, data *User) (sql.Result, error)
FindOne(ctx context.Context, uid string) (*User, error)
Update(ctx context.Context, data *User) error
Delete(ctx context.Context, uid string) error
}
)
func NewUserModel(conn sqlx.SqlConn, c cache.CacheConf) UserModel {
return &defaultUserModel{
table: "`user`",
conn: conn,
cache: cache.NewNode(conn, c, nil, nil),
}
}
这里的 cache.CacheNode 就是 go-zero 提供的缓存抽象,内部封装了 Redis 操作和数据库读取。
读操作:FindOne 流程
生成代码中 FindOne 的实现大致如下:
func (m *defaultUserModel) FindOne(ctx context.Context, uid string) (*User, error) {
userKey := fmt.Sprintf("%s%v", cacheUserPrefix, uid)
var resp User
err := m.cache.Take(ctx, &resp, userKey, func(conn sqlx.SqlConn, v interface{}) error {
query := fmt.Sprintf("select %s from %s where uid = ? limit 1", userRows, m.table)
return conn.QueryRowCtx(ctx, v, query, uid)
})
switch err {
case nil:
return &resp, nil
case sqlx.ErrNotFound:
return nil, ErrNotFound
default:
return nil, err
}
}
核心在于 cache.Take 方法,其内部逻辑:
- 从缓存读取数据。
- 如果命中,直接返回结果。
- 如果 miss,调用传入的
queryFn从数据库加载数据。 - 将数据库结果写入缓存,并设置过期时间。
- 返回数据。
这符合“读时回填”的策略,避免了启动时全量缓存预热。
写操作:Update 与 Delete 流程
go-zero 的更新和删除操作采用先更新数据库,再删除缓存的策略。以更新为例:
func (m *defaultUserModel) Update(ctx context.Context, data *User) error {
userKey := fmt.Sprintf("%s%v", cacheUserPrefix, data.Uid)
_, err := m.cache.Exec(ctx, func(conn sqlx.SqlConn) (result sql.Result, err error) {
query := fmt.Sprintf("update %s set ... where uid = ?", m.table)
return conn.ExecCtx(ctx, query, data.Uid)
}, userKey)
return err
}
Exec 方法内部首先执行数据库操作,当数据库操作成功时,调用 DelCache 删除缓存。
这种设计与我们前面分析的最佳实践一致:写操作不更新缓存,仅使其失效,下次读取时再通过 Take 自动加载新数据。
内部实现细节
CacheNode 会根据传入的 CacheConf 决定使用单节点 Redis 还是集群模式,并提供统一的 Take、Set、Del 接口。读操作为防缓存击穿,可能会添加短暂的占位符(null 缓存);写操作失败则直接返回错误,不会删除缓存,保证缓存可用。
与项目结合:用户服务的缓存实践
在我们的即时通讯系统中,用户信息会被频繁查询(验证好友、展示资料等),非常适合缓存。按照 go-zero 的默认配置,只要在 user.yaml 中正确配置了 CacheRedis,模型层就会自动携带缓存逻辑:
CacheRedis:
Host: 192.168.1.10:6379
Pass: "yourpassword"
注册时执行 Insert,go-zero 会直接写入数据库但不操作缓存(因为 Insert 没有对应的缓存 key)。首次 FindOne 或 FindOneByPhone 时,会从数据库加载并写入缓存,后续读取将命中 Redis,大幅降低数据库压力。
更新用户资料时,Update 方法会删除对应 uid 的缓存 key,保证下次读取能拿到最新数据。整个过程对业务代码完全透明,只需专注逻辑实现。
总结
- 理解了强一致性、弱一致性、最终一致性的适用场景。
- 分析了删除缓存策略下“先删后更”与“先更后删”的优劣,以及延迟双删、重试机制的作用。
- 了解了通过消息队列和
binlog订阅实现最终一致性的工程手段。 - 深入
go-zero模型缓存源码,明确了其读写分离的缓存策略:读时填充、写时失效。 - 结合用户服务,验证了这套机制在微服务中的实践价值。
掌握这些理论,可以更自信地在复杂业务中平衡性能与一致性,避免因缓存使用不当引发的数据问题。在后续的社交与 IM 服务中,我们将继续沿用这套缓存体系,并探讨高并发下的更多优化技巧。

251

被折叠的 条评论
为什么被折叠?



