SagooIoT 的缓存换后端只改一行配置,文件后端连 Redis 都不需要

先说一个很常见的现场条件:一台机器、一个数据目录、一份二进制,没有额外的中间件可以装。这种环境里做物联网平台,最容易被卡住的不是协议解析,而是"那些本该放在 Redis 里的东西该放哪里"。设备详情、产品 TSL、系统参数、告警规则、规则链,全都是读多写少、结构固定、可以常驻的字典数据。

SagooIoT 在 V2 的版本说明里有一条很短的话:"重构缓存处理,统一使用方式。"这句话背后其实是一个三十行的装配函数,它把内存、文件、Redis 三种落地方式收进了同一个开关,业务侧一行都不用改。这篇就从这三十行开始,把这套缓存收口讲清楚。

一、一个开关,同时管住两类缓存

配置在 manifest/config/config.yaml 里,只有两行是真正需要动的:

复制代码
cache:
  prefix: "SagooIot_Sys:"            # 缓存前缀
  adapter: "redis"                   # 缓存驱动方式,支持:memory|redis|file,不填默认memory
  fileDir: "./storage/cache"         # 文件缓存路径,adapter=file时必填

adapter 三个取值,对应三种落地:进程内存、本地文件、Redis。装配函数是 pkg/cache/cache.go:

复制代码
// SetAdapter 设置缓存适配器
func SetAdapter(ctx context.Context) {
	var cacheAdapter gcache.Adapter
	adapter := g.Cfg().MustGet(ctx, "cache.adapter").String()
	fileDir := g.Cfg().MustGet(ctx, "cache.fileDir").String()

	switch adapter {
	case "redis":
		cacheAdapter = gcache.NewAdapterRedis(g.Redis())
	case "file":
		if fileDir == "" {
			g.Log().Fatal(ctx, "file path must be configured for file caching.")
			return
		}
		if !gfile.Exists(fileDir) {
			if err := gfile.Mkdir(fileDir); err != nil {
				g.Log().Fatalf(ctx, "failed to create the cache directory. procedure, err:%+v", err)
				return
			}
		}
		cacheAdapter = file.NewAdapterFile(fileDir)
	default:
		cacheAdapter = gcache.NewAdapterMemory()
	}

	// 数据库缓存,默认和通用缓冲驱动一致,如果你不想使用默认的,可以自行调整
	g.DB().GetCache().SetAdapter(cacheAdapter)

	// 通用缓存
	cache = gcache.New()
	cache.SetAdapter(cacheAdapter)
}

关键在于最后两行:同一个 cacheAdapter 交给了两个地方,一个是 GoFrame ORM 的查询缓存(g.DB().GetCache()),一个是业务侧用的通用缓存实例。这意味着改一次配置,数据库查询结果和业务对象的落地方式一起换了 ------ 不会出现"业务缓存走文件、ORM 缓存还在内存里"这种半边生效的状态。

switch 的 default 分支也值得注意:配置写错了、留空了、写成 Memory 大小写不一致,都不会 panic,而是回落到进程内存。缓存这种旁路组件,宁可在没有配置时降级到最保守的实现,也不该让整个服务起不来。

二、装配只有一处,位置在启动链的前段

整个工程里调用 cache.SetAdapter(ctx) 的地方一共两类:一类是进程入口,一类是测试。入口那一类的顺序是固定的,command/global.go 里看得很清楚:

复制代码
SetGFMode(ctx)                              // 运行模式
gtime.SetTimeZone("Asia/Shanghai")          // 时区
InitTrace(ctx)                              // 链路追踪
CheckEnvironment(ctx)                       // 环境自检

cache.SetAdapter(ctx)                       // ← 缓存装配
statistics.InitCountDeviceData()            // 设备统计
dcache.InitSystemConfig(ctx)                // 系统参数入缓存
service.RuleInstance().InItRuleData(ctx)    // 规则链入缓存
analysis.RemoveDeviceStatusCountCache(ctx)  // 清统计缓存

这个次序不是随手排的。InitSystemConfig 本身就要把系统参数写进缓存,InItRuleData 要把规则链写进缓存,它们都排在 SetAdapter 之后 ------ 装配必须先完成,后面这些初始化才有地方落。

