先说一个很常见的现场条件:一台机器、一个数据目录、一份二进制,没有额外的中间件可以装。这种环境里做物联网平台,最容易被卡住的不是协议解析,而是"那些本该放在 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 堆上去更有价值。