Appearance
在构建高性能、高可用的后端服务时,缓存是不可或缺的关键组件。相较于读缓存策略,写缓存策略直接影响数据写入的核心链路,对数据一致性、系统吞吐量和延迟有着决定性作用。本文将深入探讨三种主流的缓存写入策略:写后置(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 架构优势分析
- 缓存层强一致性:通过 Redis Lua 脚本保证了"检查与扣减"操作的原子性,有效防止了并发条件下的数据不一致问题。
- 极高性能:整个同步写操作在内存中完成,响应延迟极低(<1ms),系统吞吐量由 Redis 决定,能够轻松应对高并发冲击。数据库的写入延迟与用户响应路径解耦。
- 高可用与故障容忍:即使后端数据库或消息队列出现短暂故障,由于权威数据存储在 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 架构优势分析
- 提升写入性能:通过本地缓存聚合写操作,并利用 Redis Pipeline 进行批量提交,显著减少了网络 I/O 次数,大幅提升了缓存的更新效率。
- 职责分离:数据库承担数据持久化的核心职责,缓存则专注于读性能优化。二者通过异步机制解耦,互不阻塞。
- 优雅降级:若 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 架构创新点
- HyperLogLog高效去重:以极低的内存成本(约12KB)解决了海量用户去重的难题,相比使用 Set 结构节省了大量内存资源。
- 写绕行(Write-Around):写操作(点赞)与读操作(获取总数)作用于不同的缓存数据。写入路径只负责去重、记录明细和触发增量计数,而总数缓存的维护和回填由读取路径负责,有效隔离了高频写入对读缓存的影响。
- 职责清晰:HLL 负责去重,INCR 负责实时近似计数,消息队列与数据库负责最终持久化。各组件职责明确,互不干扰。
4. 策略对比与选型指南
策略名称 | 适用场景 | 一致性模型 | 性能 | 实现复杂度 | 典型应用 |
---|---|---|---|---|---|
写后置 (Write-Behind) | 要求低延迟和高吞吐 | 最终一致性 | 高 | 中 | 库存扣减 |
写穿透(Write-Through) | 写入DB后,需要加速后续读取 | 最终一致性 | 中 | 中 | 新闻、商品信息更新 |
写绕行 (Write-Around) | 读远大于写或写操作逻辑简单 | 弱一致性 | 高 | 低 | 点赞数、文章浏览量统计 |
5. 关键实施考量
- 缓存失效策略:为避免缓存雪崩,应为缓存的过期时间(TTL)增加随机扰动,分散失效时间点。
- 数据同步机制:对于更复杂的同步需求,可引入CDC(Change Data Capture)工具(如Canal),通过订阅数据库binlog来自动更新缓存,实现业务逻辑与缓存维护的解耦。
- 数据预热:在系统启动或大促前,应将可预见的热点数据提前加载到缓存中,避免系统启动初期因大量缓存未命中而导致性能瓶颈。
- 系统监控:必须建立完善的监控体系,密切关注缓存命中率、P99响应延迟、内存使用率及碎片率等关键指标,它们是评估系统健康状况的重要依据。
6. 结论
缓存写入策略的选择不存在普适的"银弹",它是在系统的一致性(Consistency)、**可用性(Availability)和性能(Performance)**三者之间进行权衡与折衷的架构决策过程。
- 追求极致性能和高吞吐,可接受最终一致性,写后置(Write-Behind) 是理想选择。
- 以数据持久化为先,并希望优化后续读取,写穿透(Write-Through) 是一个稳健的模式。
- 在读远大于写或写操作逻辑简单的场景下,写绕行(Write-Around) 提供了简单高效的解决方案。
在真实的复杂系统中,通常会根据不同业务模块的特性,组合使用多种策略,以达到整体最优的系统架构。深刻理解并灵活运用这些策略,是构建稳健、高效分布式系统的核心能力之一。