分体部署时也是一样的规矩。sagoo-core、sagoo-rule、sagoo-alarm、sagoo-compute、sagoo-task 各自的 main.go 都会在自己的启动回调里调一次 cache.SetAdapter(ctx),mqttserver/mqtt.go 也调。每个进程独立装配,谁都不依赖别人先起来 ------ 缓存是本地资源,不该成为进程之间的隐式依赖。

文件模式还有一个细节处理得很实在:fileDir 没配就直接 Fatal 退出,而不是默默退回内存。缓存目录写错,最容易出现的现象是"数据看起来在,重启就没了",而且不报错。启动时硬失败一次,比上线后查三天要便宜得多。

三、键变成一个文件名:sha256 折成 64 个字符

三种后端里,文件后端要处理一个另两种后端都不存在的问题:键得变成一个合法的文件名。SagooIoT 的做法是直接把键哈希掉:

复制代码
func (c *AdapterFile) createName(key string) string {
	h := sha256.New()
	_, _ = h.Write([]byte(key))
	hash := hex.EncodeToString(h.Sum(nil))
	return filepath.Join(c.dir, fmt.Sprintf("%s.cache", hash))
}

哈希这一步解决的问题比想象中多。这个系统里的缓存键长这样:DeviceDetailInfo:<设备key>、deviceStatus:<设备key>、systemConfig:<参数名>、plugins:protocol:<插件名>。设备 key 往往是用户自己定的字符串,带冒号、带中文、带空格都很正常。

  • 冒号在 macOS 和 Windows 上都不是合法文件名字符,直接拿来当文件名会写失败;
  • 中文在跨平台挂载、容器卷、备份工具之间来回转换的时候容易出编码问题;
  • 文件系统对单个文件名有 255 字节上限,复合键叠上产品 key 就有可能撞上;
  • macOS 默认大小写不敏感,DeviceA 和 devicea 会变成同一个文件。

sha256 一次把这些全部抹平:定长 64 个十六进制字符,只含 0-9a-f,大小写不敏感文件系统也不会撞。举个具体例子,键 DeviceDetailInfo:demo-device-001 落盘后是:

复制代码
92be8f90f2a1cccbd9d1357a094f1759f184caa29469275c4042ef2abecba758.cache

一个键一个文件,目录本身就是命名空间。cache.fileDir 指向哪个目录,这一层缓存的范围就在哪个目录里,备份、清理、迁移都可以按目录做,不需要理解里面的内容。

四、文件里装的是一个两字段的信封

打开任何一个 .cache 文件,里面是这样一个 JSON:

复制代码
type fileContent struct {
	Duration int64       `json:"duration"`
	Data     interface{} `json:"data,omitempty"`
}

写入逻辑在 Save:

复制代码
func (c *AdapterFile) Save(key string, value string, lifeTime time.Duration) error {
	duration := int64(0)

	if lifeTime > 0 {
		duration = time.Now().Unix() + int64(lifeTime.Seconds())
	}

	content := &fileContent{duration, value}

	data, err := json.Marshal(content)
	if err != nil {
		return err
	}

	return os.WriteFile(c.createName(key), data, perm)
}

两个设计选择值得说:

Duration 存的是绝对到期时间戳,不是时长。 字段名叫 Duration,语义却是 deadline ------ 写入那一刻就把 now + lifeTime 算好存下去。对文件这种会被进程重启、会被复制、会被备份恢复的存储来说,这是唯一说得通的选法:存时长的话,进程重启后所有键的 TTL 都会重新开始计时,缓存的有效期就变成了"最后一次重启之后多久"。

lifeTime 为 0 表示永不过期。 这与 gcache 的约定一致。业务侧写入设备详情、产品详情、系统参数时传的都是 0(cache.Instance().Set(ctx, key, data, 0)),这些数据由写路径负责摘除,不靠 TTL 兜底。

JSON 信封还带来一个运维上的好处:这个文件可以直接 cat,可以直接 jq,可以直接 grep。排查"某个设备详情为什么是旧值"的时候,不用连客户端、不用记命令,看一眼文件里的 duration 就知道它是常驻还是带过期,看一眼 data 就知道落在里面的是哪一版结构。这比二进制序列化在排障时友好太多。

五、过期判断放在读路径上

文件后端没有 Redis 那种原生的 TTL,所以过期是在读的时候判的:

