4. 缓存读写模式:Cache-Aside、Read-Through、Write-Through、Write-Behind 模式详解与 Go 代码实现
在分布式系统中,缓存与数据库之间的数据一致性是核心挑战。不同的缓存读写模式决定了系统在性能、一致性和可靠性之间的取舍。本章将深入剖析四种主流缓存模式:Cache-Aside、Read-Through、Write-Through 和 Write-Behind,并结合 Go 代码展示其实现细节与适用场景。
4.1 四种模式核心概念对比
| 模式 | 读操作策略 | 写操作策略 | 一致性保证 | 性能特征 | 实现复杂度 |
|---|---|---|---|---|---|
| Cache-Aside | 应用先读缓存,未命中则读 DB 并回填缓存 | 应用直接写 DB,然后删除或更新缓存 | 最终一致性(需处理并发写) | 高读性能,写延迟受 DB 影响 | 低 |
| Read-Through | 缓存层自动回填(应用只与缓存交互) | 同 Cache-Aside 或 Write-Through | 最终一致性 | 读路径简化,缓存层负责回填 | 中 |
| Write-Through | 同 Read-Through | 缓存先写,同步写 DB,成功后返回 | 强一致性(写路径) | 写延迟增加(双写同步) | 中 |
| Write-Behind | 同 Read-Through | 缓存先写,异步批量写 DB | 弱一致性(可能丢失数据) | 极高写吞吐,延迟低 | 高 |
选型建议: 对于大多数业务系统,Cache-Aside 是最稳妥的起点。当读路径需要统一治理时,升级为 Read-Through;当写一致性要求高时,考虑 Write-Through;当写吞吐是瓶颈且能容忍短暂不一致时,才选择 Write-Behind。
4.2 Cache-Aside 模式详解与 Go 实现
Cache-Aside(旁路缓存)是应用最广泛的模式。应用代码显式管理缓存与数据库的交互。
4.2.1 读流程
- 应用查询缓存,若命中则直接返回。
- 若未命中,查询数据库。
- 将查询结果写入缓存,设置过期时间。
- 返回数据。
4.2.2 写流程
- 应用更新数据库。
- 删除缓存(或更新缓存,推荐删除以避免并发写导致的数据不一致)。
// CacheAside 示例:用户信息缓存
type UserService struct {
db *sql.DB
cache *redis.Client
}
func (s *UserService) GetUser(ctx context.Context, userID string) (*User, error) {
// 1. 读缓存
key := "user:" + userID
data, err := s.cache.Get(ctx, key).Bytes()
if err == nil {
user := &User{}
if json.Unmarshal(data, user) == nil {
return user, nil
}
}
// 2. 缓存未命中,读数据库
user, err := s.queryUserFromDB(ctx, userID)
if err != nil {
return nil, err
}
// 3. 回填缓存(设置过期时间 5 分钟)
if bytes, err := json.Marshal(user); err == nil {
s.cache.Set(ctx, key, bytes, 5*time.Minute)
}
return user, nil
}
func (s *UserService) UpdateUser(ctx context.Context, user *User) error {
// 1. 更新数据库
if err := s.updateUserInDB(ctx, user); err != nil {
return err
}
// 2. 删除缓存(而非更新)
key := "user:" + user.ID
s.cache.Del(ctx, key)
return nil
}
常见陷阱: 写操作时如果选择“更新缓存”而非“删除缓存”,在并发场景下容易产生“脏数据”。例如:线程 A 写 DB 为 100,线程 B 写 DB 为 200,但 B 先更新缓存为 200,A 后更新缓存为 100,导致缓存中永久保留旧值 100。因此,删除缓存是更安全的选择。
4.3 Read-Through 模式详解与 Go 实现
Read-Through 将“缓存未命中时回填数据库”的逻辑下沉到缓存层。应用只与缓存交互,缓存内部实现数据加载器(Loader)。
// Read-Through 模拟:自定义缓存封装
type ReadThroughCache struct {
client *redis.Client
loader func(ctx context.Context, key string) ([]byte, error) // 数据加载函数
}
func (c *ReadThroughCache) Get(ctx context.Context, key string) ([]byte, error) {
data, err := c.client.Get(ctx, key).Bytes()
if err == nil {
return data, nil
}
// 缓存未命中,调用加载器
data, err = c.loader(ctx, key)
if err != nil {
return nil, err
}
// 自动回填
c.client.Set(ctx, key, data, 5*time.Minute)
return data, nil
}
// 使用示例
cache := &ReadThroughCache{
client: redisClient,
loader: func(ctx context.Context, key string) ([]byte, error) {
userID := strings.TrimPrefix(key, "user:")
user, err := queryUserFromDB(ctx, userID)
if err != nil {
return nil, err
}
return json.Marshal(user)
},
}
优势: 应用代码不再关心缓存回填逻辑,所有读路径统一由缓存层治理。适合微服务架构中多个消费者共享同一缓存策略的场景。
4.4 Write-Through 模式详解与 Go 实现
Write-Through 要求写操作必须同步写入缓存和数据库,两者都成功后才算完成。这保证了写路径的强一致性。
// Write-Through 实现:同步双写
type WriteThroughCache struct {
client *redis.Client
db *sql.DB
}
func (c *WriteThroughCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
// 1. 先写数据库(事务内)
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 假设存储的是序列化后的 JSON
if _, err := tx.ExecContext(ctx, "INSERT INTO cache_store (cache_key, data) VALUES ($1, $2) ON CONFLICT (cache_key) DO UPDATE SET data = $2", key, value); err != nil {
return err
}
// 2. 写缓存
if err := c.client.Set(ctx, key, value, ttl).Err(); err != nil {
return err // 缓存失败,回滚数据库
}
// 3. 提交数据库事务
return tx.Commit()
}
性能代价: Write-Through 的写延迟是“缓存延迟 + 数据库延迟”,通常比 Cache-Aside 的写延迟更高。适用于对数据一致性要求极高的场景,如金融交易、订单状态变更。
4.5 Write-Behind 模式详解与 Go 实现
Write-Behind(也叫 Write-Back)将写操作先写入缓存,然后异步批量写入数据库。这极大提升了写吞吐,但引入了数据丢失风险。
// Write-Behind 实现:异步批量刷盘
type WriteBehindCache struct {
client *redis.Client
db *sql.DB
buffer chan WriteOp // 写操作缓冲通道
batchSize int
flushInterval time.Duration
}
type WriteOp struct {
Key string
Value []byte
}
func (c *WriteBehindCache) Start(ctx context.Context) {
ticker := time.NewTicker(c.flushInterval)
defer ticker.Stop()
batch := make([]WriteOp, 0, c.batchSize)
for {
select {
case op := <-c.buffer:
batch = append(batch, op)
if len(batch) >= c.batchSize {
c.flushToDB(ctx, batch)
batch = batch[:0]
}
case <-ticker.C:
if len(batch) > 0 {
c.flushToDB(ctx, batch)
batch = batch[:0]
}
case <-ctx.Done():
// 退出前强制刷盘
if len(batch) > 0 {
c.flushToDB(ctx, batch)
}
return
}
}
}
func (c *WriteBehindCache) flushToDB(ctx context.Context, batch []WriteOp) {
// 批量写入数据库(使用事务或批量 INSERT)
tx, _ := c.db.BeginTx(ctx, nil)
for _, op := range batch {
tx.ExecContext(ctx, "INSERT INTO cache_store (cache_key, data) VALUES ($1, $2) ON CONFLICT (cache_key) DO UPDATE SET data = $2", op.Key, op.Value)
}
tx.Commit()
}
func (c *WriteBehindCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
// 先写缓存
if err := c.client.Set(ctx, key, value, ttl).Err(); err != nil {
return err
}
// 异步入队
c.buffer <- WriteOp{Key: key, Value: value}
return nil
}
关键设计点: Write-Behind 必须考虑宕机时的数据丢失。生产环境中常结合 WAL(Write-Ahead Log) 或 消息队列 来保证数据不丢。例如:先写 Redis 并记录一条 WAL 日志,后台进程消费 WAL 写入 DB。
4.6 四种模式架构对比图
下图展示了四种模式在读写路径上的核心差异,帮助理解数据流向与一致性边界。
4.7 模式选型决策树
在实际项目中,可以根据以下决策树快速选择适合的缓存模式:
4.8 生产环境注意事项
- 缓存穿透: 查询一个不存在的数据,导致每次请求都穿透到 DB。解决方案:缓存空值(设置短 TTL)或使用布隆过滤器。
- 缓存击穿: 热点 key 在失效瞬间被大量并发请求打到 DB。解决方案:互斥锁(Mutex)或后台异步刷新。
- 缓存雪崩: 大量 key 在同一时间过期,导致 DB 压力激增。解决方案:设置随机 TTL,或使用多级缓存。
- 删除缓存失败: 在 Cache-Aside 写流程中,如果删除缓存失败,会导致脏数据。解决方案:使用重试机制或监听 binlog 异步删除。
- Write-Behind 数据丢失: 缓存宕机导致未刷盘的数据丢失。解决方案:结合 WAL 日志或消息队列的持久化机制。
最佳实践: 在 Go 项目中,推荐使用
singleflight 包(golang.org/x/sync/singleflight)来防止缓存击穿。对于热点 key,可以结合 sync.Map 做本地缓存,形成多级缓存架构,进一步降低 Redis 压力。