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

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

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

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

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

期待你的声音

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

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

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

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

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

相关推荐
艾莉丝努力练剑3 小时前
【C++:继承】C++面向对象继承全面解析:派生类构造、多继承、菱形虚拟继承与设计模式实践
linux·开发语言·c++·人工智能·stl·1024程序员节
少年码客3 小时前
英文 PDF 文档翻译成中文的优质应用
人工智能·1024程序员节
hweiyu004 小时前
Gradle 增量构建与构建缓存:自定义 Task 如何实现 “只构建变化内容”?
gradle·1024程序员节
报错小能手4 小时前
项目——基于C/S架构的预约系统平台(3)
linux·开发语言·笔记·学习·架构·1024程序员节
cxr8284 小时前
涌现的架构:集体智能框架构建解析
人工智能·语言模型·架构·1024程序员节·ai智能体·ai赋能
CaracalTiger4 小时前
告别云端依赖!ComfyUI本地化视频生成实战教程+cpolar实战
python·gpt·开源·aigc·ai编程·1024程序员节·ai-native
心寒丶4 小时前
Linux基础知识(三、Linux常见操作目录命令)
linux·运维·服务器·1024程序员节
W.Y.B.G4 小时前
css3 学习笔记
笔记·学习·css3·1024程序员节
spencer_tseng4 小时前
JDK 9 List.of(...)
java·windows·list·1024程序员节