复制代码
func (c *AdapterFile) read(key string) (*fileContent, error) {
	rp := gfile.RealPath(c.createName(key))
	if rp == "" {
		return nil, nil
	}

	value, err := os.ReadFile(rp)
	if err != nil {
		return nil, err
	}

	content := &fileContent{}
	if err := json.Unmarshal(value, content); err != nil {
		return nil, err
	}

	if content.Duration == 0 {
		return content, nil
	}

	if content.Duration <= time.Now().Unix() {
		_ = c.Delete(key)
		return nil, CacheExpiredErr
	}
	return content, nil
}

读到过期就顺手把文件删掉,然后返回一个专门的 CacheExpiredErr。这个错误值不是随手定义的 ------ gcache 的 Adapter 接口里,GetOrSet 系列方法必须能区分"没有这个键"和"这个键过期了",前者要回源重建,后者同样要回源重建但语义不同。文件后端用一个 errors.Is 可识别的哨兵错误把这件事表达清楚:

复制代码
func (c *AdapterFile) GetOrSet(ctx context.Context, key interface{}, value interface{}, duration time.Duration) (result *gvar.Var, err error) {
	result, err = c.Get(ctx, key)
	if err != nil && !errors.Is(err, CacheExpiredErr) {
		return nil, err
	}
	if result.IsNil() {
		return gvar.New(value), c.Set(ctx, key, value, duration)
	}
	return
}

惰性过期的收益是结构上的:不需要后台清理协程,不需要定时扫描任务,进程空闲时不产生任何 I/O。对一个要长时间跑在设备旁边的服务来说,这比"每隔一分钟遍历一遍目录"要合适得多 ------ 过期文件在下一次被访问时消失,没被访问过的也就没必要急着删。

六、20 个方法,文件后端一一对上

gcache 的 Adapter 接口一共有 20 个带 ctx 的方法,文件后端把这 20 个全部实现了,没有留空壳。这一点比方法数量本身更重要:一个适配器只要能编译通过,就说明它对每一个语义都有交代。

方法 文件后端的落地方式
Set 值为 nil 或 duration 为负 → 转成删除;否则 Save 覆盖写
SetMap 锁内逐键 Save,一次批量写同 TTL
SetIfNotExist 写锁内先 Has 再 Save,避免查与写之间的空档
SetIfNotExistFunc 同上,值由回调生成,生成失败不落盘
SetIfNotExistFuncLock 复用上面两个方法的锁语义
Get Fetch 后包成 gvar.Var
GetOrSet / GetOrSetFunc 命中直接返回,未命中回源并写回
Contains 走 Has,过期视为不存在
Size 返回 0(文件后端不维护计数)
Data / Values 遍历目录读出所有信封
Keys 返回目录下的条目名
Update 读旧值、返回旧值、按原 TTL 覆盖写
UpdateExpire / GetExpire 仅改 duration 字段,不重写 data
Remove 返回最后一个键的旧值,再批量删
Clear Flush 清空目录
Close 空实现,无连接可关

Set 里那个"nil 或负 duration 就转删除"的分支,和 Redis 适配器的写法是完全对齐的:

复制代码
func (c *AdapterFile) Set(ctx context.Context, key interface{}, value interface{}, lifeTime time.Duration) (err error) {
	c.mutex.Lock()
	defer c.mutex.Unlock()
	fileKey := gconv.String(key)
	if value == nil || lifeTime < 0 {
		return c.Delete(fileKey)
	}
	return c.Save(fileKey, gconv.String(value), lifeTime)
}

同一个调用 Set(ctx, key, nil, 0),在 Redis 上是 DEL,在文件上也是删文件,在内存上同样是移除。业务代码不需要知道自己跑在哪个后端上 ------ 这就是把三种实现塞进同一个接口的全部意义。

至于值的类型,文件后端统一走 gconv.String 落成字符串。对结构体来说,gconv.String 走的是 json.Marshal 分支,取出来后 gconv.Scan 会先做一次 JSON 合法性判断再反序列化,字段能原样还原。对本来就是文本的值 ------ 比如插件配置 ------ 存进去是 YAML 原文,取出来还是 YAML 原文,pkg/plugins/manager.go 就是这么用的:

复制代码
func getPluginsConfigData(pluginType, pluginName string) (res map[interface{}]interface{}, err error) {
	key := fmt.Sprintf(consts.PluginsTypeName, pluginType, pluginName)
	pcgData, err := cache.Instance().Get(context.Background(), key)
	if err != nil {
		return
	}
	if pcgData == nil {
		return nil, errors.New("插件缓存配置不存在")
	}
	err = gyaml.DecodeTo([]byte(pcgData.String()), &res)
	return
}

