架构权衡与实践:基于“约束大于规范”的缓存组件封装
去年,基于“约束大于规范”的理念,结合业务中常见的应用场景,我们封装了一个符合业务实际需求的缓存组件——《基于约束大于规范的想法,封装缓存组件》
当时该组件还有一些优化点未完全实现,例如延时双删机制。该机制主要用于应对主从延迟带来的数据一致性问题。
在 cache-aside 模式中,数据更新操作通常会执行删除缓存的动作,但这会引入一个问题:在并发查询时,若读取的是从库数据,而主从同步存在延迟,就可能导致缓存中残留旧数据,形成脏数据。因此,我们需要在稍后一段时间再次执行删除操作。
不过,即便采用延时双删策略,理论上仍然存在风险。例如,假设设置的延迟删除时间为 5 秒,而主从同步耗时超过 5 秒,缓存中仍可能保留脏数据。从理想角度看,更可靠的方式是采用主动通知机制来实现缓存删除,例如基于 binlog 日志的同步方案。
然而,架构设计本质上是一种权衡的艺术,在发生概率极低的情况下,我们是否真的需要引入复杂度更高的方案?
部分代码示例:
package cacheimport ("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] * secondsexpiryDeviation = 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 *RedisCachetype RedisCache struct {rds *redis.Clientexpiry time.Duration //缓存失效时间notFoundExpiry time.Duration //数据库没有查询到记录时,缓存失效时间logger *zap.Loggerbarrier singleflight.Group //允许具有相同键的并发调用共享调用结果unstableExpiry Unstable //避免缓存雪崩,失效时间随机值
}func NewRedisCache(rds *redis.Client, log *zap.Logger, barrier singleflight.Group, opts ...Option) Cache {if log == nil {// 使用zap默认配置初始化日志var err errorlog, 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 := 3for i := 0; i < maxRetries; i++ {err := r.deleteKeys(ctx, keys...)if err == nil {return nil // 删除成功}// 计算重试间隔(指数退避)retryDelay := time.Duration(1<<uint(i)) * time.Millisecondr.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.Millisecondselect {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) {//数据库中不存在该值,则将占位符缓存到redisif 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}//断言为[]byteval, 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 这类更彻底的解决方案,则为我们指明了未来的优化方向,当业务对数据一致性要求达到极致时,它们就是我们技术演进的下一站。
技术决策的精髓,不在于选择最完美的方案,而在于选择最适合当前场景的方案。
期待你的声音
缓存世界的实践与挑战远不止于此,我非常期待能听到您的声音:
-
分享您的经验:在您的项目中,是如何处理缓存与数据库一致性的?是否有过更精妙或更“坑”的实践?
-
探讨方案细节:关于文中的延时双删,您认为固定的延迟时间是否是最优解?我们是否可以设计一个动态调整延迟时间的策略?
-
挑战与思考:在您看来,对于“极低概率”的脏数据问题,我们是否应该投入“高复杂度”的解决方案?您的决策边界在哪里?
欢迎在评论区留下您的真知灼见,我们一同探讨,共同精进。
