Skip to content

在构建高性能、高可用的后端服务时,缓存是不可或缺的关键组件。相较于读缓存策略,写缓存策略直接影响数据写入的核心链路,对数据一致性、系统吞吐量和延迟有着决定性作用。本文将深入探讨三种主流的缓存写入策略:写后置(Write-Behind)、写穿透(Write-Through)、以及写绕行(Write-Around),并通过三个具体的业务场景案例,分析其架构设计、实现细节与适用性,旨在为复杂系统中的缓存架构选型提供技术参考。

1. 模式一:写后置(Write-Behind)策略

1.1 业务场景:高并发库存扣减

此场景的技术挑战主要体现在以下三点:

  • 高吞吐量:在促销或秒杀活动中,库存扣减的写请求峰值可达每秒十万次以上。
  • 强一致性:库存数据必须绝对准确,杜绝超卖现象。
  • 低延迟:用户请求的响应时间必须控制在毫秒级别。

1.2 架构设计与实现

为应对上述挑战,我们采用 写后置(Write-Behind)策略,结合 Redis 的原子操作与 Kafka 消息队列实现。其核心思想是:写操作优先在缓存中完成,然后通过异步消息队列将数据变更同步至持久化存储(数据库)。

go
package inventory

import (
    "context"
    "errors"
    "log"

    "github.com/Shopify/sarama"
    "github.com/go-redis/redis/v8"
    "gorm.io/gorm"
)

// InventoryService 封装了库存操作的核心逻辑
type InventoryService struct {
    db            *gorm.DB
    redisClient   *redis.Client
    kafkaProducer *sarama.Producer
}

// 该Lua脚本确保库存检查与扣减操作的原子性
const luaDeduct = `
    -- KEYS[1]: 库存的Redis Key
    -- ARGV[1]: 计划扣减的数量
    local current = redis.call('GET', KEYS[1])
    if current and tonumber(current) >= tonumber(ARGV[1]) then
        -- 执行扣减并返回扣减后的库存值
        return redis.call('DECRBY', KEYS[1], ARGV[1])
    else
        -- 库存不足,返回-1作为标识
        return -1
    end
`

// DeductStock 执行库存扣减
func (s *InventoryService) DeductStock(ctx context.Context, sku string, count int) (bool, error) {
    // 步骤1: 在Redis中执行Lua脚本,实现原子性的库存检查与扣减
    script := redis.NewScript(luaDeduct)
    result, err := script.Run(ctx, s.redisClient, []string{"stock:" + sku}, count).Int64()
    if err != nil {
        log.Printf("Redis script execution failed: %v", err)
        return false, err
    }
    if result < 0 {
        return false, errors.New("insufficient stock")
    }

    // 步骤2: 构造消息,通过Kafka异步通知数据库进行数据持久化
    msg := StockMsg{SKU: sku, Count: count, NewStock: result}
    if err := s.kafkaProducer.SendMessage("stock-deduct-topic", msg); err != nil {
        // 消息发送失败需记录日志并告警,以便后续进行数据补偿
        // 但主流程不应阻塞,因为缓存操作已成功
        log.Printf("Failed to send Kafka message for stock deduction, SKU: %s", sku)
    }

    // 缓存操作成功,即可向上游返回成功状态
    return true, nil
}

// SyncToDB 是Kafka消费者侧的逻辑,负责将数据变更同步至数据库
func (s *InventoryService) SyncToDB(ctx context.Context, msg StockMsg) error {
    // 使用最终库存值进行更新,保证操作的幂等性
    query := "UPDATE inventory SET stock = ? WHERE sku = ?"
    _, err := s.db.ExecContext(ctx, query, msg.NewStock, msg.SKU)
    return err
}

1.3 架构优势分析

  1. 缓存层强一致性:通过 Redis Lua 脚本保证了"检查与扣减"操作的原子性,有效防止了并发条件下的数据不一致问题。
  2. 极高性能:整个同步写操作在内存中完成,响应延迟极低(<1ms),系统吞吐量由 Redis 决定,能够轻松应对高并发冲击。数据库的写入延迟与用户响应路径解耦。
  3. 高可用与故障容忍:即使后端数据库或消息队列出现短暂故障,由于权威数据存储在 Redis 中,系统仍可正常提供服务。消息队列的重试与持久化机制可保证数据最终落盘。

2. 模式二:写穿透(Write-Through)

2.1 业务场景:新闻评论系统

此场景的技术需求如下:

  • 写入密集:热点新闻可能在短时间内产生大量评论。
  • 最终一致性:用户发布评论后,可以容忍秒级的延迟才在评论列表中看到。
  • 读取优化:评论列表的读取请求需要被加速。

2.2 架构设计与实现