原文进、原文出,中间不经过任何结构体,插件配置的热更新路径因此变得很短。

七、业务层只认一个 Instance()

业务代码不直接碰适配器,只碰 cache.Instance()。Instance() 在未初始化时会直接 panic,这个设计是刻意的 ------ 缓存没装配就使用,属于启动顺序错了,早崩比晚崩好排查:

复制代码
var cache *gcache.Cache

func Instance() *gcache.Cache {
	if cache == nil {
		panic("cache uninitialized.")
	}
	return cache
}

在此之上,pkg/dcache 又加了一层进程内缓存,形成两级结构:最近最热的少量数据放进程内存,稍冷一点的放远端缓存。

复制代码
// 创建一个全局的本地缓存,用于缓解高并发访问redis数据库
var localCache = myCache.New(5*time.Minute, 10*time.Minute)

设备在线状态是最典型的例子。它被 MQTT 接入层高频查询,每次上报都要问一遍"这台设备在线吗",如果每次都打远端缓存,QPS 会很可观:

复制代码
func GetDeviceStatus(ctx context.Context, deviceKey string) (res int) {
	if cachedData, found := localCache.Get(consts.DeviceStatusPrefix + deviceKey); found {
		if cachedData != nil {
			return 2
		}
		return 1
	}
	data, err := cache.Instance().Get(ctx, consts.DeviceStatusPrefix+deviceKey)
	if err != nil || data == nil {
		return 1
	}
	if data.Val() != nil {
		// 将数据写入本地缓存,有效期为10秒。
		localCache.Set(consts.DeviceStatusPrefix+deviceKey, data.Val(), 10*time.Second)
		return 2
	}
	return 1
}

本地这一层只放 10 秒。10 秒是个很克制的数字:足够吸收同一台设备连续上报带来的重复查询,又不至于让"设备刚掉线"和"系统认为它在线"之间出现太长的窗口。

设备详情走的是同样的两级结构,只是本地有效期长得多(5 分钟),因为设备详情变更频率远低于在线状态。取不到时还有一层重试保护:3 次重试、50ms 起步的指数退避、整体 3 秒超时,判断"这次失败值不值得重试"靠的是错误文本里有没有 connection reset、i/o timeout、EOF 这类连接层特征 ------ 只有连接问题才重试,业务层的"没这条数据"直接返回。

各类业务对象用的键前缀集中定义在 internal/consts/dcache.go:

业务对象 键前缀 写入时的 TTL
设备详情 DeviceDetailInfo: 0(不过期,写路径摘除)
产品详情 ProductDetailInfo: 0
设备在线状态 deviceStatus: 由上报链路设置
系统参数 systemConfig: 0
告警规则 deviceAlarmRule: 0
规则链 sagooRuleEngine:ruleChain: 0

一眼能看出来:常驻的都是"配置类",会变但变得慢,靠写路径精确摘除;带 TTL 的都是"状态类",本来就有生命周期,让它自己过期最省事。这个划分不是技术限制,是数据性质决定的。

八、键名带前缀,摘除可以点到为止

前缀除了给人看,还有一个实际作用:让"只摘一条"成为可能。缓存和数据库最难配合的地方从来不是写,而是失效。

SagooIoT 里有一处跨库同步设备的逻辑,改完库之后紧接着做了一件事:

复制代码
_, err = dao.DevDevice.Ctx(ctx).Data(param).FieldsEx(dao.DevDevice.Columns().Id).
	Where(dao.DevDevice.Columns().Key, input.Key).Update()
if err != nil {
	return
}

//从缓存中删除
_, err = cache.Instance().Remove(ctx, consts.CacheGfOrmPrefix+consts.GetDetailDeviceOutput+input.Key)

顺序是先改库、再摘缓存。这个顺序是对的:反过来的话,摘缓存和写库之间会有一个窗口,读请求会把旧值重新填回去,然后旧值就一直留在缓存里了。

有意思的是被摘掉的那个键。consts.CacheGfOrmPrefix 的值是 SelectCache: ------ 也就是 GoFrame ORM 查询缓存的统一前缀,consts.GetDetailDeviceOutput 是 GetDetailDeviceOutput:。两个前缀拼起来再加上设备 key,构成的正是 ORM 那条 Detail 查询在缓存里的键。

