perf(config): add Otter RAM cache layer for system configs

Introduce pkg/cache/ram on top of existing Redis/DB config reads.
GetByKey now checks local RAM before Redis. Unified invalidation clears
RAM and Redis hash fields on admin writes, with pub/sub for multi-node
RAM eviction. Add tests and wire create/update/migrator write paths.
This commit is contained in:
ryan
2026-06-17 11:56:34 +08:00
parent ca21e0eca7
commit d3ba767087
16 changed files with 406 additions and 46 deletions
+1
View File
@@ -126,6 +126,7 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/maypok86/otter/v2 v2.3.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/paulmach/orb v0.12.0 // indirect
+2
View File
@@ -224,6 +224,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+1 -5
View File
@@ -129,9 +129,5 @@ func saveOrUpdateConfig(ctx context.Context, key string, value string) error {
}
}
// Sync config value in Redis cache
if db.Redis != nil {
_ = db.HSetJSON(ctx, model.SystemConfigRedisHashKey, key, &sc)
}
return nil
return model.InvalidateSystemConfigCache(ctx, key)
}
+8 -8
View File
@@ -87,16 +87,16 @@ func CreateSystemConfig(c *gin.Context) {
return err
}
if err := db.HSetJSON(c.Request.Context(), model.SystemConfigRedisHashKey, req.Key, &config); err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, response.Err(err.Error()))
return
}
if err := model.InvalidateSystemConfigCache(c.Request.Context(), req.Key); err != nil {
logger.WarnF(c.Request.Context(), "清理系统配置缓存失败: %v", err)
}
if err := model.InvalidateVisibleSystemConfigsCache(c.Request.Context()); err != nil {
logger.WarnF(c.Request.Context(), "清理公共配置列表缓存失败: %v", err)
}
@@ -233,10 +233,6 @@ func UpdateSystemConfig(c *gin.Context) {
return err
}
if err := db.HSetJSON(c.Request.Context(), model.SystemConfigRedisHashKey, key, &config); err != nil {
return err
}
resolveStorageMigrationTasksOnDirectDriverUpdate(
c.Request.Context(),
tx,
@@ -287,6 +283,10 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate(
}
func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
if err := model.InvalidateSystemConfigCache(ctx, key); err != nil {
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
}
if key == model.ConfigKeyStorageConfig {
upload.ResetAccessCaches()
upload.PublishAccessCacheInvalidation(ctx)
@@ -83,17 +83,25 @@ func TestCreateSystemConfig(t *testing.T) {
t.Fatalf("failed to find system config in DB: %v", err)
}
// Verify Redis Cache
var redisConfig model.SystemConfig
err = db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, "custom_key", &redisConfig)
if err != nil {
t.Fatalf("failed to find system config in Redis: %v", err)
// Verify caches are invalidated after create and repopulate on read
_, err = db.Redis.HGet(
context.Background(),
db.PrefixedKey(model.SystemConfigRedisHashKey),
"custom_key",
).Result()
if err == nil {
t.Fatal("expected redis cache miss immediately after create")
}
if redisConfig.Value != "custom_value" {
t.Errorf("CreateSystemConfig(custom_key).Value = %q, want %q", redisConfig.Value, "custom_value")
var loaded model.SystemConfig
if err := loaded.GetByKey(context.Background(), "custom_key"); err != nil {
t.Fatalf("GetByKey(custom_key) error = %v", err)
}
if redisConfig.Visibility != model.ConfigVisibilityVisible {
t.Errorf("CreateSystemConfig(custom_key).Visibility = %d, want %d", redisConfig.Visibility, model.ConfigVisibilityVisible)
if loaded.Value != "custom_value" {
t.Errorf("GetByKey(custom_key).Value = %q, want %q", loaded.Value, "custom_value")
}
if loaded.Visibility != model.ConfigVisibilityVisible {
t.Errorf("GetByKey(custom_key).Visibility = %d, want %d", loaded.Visibility, model.ConfigVisibilityVisible)
}
})
@@ -234,14 +242,25 @@ func TestUpdateSystemConfig(t *testing.T) {
t.Errorf("database values not updated: %+v", cfg)
}
// Verify Redis
var redisConfig model.SystemConfig
_ = db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeySiteName, &redisConfig)
if redisConfig.Value != "Super Site Name" {
t.Errorf("redis cache value not updated, got '%s'", redisConfig.Value)
// Verify caches are invalidated after update and repopulate on read
_, err := db.Redis.HGet(
context.Background(),
db.PrefixedKey(model.SystemConfigRedisHashKey),
model.ConfigKeySiteName,
).Result()
if err == nil {
t.Fatal("expected redis cache miss immediately after update")
}
if redisConfig.Visibility != model.ConfigVisibilityHidden {
t.Errorf("redis cache visibility = %d, want %d", redisConfig.Visibility, model.ConfigVisibilityHidden)
var loaded model.SystemConfig
if err := loaded.GetByKey(context.Background(), model.ConfigKeySiteName); err != nil {
t.Fatalf("GetByKey(site_name) error = %v", err)
}
if loaded.Value != "Super Site Name" {
t.Errorf("GetByKey(site_name).Value = %q, want %q", loaded.Value, "Super Site Name")
}
if loaded.Visibility != model.ConfigVisibilityHidden {
t.Errorf("GetByKey(site_name).Visibility = %d, want %d", loaded.Visibility, model.ConfigVisibilityHidden)
}
})
@@ -0,0 +1,106 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"errors"
"testing"
"github.com/redis/go-redis/v9"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
)
func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := context.Background()
model.ResetSystemConfigRAMCacheForTest()
if err := model.InvalidateAllSystemConfigCaches(ctx); err != nil {
t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err)
}
var warm model.SystemConfig
if err := warm.GetByKey(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("GetByKey(site_name) warm error = %v", err)
}
if warm.Value != "Wavelet" {
t.Fatalf("GetByKey(site_name).Value = %q, want %q", warm.Value, "Wavelet")
}
if err := dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeySiteName).
Update("value", "ram_probe_value").Error; err != nil {
t.Fatalf("Update(site_name) error = %v", err)
}
if err := db.HDel(ctx, model.SystemConfigRedisHashKey, model.ConfigKeySiteName); err != nil {
t.Fatalf("HDel(site_name) error = %v", err)
}
var cached model.SystemConfig
if err := cached.GetByKey(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("GetByKey(site_name) cached error = %v", err)
}
if cached.Value != "Wavelet" {
t.Fatalf("GetByKey(site_name).Value = %q, want stale RAM value %q", cached.Value, "Wavelet")
}
if err := model.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err)
}
var refreshed model.SystemConfig
if err := refreshed.GetByKey(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("GetByKey(site_name) refreshed error = %v", err)
}
if refreshed.Value != "ram_probe_value" {
t.Fatalf("GetByKey(site_name).Value = %q, want %q", refreshed.Value, "ram_probe_value")
}
exists, err := db.Redis.HExists(ctx, db.PrefixedKey(model.SystemConfigRedisHashKey), model.ConfigKeySiteName).Result()
if err != nil {
t.Fatalf("HExists(site_name) error = %v", err)
}
if !exists {
t.Fatal("expected redis hash field to be repopulated after refresh")
}
}
func TestInvalidateSystemConfigCacheClearsRedisField(t *testing.T) {
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := context.Background()
var sc model.SystemConfig
if err := sc.GetByKey(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("GetByKey(site_name) error = %v", err)
}
if err := model.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err)
}
_, err := db.Redis.HGet(ctx, db.PrefixedKey(model.SystemConfigRedisHashKey), model.ConfigKeySiteName).Result()
if !errors.Is(err, redis.Nil) {
t.Fatalf("HGet(site_name) error = %v, want redis.Nil", err)
}
if err := dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeySiteName).
Update("value", "after_invalidate").Error; err != nil {
t.Fatalf("Update(site_name) error = %v", err)
}
var refreshed model.SystemConfig
if err := refreshed.GetByKey(ctx, model.ConfigKeySiteName); err != nil {
t.Fatalf("GetByKey(site_name) refreshed error = %v", err)
}
if refreshed.Value != "after_invalidate" {
t.Fatalf("GetByKey(site_name).Value = %q, want %q", refreshed.Value, "after_invalidate")
}
}
+2 -1
View File
@@ -4,7 +4,8 @@
package db
const (
errRedisHashSetFailed = "failed to set redis hash: %w"
errRedisHashSetFailed = "failed to set redis hash: %w"
errRedisHashDeleteFailed = "failed to delete redis hash field: %w"
errUnmarshalDataFailed = "failed to unmarshal data: %w"
errMarshalDataFailed = "failed to marshal data: %w"
errRedisKeySetFailed = "failed to set redis key: %w"
+1 -4
View File
@@ -69,10 +69,7 @@ func Migrate() {
}
func clearSystemConfigCache() {
if db.Redis == nil {
return
}
if err := db.Redis.Del(context.Background(), db.PrefixedKey(model.SystemConfigRedisHashKey)).Err(); err != nil {
if err := model.InvalidateAllSystemConfigCaches(context.Background()); err != nil {
log.Printf("[%s] clear system config cache failed: %v\n", dbType(), err)
}
}
+11
View File
@@ -124,6 +124,17 @@ func HSetJSON[T any](ctx context.Context, hashKey, fieldKey string, data T) erro
return nil
}
// HDel removes one or more fields from a Redis Hash.
func HDel(ctx context.Context, hashKey string, fieldKeys ...string) error {
if Redis == nil || len(fieldKeys) == 0 {
return nil
}
if err := Redis.HDel(ctx, PrefixedKey(hashKey), fieldKeys...).Err(); err != nil {
return fmt.Errorf(errRedisHashDeleteFailed, err)
}
return nil
}
// HGetJSON 从 Redis Hash 获取数据并反序列化为泛型类型
// ctx: 上下文
// hashKey: Redis Hash key
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
import (
"context"
"encoding/json"
"sync"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
)
const (
// SystemConfigInvalidationChannel broadcasts RAM cache eviction across nodes.
SystemConfigInvalidationChannel = "system:config_invalidation"
systemConfigInvalidateAllToken = "*"
systemConfigRAMMaximumSize = 512
)
type systemConfigInvalidationMessage struct {
Key string `json:"key"`
}
var (
systemConfigRAMCache = ram.MustNew[string, SystemConfig](ram.Options{MaximumSize: systemConfigRAMMaximumSize})
systemConfigListenerOnce sync.Once
)
func ensureSystemConfigCacheListener() {
systemConfigListenerOnce.Do(startSystemConfigCacheInvalidationListener)
}
func startSystemConfigCacheInvalidationListener() {
if db.Redis == nil {
return
}
go func() {
pubsub := db.Redis.Subscribe(context.Background(), SystemConfigInvalidationChannel)
defer func() {
_ = pubsub.Close()
}()
for msg := range pubsub.Channel() {
var payload systemConfigInvalidationMessage
if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil {
systemConfigRAMCache.InvalidateAll()
continue
}
if payload.Key == "" || payload.Key == systemConfigInvalidateAllToken {
systemConfigRAMCache.InvalidateAll()
continue
}
systemConfigRAMCache.Invalidate(payload.Key)
}
}()
}
func cloneSystemConfig(sc SystemConfig) SystemConfig {
return sc
}
func populateSystemConfigCache(ctx context.Context, sc SystemConfig) {
systemConfigRAMCache.Set(sc.Key, cloneSystemConfig(sc))
if db.Redis != nil {
_ = db.HSetJSON(ctx, SystemConfigRedisHashKey, sc.Key, &sc)
}
}
func publishSystemConfigRAMInvalidation(ctx context.Context, key string) {
if db.Redis == nil {
return
}
payload, err := json.Marshal(systemConfigInvalidationMessage{Key: key})
if err != nil {
return
}
_ = db.Redis.Publish(ctx, SystemConfigInvalidationChannel, payload).Err()
}
// InvalidateSystemConfigCache evicts one config key from local RAM and Redis.
// It also publishes cluster-wide RAM invalidation when Redis is available.
func InvalidateSystemConfigCache(ctx context.Context, key string) error {
ensureSystemConfigCacheListener()
systemConfigRAMCache.Invalidate(key)
if db.Redis != nil {
if err := db.HDel(ctx, SystemConfigRedisHashKey, key); err != nil {
return err
}
}
publishSystemConfigRAMInvalidation(ctx, key)
return nil
}
// InvalidateAllSystemConfigCaches evicts all config entries from local RAM and Redis.
// It also publishes cluster-wide RAM invalidation when Redis is available.
func InvalidateAllSystemConfigCaches(ctx context.Context) error {
ensureSystemConfigCacheListener()
systemConfigRAMCache.InvalidateAll()
if db.Redis != nil {
if err := db.Redis.Del(ctx, db.PrefixedKey(SystemConfigRedisHashKey)).Err(); err != nil {
return err
}
}
publishSystemConfigRAMInvalidation(ctx, systemConfigInvalidateAllToken)
return nil
}
// ResetSystemConfigRAMCacheForTest clears only the process-local RAM cache.
func ResetSystemConfigRAMCacheForTest() {
systemConfigRAMCache.InvalidateAll()
}
+10 -5
View File
@@ -81,10 +81,18 @@ func (SystemConfig) TableName() string {
return "w_system_configs"
}
// GetByKey 通过 key 查询配置(带 Redis 缓存)
// GetByKey 通过 key 查询配置(带 RAM + Redis 缓存)
func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error {
ensureSystemConfigCacheListener()
if cached, ok := systemConfigRAMCache.GetIfPresent(key); ok {
*sc = cloneSystemConfig(cached)
return nil
}
if db.Redis != nil {
if err := db.HGetJSON(ctx, SystemConfigRedisHashKey, key, sc); err == nil {
systemConfigRAMCache.Set(key, cloneSystemConfig(*sc))
return nil
} else if !errors.Is(err, redis.Nil) {
// Redis 服务错误,返回错误
@@ -102,10 +110,7 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error {
return err
}
// 更新 Redis Hash 缓存
if db.Redis != nil {
_ = db.HSetJSON(ctx, SystemConfigRedisHashKey, key, sc)
}
populateSystemConfigCache(ctx, *sc)
return nil
}
+1 -6
View File
@@ -208,12 +208,7 @@ func upsertSystemConfig(ctx context.Context, tx *gorm.DB, key string, value any,
FirstOrCreate(&sc).Error; err != nil {
return err
}
if db.Redis != nil {
if err := db.HSetJSON(ctx, model.SystemConfigRedisHashKey, key, &sc); err != nil {
return err
}
}
return nil
return model.InvalidateSystemConfigCache(ctx, key)
}
// MergeMaskedSecrets restores unchanged secrets from the current configuration.
+1
View File
@@ -75,6 +75,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
// Cleanup function
cleanup := func() {
model.ResetSystemConfigRAMCacheForTest()
_ = redisClient.Close()
mr.Close()
// Reset database and Redis references
-1
View File
@@ -1,4 +1,3 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package ram provides a thin wrapper around Otter v2 for process-local caching.
package ram
import (
"github.com/maypok86/otter/v2"
)
const defaultMaximumSize = 256
// Options configures a RAM cache instance.
type Options struct {
// MaximumSize bounds the number of entries. Zero uses a small default.
MaximumSize int
}
// Cache is a concurrency-safe in-memory cache backed by Otter.
type Cache[K comparable, V any] struct {
inner *otter.Cache[K, V]
}
// New creates a RAM cache from the provided options.
func New[K comparable, V any](opts Options) (*Cache[K, V], error) {
maximumSize := opts.MaximumSize
if maximumSize == 0 {
maximumSize = defaultMaximumSize
}
inner, err := otter.New(&otter.Options[K, V]{
MaximumSize: maximumSize,
})
if err != nil {
return nil, err
}
return &Cache[K, V]{inner: inner}, nil
}
// MustNew creates a RAM cache and panics when configuration is invalid.
func MustNew[K comparable, V any](opts Options) *Cache[K, V] {
cache, err := New[K, V](opts)
if err != nil {
panic(err)
}
return cache
}
// GetIfPresent returns the cached value when present.
func (c *Cache[K, V]) GetIfPresent(key K) (V, bool) {
return c.inner.GetIfPresent(key)
}
// Set stores a value in the cache.
func (c *Cache[K, V]) Set(key K, value V) {
c.inner.Set(key, value)
}
// Invalidate removes one entry from the cache.
func (c *Cache[K, V]) Invalidate(key K) {
c.inner.Invalidate(key)
}
// InvalidateAll removes every entry from the cache.
func (c *Cache[K, V]) InvalidateAll() {
c.inner.InvalidateAll()
}
// EstimatedSize returns the approximate number of cached entries.
func (c *Cache[K, V]) EstimatedSize() int {
return c.inner.EstimatedSize()
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package ram
import "testing"
func TestCacheSetGetInvalidate(t *testing.T) {
cache := MustNew[string, int](Options{MaximumSize: 8})
cache.Set("count", 3)
got, ok := cache.GetIfPresent("count")
if !ok {
t.Fatal("GetIfPresent(count) ok = false, want true")
}
if got != 3 {
t.Fatalf("GetIfPresent(count) = %d, want %d", got, 3)
}
cache.Invalidate("count")
if _, ok := cache.GetIfPresent("count"); ok {
t.Fatal("GetIfPresent(count) after Invalidate ok = true, want false")
}
}
func TestCacheInvalidateAll(t *testing.T) {
cache := MustNew[string, string](Options{MaximumSize: 8})
cache.Set("a", "1")
cache.Set("b", "2")
cache.InvalidateAll()
if cache.EstimatedSize() != 0 {
t.Fatalf("EstimatedSize() after InvalidateAll = %d, want 0", cache.EstimatedSize())
}
}