去年,基于"约束大于规范"的理念,结合业务中常见的应用场景,我们封装了一个符合业务实际需求的缓存组件------《基于约束大于规范的想法,封装缓存组件》
当时该组件还有一些优化点未完全实现,例如延时双删机制。该机制主要用于应对主从延迟带来的数据一致性问题。
在 cache-aside 模式中,数据更新操作通常会执行删除缓存的动作,但这会引入一个问题:在并发查询时,若读取的是从库数据,而主从同步存在延迟,就可能导致缓存中残留旧数据,形成脏数据。因此,我们需要在稍后一段时间再次执行删除操作。
不过,即便采用延时双删策略,理论上仍然存在风险。例如,假设设置的延迟删除时间为 5 秒,而主从同步耗时超过 5 秒,缓存中仍可能保留脏数据。从理想角度看,更可靠的方式是采用主动通知机制来实现缓存删除,例如基于 binlog 日志的同步方案。
然而,架构设计本质上是一种权衡的艺术,在发生概率极低的情况下,我们是否真的需要引入复杂度更高的方案?
部分代码示例:
Go
package cache
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-redis/redis/v8"
"golang.org/x/sync/singleflight"
//"gorm.io/gorm/logger"
"go.uber.org/zap"
)
const (
notFoundPlaceholder = "*" //数据库没有查询到记录时,缓存值设置为*,避免缓存穿透
// make the expiry unstable to avoid lots of cached items expire at the same time
// make the unstable expiry to be [0.95, 1.05] * seconds
expiryDeviation = 0.05
)
// indicates there is no such value associate with the key
var errPlaceholder = errors.New("placeholder")
var ErrNotFound = errors.New("not found")
// ErrRecordNotFound record not found error
var ErrRecordNotFound = errors.New("record not found") //数据库没有查询到记录时,返回该错误
var redisCache *RedisCache
type RedisCache struct {
rds *redis.Client
expiry time.Duration //缓存失效时间
notFoundExpiry time.Duration //数据库没有查询到记录时,缓存失效时间
logger *zap.Logger
barrier singleflight.Group //允许具有相同键的并发调用共享调用结果
unstableExpiry Unstable //避免缓存雪崩,失效时间随机值
}
func NewRedisCache(rds *redis.Client, log *zap.Logger, barrier singleflight.Group, opts ...Option) Cache {
if log == nil {
// 使用zap默认配置初始化日志
var err error
log, err = zap.NewProduction()
if err != nil {
// 初始化失败时使用默认日志
log = zap.NewExample()
log.Warn("使用默认zap日志配置")
}
}
o := newOptions(opts...)
return &RedisCache{
rds: rds,
expiry: o.Expiry,
notFoundExpiry: o.NotFoundExpiry,
logger: log,
barrier: barrier,
unstableExpiry: NewUnstable(expiryDeviation),
}
}
func SetRedisCache(rc *RedisCache) {
redisCache = rc
}
func GetRedisCache() Cache {
return redisCache
}
// 批量删除缓存键
func (r *RedisCache) DelCtx(ctx context.Context, query func() error, keys ...string) error {
if err := query(); err != nil {
r.logger.Error(fmt.Sprintf("Failed to query: %v", err))
return err
}
// 增加空键检查
if len(keys) == 0 {
return nil
}
// 先删除一次缓存
if err := r.retryDelete(ctx, keys...); err != nil {
r.logger.Error(fmt.Sprintf("Failed to delete keys after retries: %v", err))
return err
}
// 延迟后再次删除缓存(双删策略)
//newCtx := context.WithoutCancel(ctx) //go1.17版本不支持这个方法
go r.delayedDelete(ctx, keys...)
return nil
}
// 带重试机制的缓存删除(简化错误重试)
func (r *RedisCache) retryDelete(ctx context.Context, keys ...string) error {
maxRetries := 3
for i := 0; i < maxRetries; i++ {
err := r.deleteKeys(ctx, keys...)
if err == nil {
return nil // 删除成功
}
// 计算重试间隔(指数退避)
retryDelay := time.Duration(1<<uint(i)) * time.Millisecond
r.logger.Warn(fmt.Sprintf("Delete keys failed, retry %d in %v: %v",
i+1, retryDelay, err))
// 等待重试间隔
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
// 继续下一次重试
}
}
return fmt.Errorf("max retries reached for deleting keys: %v", keys)
}
// 执行缓存删除
func (r *RedisCache) deleteKeys(ctx context.Context, keys ...string) error {
pipe := r.rds.Pipeline()
for _, key := range keys {
pipe.Del(ctx, key)
}
_, err := pipe.Exec(ctx)
return err
}
// 延迟双删
func (r *RedisCache) delayedDelete(ctx context.Context, keys ...string) {
// 创建带超时的上下文,防止无限阻塞
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 延迟执行
delay := 500 * time.Millisecond
select {
case <-time.After(delay):
case <-ctx.Done():
r.logger.Warn("delayedDelete context cancelled before delay expired")
return
}
r.logger.Info("delayedDelete")
// 最多重试三次
if err := r.retryDelete(ctx, keys...); err != nil {
r.logger.Error(fmt.Sprintf("Delayed delete failed after retries: %v", err))
} else {
r.logger.Info(fmt.Sprintf("Successfully executed delayed delete for keys: %v", keys))
}
}
func (r *RedisCache) TakeCtx(ctx context.Context, key string, query func() (interface{}, error)) ([]byte, error) {
return r.TakeWithExpireCtx(ctx, key, r.expiry, query)
}
func (r *RedisCache) TakeWithExpireCtx(ctx context.Context, key string, expire time.Duration, query func() (interface{}, error)) ([]byte, error) {
// 在过期时间的基础上,增加一个随机值,避免缓存雪崩
expire = r.aroundDuration(expire)
// 并发控制,同一个key的请求,只有一个请求执行,其他请求等待共享结果
res, err, _ := r.barrier.Do(key, func() (interface{}, error) {
cacheVal, err := r.doGetCache(ctx, key)
if err != nil {
// 如果缓存中查到的是notfound的占位符,直接返回
if errors.Is(err, errPlaceholder) {
return nil, ErrNotFound
} else if !errors.Is(err, ErrNotFound) {
return nil, err
}
}
// 缓存中存在值,直接返回
if len(cacheVal) > 0 {
return cacheVal, nil
}
data, err := query()
if errors.Is(err, ErrRecordNotFound) {
//数据库中不存在该值,则将占位符缓存到redis
if err = r.setCacheWithNotFound(ctx, key); err != nil {
r.logger.Error(fmt.Sprintf("Failed to set not found key %s: %v", key, err))
}
return nil, ErrNotFound
} else if err != nil {
return nil, err
}
if strData, ok := data.(string); ok {
cacheVal = []byte(strData)
} else {
cacheVal, err = json.Marshal(data)
if err != nil {
return nil, err
}
}
if err := r.rds.Set(ctx, key, cacheVal, expire).Err(); err != nil {
r.logger.Error(fmt.Sprintf("Failed to set key %s: %v", key, err))
return nil, err
}
return cacheVal, nil
})
if err != nil {
return []byte{}, err
}
//断言为[]byte
val, ok := res.([]byte)
if !ok {
return []byte{}, fmt.Errorf("failed to convert value to bytes")
}
return val, nil
}
func (r *RedisCache) aroundDuration(duration time.Duration) time.Duration {
return r.unstableExpiry.AroundDuration(duration)
}
// 获取缓存
func (r *RedisCache) doGetCache(ctx context.Context, key string) ([]byte, error) {
val, err := r.rds.Get(ctx, key).Bytes()
if err != nil {
if err == redis.Nil {
return nil, ErrNotFound
}
return nil, err
}
if len(val) == 0 {
return nil, ErrNotFound
}
// 如果缓存的值为notfound的占位符,则表示数据库中不存在该值,避免再次查询数据库,避免缓存穿透
if string(val) == notFoundPlaceholder {
return nil, errPlaceholder
}
return val, nil
}
// 数据库没有查询到值,则设置占位符,避免缓存穿透
func (r *RedisCache) setCacheWithNotFound(ctx context.Context, key string) error {
notFoundExpiry := r.aroundDuration(r.notFoundExpiry)
if err := r.rds.Set(ctx, key, notFoundPlaceholder, notFoundExpiry).Err(); err != nil {
r.logger.Error(fmt.Sprintf("Failed to set not found key %s: %v", key, err))
return err
}
return nil
}
总结与展望
在架构设计的道路上,我们总是在简单与复杂 、理想与现实之间进行权衡。本文所探讨的基于"约束大于规范"理念的缓存组件,以及为保障数据一致性所实现的延时双删策略,正是这种权衡下的一个实践缩影。
我们必须承认,没有一种方案是完美的。延时双删虽然不能百分之百地解决极端情况下的主从延迟问题,但它以可接受的复杂度,抵御了绝大部分的脏数据场景,这对于许多业务系统而言已经足够。而像 Binlog 这类更彻底的解决方案,则为我们指明了未来的优化方向,当业务对数据一致性要求达到极致时,它们就是我们技术演进的下一站。
技术决策的精髓,不在于选择最完美的方案,而在于选择最适合当前场景的方案。
期待你的声音
缓存世界的实践与挑战远不止于此,我非常期待能听到您的声音:
-
分享您的经验:在您的项目中,是如何处理缓存与数据库一致性的?是否有过更精妙或更"坑"的实践?
-
探讨方案细节:关于文中的延时双删,您认为固定的延迟时间是否是最优解?我们是否可以设计一个动态调整延迟时间的策略?
-
挑战与思考:在您看来,对于"极低概率"的脏数据问题,我们是否应该投入"高复杂度"的解决方案?您的决策边界在哪里?
欢迎在评论区留下您的真知灼见,我们一同探讨,共同精进。