此模式下,写操作遵循 先写数据库,再更新缓存 的原则。为减少对缓存的频繁写操作,引入了本地缓冲区和批量更新机制。

go
package comment

import (
    "context"
    "encoding/json"
    "sync"
    "time"

    "github.com/go-redis/redis/v8"
    "gorm.io/gorm"
)

type CommentService struct {
    db          *gorm.DB
    redisClient *redis.Client
    writeBuffer map[string][]Comment // 内存缓冲区,用于暂存待更新到缓存的数据
    bufferLock  sync.Mutex
    ticker      *time.Ticker // 定时器,用于触发批量刷新
}

func NewCommentService(db *gorm.DB, redisClient *redis.Client) *CommentService {
    s := &CommentService{
        db:          db,
        redisClient: redisClient,
        writeBuffer: make(map[string][]Comment),
        ticker:      time.NewTicker(5 * time.Second), // 每5秒触发一次
    }
    go s.flushRoutine() // 启动后台goroutine执行刷新任务
    return s
}

// AddComment 用户提交新评论
func (s *CommentService) AddComment(ctx context.Context, newsID string, comment Comment) error {
    // 步骤1: 优先将数据写入数据库,确保数据持久化
    if err := s.insertCommentToDB(ctx, comment); err != nil {
        return err // 数据库写入失败,则终止操作
    }

    // 步骤2: 将新评论添加到本地内存缓冲区
    s.bufferLock.Lock()
    s.writeBuffer[newsID] = append(s.writeBuffer[newsID], comment)
    s.bufferLock.Unlock()

    return nil
}

// flushRoutine 定时将缓冲区数据批量写入Redis
func (s *CommentService) flushRoutine() {
    for range s.ticker.C {
        s.bufferLock.Lock()
        if len(s.writeBuffer) == 0 {
            s.bufferLock.Unlock()
            continue
        }
        // 复制缓冲区并清空,以减小锁的临界区
        bufferCopy := s.writeBuffer
        s.writeBuffer = make(map[string][]Comment)
        s.bufferLock.Unlock()

        // 遍历复制的缓冲区,批量更新至Redis
        for newsID, comments := range bufferCopy {
            s.flushToRedis(context.Background(), newsID, comments)
        }
    }
}

// flushToRedis 使用Redis Pipeline执行批量写入
func (s *CommentService) flushToRedis(ctx context.Context, newsID string, comments []Comment) error {
    key := "comments:" + newsID
    pipe := s.redisClient.Pipeline()

    for _, c := range comments {
        data, _ := json.Marshal(c)
        pipe.RPush(ctx, key, data) // 使用List数据结构存储评论
    }
    pipe.Expire(ctx, key, 24*time.Hour) // 为热点数据设置过期时间
    
    // 通过一次网络交互执行所有命令
    _, err := pipe.Exec(ctx)
    return err
}

// ReadComments 读取评论列表
func (s *CommentService) ReadComments(ctx context.Context, newsID string) ([]Comment, error) {
    key := "comments:" + newsID
    comments, err := s.redisClient.LRange(ctx, key, 0, -1).Result()
    if err != nil {
        return nil, err
    }
    return comments, nil
}

2.3 架构优势分析

  1. 提升写入性能:通过本地缓存聚合写操作,并利用 Redis Pipeline 进行批量提交,显著减少了网络 I/O 次数,大幅提升了缓存的更新效率。
  2. 职责分离:数据库承担数据持久化的核心职责,缓存则专注于读性能优化。二者通过异步机制解耦,互不阻塞。
  3. 优雅降级:若 Redis 服务不可用,系统依然可以完成评论的写入(存储于数据库)。读取时,若缓存失效,可回源至数据库获取数据,保证了核心功能的可用性。

3. 模式三:写绕行(Write-Around)策略

3.1 业务场景:社交点赞计数

此场景的技术挑战为:

  • 极高并发写入:热点内容的点赞请求量巨大。
  • 去重与幂等性:同一用户对同一内容只能点赞一次。
  • 数据容忍度:点赞总数的显示允许微小延迟或误差。

3.2 架构设计与实现

采用 写绕行(Write-Around) 策略,并结合 Redis 的高级数据结构 HyperLogLog。写操作绕过主数据缓存,直接更新底层数据或发送消息,而读操作遵循标准的 Cache-Aside 模式。

go
package like

import (
    "context"
    "errors"
    "fmt"
    "log"
    "time"

    "github.com/go-redis/redis/v8"
    "gorm.io/gorm"
)

type LikeService struct {
    db           *gorm.DB
    redisClient   *redis.Client
    kafkaProducer *sarama.Producer
}

