Go-Zero项目开发5: 数据库与缓存一致性问题及 go-zero 缓存机制解析

纲要

  • 一致性级别概述
    • 强一致性:要求各节点数据实时统一,常见于金融场景
    • 弱一致性:允许短暂不一致,适合社交、内容平台
    • 最终一致性:数据最终收敛到一致,常用于数据同步、日志统计
  • 弱一致性下的缓存更新策略
    • 更新缓存 vs 删除缓存的取舍
    • 先删除缓存再更新数据库的风险
    • 延迟双删方案及其局限性
    • 先更新数据库再删除缓存的优势与极端问题
  • 最终一致性的工程实践
    • 基于消息队列异步删除缓存
    • 基于数据库增量日志(如 canal)监听实现零侵入
  • 强一致性的保障手段
    • 串行化请求(如 Kafka 顺序消息)
    • 分布式锁(读/写共用锁)
    • 开关控制(类似分布式锁的变体)
  • go-zero 缓存机制源码分析
    • 模型层缓存配置与对象创建
    • 读操作流程:FindOne 如何利用缓存
    • 写操作流程:Update 如何先写库再删缓存
    • 内部分析:CacheNodeTakeDelCache
  • 与项目结合:用户服务的缓存实践

引言

在前面的文章中,我们完成了用户服务的注册、登录、搜索等功能,并通过 goctl model 生成了数据模型。生成代码会默认引入 go-zero 的缓存层,以 redis 作为媒介缓存数据库查询结果。这自然引出一个关键问题:缓存与数据库之间如何维持一致性? 本文将从理论到实践,分析常见的一致性方案,并结合 go-zero@latest 源码探究其内部缓存机制的设计思想。

一致性级别概述

分布式中,缓存与数据库的一致性可分为三个级别:

级别特点典型场景
强一致性任意时刻所有节点看到的数据完全相同金融交易、在线支付
弱一致性允许短暂的不一致,不同线程可能看到新旧数据社交动态、内容展示
最终一致性数据随时间推移最终收敛到一致日志统计、异步同步

互联网业务大多采用弱一致性或最终一致性,权衡性能与数据准确性。

弱一致性下的缓存更新策略

缓存更新核心围绕两个动作:更新缓存删除缓存。两者优劣对比如下:

策略优点缺点
更新缓存命中率高,减少数据库压力并发写入容易造成数据覆盖,复杂度高
删除缓存实现简单,避免并发写冲突可能造成缓存击穿,增大数据库瞬时压力

实际业务中更倾向删除缓存,本文后续策略均基于此。

先删除缓存,再更新数据库

DBCache线程2线程1DBCache线程2线程1开始更新数据库(可能延时/失败)缓存中仍是旧数据,不一致!删除缓存查询缓存(未命中)读取旧数据回写旧数据更新数据库为新值

问题:在线程1更新完成前,线程2可能将旧数据回填至缓存,导致脏数据长期存在。

改进方案:延迟双删
线程1在更新数据库后,休眠一段时间,再执行一次缓存删除。

DBCache线程2线程1DBCache线程2线程1休眠 N 毫秒保证后续请求读取新数据删除缓存更新数据库再次删除缓存

该方案仍可能因第二次删除失败而残留旧数据,通常配合重试机制,此时已偏向最终一致性

先更新数据库,再删除缓存

CacheDB线程2线程1CacheDB线程2线程1理想情况:线程2在删除后访问,未命中,读新值更新数据库删除缓存

优势在于更新数据库和删除缓存是一个原子任务,若删除失败可内部重试,不会出现线程2回填旧数据的问题。但存在极端情况:

  • 线程2 在缓存未命中时读取数据库(此时线程1 尚未更新数据库),读到了旧值。
  • 线程1 更新数据库并删除缓存,但删除缓存发生在线程2 回写缓存之前
  • 线程2 将旧值回写到缓存,导致缓存为旧数据。
DBCache线程2线程1DBCache线程2线程1缓存又被重置为旧值查询缓存(未命中)读取旧数据更新数据库删除缓存回写旧数据

该场景严格条件下才会出现,但概率较低。实践中多采用先更新数据库再删除缓存,配合重试或消息队列。

最终一致性的工程实践

基于消息队列

缓存异步Worker消息队列数据库请求A(写)缓存异步Worker消息队列数据库请求A(写)更新数据投递缓存删除任务消费任务删除缓存

优点:解耦写入与缓存操作,失败可重复消费。缺点:存在短暂延迟,代码有侵入(需主动发送 MQ 消息)。

基于数据库增量日志

利用 Canal 等工具监听 MySQL binlog,数据变更后自动触发缓存更新/删除,实现零业务侵入

更新/插入

binlog

解析事件

修改缓存

应用服务

MySQL

Canal 监听

缓存更新Worker

Redis

该方式解耦彻底,扩展性强,但需额外维护中间件。

强一致性的保障手段

串行化

将并行的读写请求通过消息队列(如 Kafka 分区顺序)串行执行,保证同一数据的操作有序。

分布式锁

缓存数据库分布式锁(Redis/ZK)业务进程缓存数据库分布式锁(Redis/ZK)业务进程alt[获取成功][获取超时/失败]获取锁更新数据删除缓存释放锁快速失败或自旋重试

读写操作均需获取同一把锁,保证数据强一致,但吞吐量下降明显。

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 方法,其内部逻辑:

  1. 从缓存读取数据。
  2. 如果命中,直接返回结果。
  3. 如果 miss,调用传入的 queryFn 从数据库加载数据。
  4. 将数据库结果写入缓存,并设置过期时间。
  5. 返回数据。

这符合“读时回填”的策略,避免了启动时全量缓存预热。

写操作:UpdateDelete 流程

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 还是集群模式,并提供统一的 TakeSetDel 接口。读操作为防缓存击穿,可能会添加短暂的占位符(null 缓存);写操作失败则直接返回错误,不会删除缓存,保证缓存可用。

与项目结合:用户服务的缓存实践

在我们的即时通讯系统中,用户信息会被频繁查询(验证好友、展示资料等),非常适合缓存。按照 go-zero 的默认配置,只要在 user.yaml 中正确配置了 CacheRedis,模型层就会自动携带缓存逻辑:

CacheRedis:
	Host: 192.168.1.10:6379 
	Pass: "yourpassword"

注册时执行 Insertgo-zero 会直接写入数据库但不操作缓存(因为 Insert 没有对应的缓存 key)。首次 FindOneFindOneByPhone 时,会从数据库加载并写入缓存,后续读取将命中 Redis,大幅降低数据库压力。

更新用户资料时,Update 方法会删除对应 uid 的缓存 key,保证下次读取能拿到最新数据。整个过程对业务代码完全透明,只需专注逻辑实现。

总结

  • 理解了强一致性、弱一致性、最终一致性的适用场景。
  • 分析了删除缓存策略下“先删后更”与“先更后删”的优劣,以及延迟双删、重试机制的作用。
  • 了解了通过消息队列和 binlog 订阅实现最终一致性的工程手段。
  • 深入 go-zero 模型缓存源码,明确了其读写分离的缓存策略:读时填充、写时失效
  • 结合用户服务,验证了这套机制在微服务中的实践价值。

掌握这些理论,可以更自信地在复杂业务中平衡性能与一致性,避免因缓存使用不当引发的数据问题。在后续的社交与 IM 服务中,我们将继续沿用这套缓存体系,并探讨高并发下的更多优化技巧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wang's Blog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值