也就是说,业务缓存和 ORM 缓存共用同一个命名空间,靠前缀区分。这带来两个直接的好处:一是不需要维护两套客户端,装配一次两边都生效;二是不需要 Flush 全库 ------ 摘一个键就够,其他设备的缓存完全不受影响。

在声明了 Cache 的模型上,GoFrame 的写操作本身也会顺手摘掉对应的 SelectCache: 键(gdb_model_cache.go 里的 checkAndRemoveSelectCache)。SagooIoT 在跨进程同步这种绕开模型的路径上再手动摘一次,两头都能对上。

九、三种后端怎么选

把三种实现摊开对比,各自的定位其实很清楚:

memory file redis
读延迟 纳秒级,无 I/O 一次文件读,页缓存命中后极快 一次网络往返
进程重启后 全部丢失 全部保留 全部保留
多实例共享 不支持 不支持(各实例各一份) 支持
外部依赖 无 无 需要 Redis 服务
适合的部署 单机开发、压测 单机/边缘/离线现场 集群、多副本、云上

memory 是默认值,也是"配置没写对"时的兜底。它最快,代价是重启即失,多进程各一份 ------ 分体部署下 sagoo-core 和 sagoo-admin 看到的缓存不是同一份,所以生产上一般不会选它。

file 是这套设计里最有意思的一档。它把"持久缓存"这件事从"必须有 Redis"里解耦出来了:一台没有外部依赖的机器,重启之后设备详情、产品 TSL、系统参数仍然在,不用在启动瞬间被一波查询打穿数据库。对边缘盒子、现场一体机、离线内网这类环境,这一档基本就是最优解。

redis 是集群场景的答案。多副本共享、原生 TTL、跨进程一致性,这些都是文件后端给不了的。

值得强调的是,这三档的切换成本是"改一个字符串"。业务代码里没有一处 if adapter == "file",dcache、plugins、proxy 全都只认 cache.Instance()。后面要换成别的实现 ------ 比如某个客户要求走自研的分布式缓存 ------ 也只需要再写一个 gcache.Adapter 的实现,在 switch 里加一个 case。

结语

把这套缓存拆完,会发现它的技术含量不在某个巧妙的算法上,而在几个"边界划得干净"的决定:

配置是三选一,装配只有一处,业务侧只有一个入口;文件后端用 sha256 把任意键折成合法文件名,用绝对时间戳记到期,用读路径做惰性过期;键名统一带前缀,让"摘一条"成为默认操作而不是最后手段;常驻的配置类数据和带 TTL 的状态类数据分开对待,各用各的失效方式。

这些东西单看每一条都很朴素。但物联网平台的缓存层最容易出的问题,恰恰不是性能不够,而是"不知道哪份数据是旧的"。把失效边界划清楚,比把 QPS 堆上去更有价值。

项目地址

社区版源代码:https://github.com/sagoo-cloud

在线文档:https://iotdoc.sagoo.cn

相关推荐
喵了几个咪3 小时前
GoWind ShortPlay 深度拆解:三服务 BFF + Core,一条请求的完整工程链路
golang·gowind
珠海西格电力3 小时前
零碳园区管理系统的性能评估体系的行业标准
大数据·网络·人工智能·物联网·机器学习·架构·能源
优信电子4 小时前
HC05蓝牙模块主机与从机进行连接通信
单片机·物联网·嵌入式
K成长日志5 小时前
BLE Host层L2CAP -- 配置参数选项
物联网·网络协议·嵌入式·蓝牙·iot·ble·bluetooth
2601_968900776 小时前
推理部署配置实践:缓存窗口、硬件配比与协议统一的核查清单
缓存
金瑞铭8 小时前
RFID称重货架-科研实验室试剂精准管控
物联网·rfid·iot
一条泥憨鱼9 小时前
【Redis | 黑马点评】基于Redis的消息队列 | stream消息队列 源码解读
java·数据库·redis·缓存·lua
数字新视界9 小时前
信创机房动环监控的核心功能与智能运维管理实践
大数据·物联网·信息可视化·信息与通信·一体化机柜
每天一道题9 小时前
Redis 缓存为什么会出问题?穿透、击穿、雪崩与缓存一致性
数据库·redis·缓存