// LikePost 处理点赞请求
func (s *LikeService) LikePost(ctx context.Context, postID, userID string) error {
    // Key定义:hllKey用于去重,counterKey用于实时计数
    hllKey := fmt.Sprintf("post:%s:liked_users_hll", postID)
    counterKey := fmt.Sprintf("post:%s:likes_count", postID)

    // 步骤1: 使用HyperLogLog进行高效去重。PFAdd若添加新元素则返回1,否则返回0。
    // HyperLogLog (HLL) 是一种概率性数据结构,以极小的内存占用(约12KB)估算海量数据集的基数,适用于大规模去重场景。
    added, err := s.redisClient.PFAdd(ctx, hllKey, userID).Result()
    if err != nil {
        return err
    }

    // 若added为0,表示用户已点赞,直接返回,实现幂等性
    if added == 0 {
        return errors.New("already liked")
    }

    // 步骤2: (Write-Around) 异步发送点赞明细消息至Kafka进行持久化。
    // 写入链路不直接更新计数缓存,而是绕过它。
    msg := LikeMsg{PostID: postID, UserID: userID, Time: time.Now()}
    if err := s.kafkaProducer.SendMessage("likes-topic", msg); err != nil {
        log.Printf("Failed to send Kafka message for like event, PostID: %s, UserID: %s", postID, userID)
    }

    // 步骤3: 原子地增加实时计数器,供前端展示。
    s.redisClient.Incr(ctx, counterKey)

    return nil
}

// GetLikes 获取点赞总数(读操作,遵循Cache-Aside)
func (s *LikeService) GetLikes(ctx context.Context, postID string) (int64, error) {
    counterKey := fmt.Sprintf("post:%s:likes_count", postID)
    
    // 步骤1: 优先从Redis计数器读取
    count, err := s.redisClient.Get(ctx, counterKey).Int64()
    if err == nil {
        return count, nil // 缓存命中
    }

    // 步骤2: 缓存未命中,回源到数据库查询,并重建缓存
    var dbCount int64
    // 从持久化的明细表中聚合统计
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(DISTINCT user_id) FROM likes WHERE post_id = ?", postID).Scan(&dbCount)
    if err != nil {
        return 0, err
    }

    // 回填缓存
    s.redisClient.Set(ctx, counterKey, dbCount, 24*time.Hour)
    
    return dbCount, nil
}

3.3 架构创新点

  1. HyperLogLog高效去重:以极低的内存成本(约12KB)解决了海量用户去重的难题,相比使用 Set 结构节省了大量内存资源。
  2. 写绕行(Write-Around):写操作(点赞)与读操作(获取总数)作用于不同的缓存数据。写入路径只负责去重、记录明细和触发增量计数,而总数缓存的维护和回填由读取路径负责,有效隔离了高频写入对读缓存的影响。
  3. 职责清晰:HLL 负责去重,INCR 负责实时近似计数,消息队列与数据库负责最终持久化。各组件职责明确,互不干扰。

4. 策略对比与选型指南

策略名称适用场景一致性模型性能实现复杂度典型应用
写后置 (Write-Behind)要求低延迟和高吞吐最终一致性库存扣减
写穿透(Write-Through)写入DB后,需要加速后续读取最终一致性新闻、商品信息更新
写绕行 (Write-Around)读远大于写或写操作逻辑简单弱一致性点赞数、文章浏览量统计

5. 关键实施考量

  1. 缓存失效策略:为避免缓存雪崩,应为缓存的过期时间(TTL)增加随机扰动,分散失效时间点。
  2. 数据同步机制:对于更复杂的同步需求,可引入CDC(Change Data Capture)工具(如Canal),通过订阅数据库binlog来自动更新缓存,实现业务逻辑与缓存维护的解耦。
  3. 数据预热:在系统启动或大促前,应将可预见的热点数据提前加载到缓存中,避免系统启动初期因大量缓存未命中而导致性能瓶颈。
  4. 系统监控:必须建立完善的监控体系,密切关注缓存命中率P99响应延迟内存使用率碎片率等关键指标,它们是评估系统健康状况的重要依据。

6. 结论

缓存写入策略的选择不存在普适的"银弹",它是在系统的一致性(Consistency)、**可用性(Availability)性能(Performance)**三者之间进行权衡与折衷的架构决策过程。

  • 追求极致性能和高吞吐,可接受最终一致性,写后置(Write-Behind) 是理想选择。
  • 数据持久化为先,并希望优化后续读取,写穿透(Write-Through) 是一个稳健的模式。
  • 读远大于写或写操作逻辑简单的场景下,写绕行(Write-Around) 提供了简单高效的解决方案。

在真实的复杂系统中,通常会根据不同业务模块的特性,组合使用多种策略,以达到整体最优的系统架构。深刻理解并灵活运用这些策略,是构建稳健、高效分布式系统的核心能力之一。