当前位置: 首页 > news >正文

架构权衡与实践:基于“约束大于规范”的缓存组件封装

去年,基于“约束大于规范”的理念,结合业务中常见的应用场景,我们封装了一个符合业务实际需求的缓存组件——《基于约束大于规范的想法,封装缓存组件》

当时该组件还有一些优化点未完全实现,例如延时双删机制。该机制主要用于应对主从延迟带来的数据一致性问题。

在 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 这类更彻底的解决方案,则为我们指明了未来的优化方向,当业务对数据一致性要求达到极致时,它们就是我们技术演进的下一站。

技术决策的精髓,不在于选择最完美的方案,而在于选择最适合当前场景的方案。

期待你的声音

缓存世界的实践与挑战远不止于此,我非常期待能听到您的声音:

  1. 分享您的经验:在您的项目中,是如何处理缓存与数据库一致性的?是否有过更精妙或更“坑”的实践?

  2. 探讨方案细节:关于文中的延时双删,您认为固定的延迟时间是否是最优解?我们是否可以设计一个动态调整延迟时间的策略?

  3. 挑战与思考:在您看来,对于“极低概率”的脏数据问题,我们是否应该投入“高复杂度”的解决方案?您的决策边界在哪里?

欢迎在评论区留下您的真知灼见,我们一同探讨,共同精进。

http://www.dtcms.com/a/524790.html

相关文章:

  • 【实战经验】飞牛云 如何使用 SSD 缓存加速?
  • 数据结构--顺序表与链表
  • 网站排名优化课程深圳网站建设开发哪家好
  • 使用 WebSocket 实现手机控制端与电脑展示端的实时通信,支持断线重连、状态同步和双向数据交互。(最优方案)
  • 快递鸟 MCP Server:AI 工具解锁 物流 API 能力,开启智能物流新生态
  • UV Python 包和项目管理工具
  • 使用 Quill 实现编辑器功能
  • 企业网站建设的可行性图片编辑软件加文字
  • 零基础网站建设视频教程做淘宝美工的网站
  • 微米级光斑分析仪市场报告:政策、趋势与前景深度解析
  • 达梦 DM Database 集群:从概念到开发场景
  • 面向社科研究者:用深度学习做因果推断(一)
  • 站长seo计费系统比较好的网页模板网站
  • 【学习笔记】大模型
  • ES7243E 模拟音频转I2S输入给BES I2S_Master数据运行流程分析
  • 虚拟内存与RAM
  • 广州花都区网站建设长沙seo优化排名推广
  • 广告公司网站模版做一家网站要多少钱
  • 【Linux知识】Linux文本操作相关命令行
  • Port设置功能开发实践: Pyside6 MVC架构与Model/View/Delegate模式的应用
  • 白之家低成本做网站深圳比较好的建网站公司
  • 深度学习一些知识点(指标+正则化)
  • 企业官方网站建设的作用仿牌 镜像网站
  • java实现多线程分片下载超大文件,支持HTTPS。
  • 数据结构和算法(十)--B树
  • 从零起步学习MySQL || 第九章:从数据页的角度看B+树及MySQL中数据的底层存储原理(结合常见面试题深度解析)
  • HTTP 与 SOCKS5 代理协议:企业级选型指南与工程化实践
  • 新华三H3CNE网络工程师认证—STP状态机与收敛过程
  • 从零起步学习MySQL || 第十章:深入了解B+树及B+树的性能优势(结合底层数据结构与数据库设计深度解析)
  • 阿里云服务器网站备案台州北京网站建设