Skip to content

持久化存储 ​

wicket 的授权码、刷新令牌、会话、密钥等状态都通过存储端口读写。本页说明不持久化会出什么问题,以及如何用官方 PostgreSQL 适配器 github.com/gonewx/wicket-pg 完成生产装配。

为什么需要 ​

端口族存的是什么只放在进程内存里的后果
storage授权码、刷新令牌、引用令牌、用户同意、持久化授权、设备码、CIBA 请求重启后已签发的刷新令牌全部失效;多副本下授权码在 A 副本签发、换令牌请求打到 B 副本时查不到,用户看到随机失败
session服务端会话记录重启即全员登出;多副本间会话不共享
keymgmt自动管理的签名密钥每次重启换钥,已签发的 JWT 无法验签;各副本各自建钥

只使用无状态验签的宿主不需要本页。 oidcverify、wechatverify、phoneverify 三个包不依赖任何存储;如果你只是“把外部令牌验签后换成本地身份”,不需要装配授权服务器,也就不需要存储。见 本地验签与服务端交换。

11 个存储注入点 ​

cfg.Freeze() 校验以下 11 个 seam 是否都已注入:

seam注入方法wicket-pg 是否提供
authorization code storeSetAuthorizationCodeStore是:store.NewAuthorizationCodeStore
refresh token storeSetRefreshTokenStore是:store.NewRefreshTokenStore
reference token storeSetReferenceTokenStore是:store.NewReferenceTokenStore
user consent storeSetUserConsentStore是:store.NewUserConsentStore
persisted grant storeSetPersistedGrantStore是:store.NewPersistedGrantStore
device flow storeSetDeviceFlowStore是:store.NewDeviceFlowStore
backchannel authentication request storeSetBackchannelAuthenticationRequestStore是:store.NewBackchannelAuthenticationRequestStore
session storeSetSessionStore是:store.NewSessionStore
key record storeSetRecordStore是:store.NewKeyRecordStore
authorization parameters message storeSetAuthorizationParametersMessageStore[Provider]否,宿主自行处理
distributed cacheSetDistributedCache[Provider]否,宿主自行处理

缺任何一个,又没有调用 cfg.AllowInMemoryFallback(),Freeze() 就会 panic:

text
panic: store seam "authorization parameters message store" not injected by host and in-memory fallback not declared

所以只注入 wicket-pg 的 9 个 store、不处理剩下两个,Freeze() 必然失败。处理方法见下文。

开发环境:内存回退 ​

go
cfg.AllowInMemoryFallback() // 仅限本地开发,必须在 Freeze 之前调用
cfg.Freeze()

声明回退后,未注入的 store 在首次使用时换成进程内存实现,并输出一条 warn 日志。生产装配不要调用它:它对所有未注入的 seam 一视同仁,会把“忘了注入某个 store”变成一个只在多副本或重启后才暴露的隐患。

官方 PostgreSQL 适配器 ​

github.com/gonewx/wicket-pg 实现了上表中的 9 个 store,自带迁移。要求 PostgreSQL 15+、Go 1.27。当前版本 v0.1.3,依赖 wicket v0.1.4。

bash
GOWORK=off go get github.com/gonewx/wicket-pg@v0.1.3

1. 执行迁移 ​

迁移随适配器分发,表的所有权属于适配器。宿主在启动序列中显式调用;migrations.Up 幂等(已应用的版本记录在 schema_migrations 表中),不会自动触发:

go
import (
	"github.com/gonewx/wicket-pg/migrations"
	"github.com/jackc/pgx/v5/pgxpool"
)

pool, err := pgxpool.New(ctx, dsn) // 连接池由宿主创建和关闭
if err != nil {
	log.Fatal(err)
}
defer pool.Close()

if err := migrations.Up(ctx, pool); err != nil {
	log.Fatal(err)
}

需要回滚时调用 migrations.Down(ctx, pool),它会回滚全部已应用的版本。

2. 构造并注入 ​

每个构造器签名都是 store.NewXxxStore(pool *pgxpool.Pool, logger *slog.Logger),logger 传 nil 时使用 slog.Default()。建议把适配器保存在一个结构体里——清理循环和合规校验都要用到它们的具体类型:

go
import (
	"github.com/gonewx/wicket-pg/store"
	"github.com/gonewx/wicket/di"
	servicesmemory "github.com/gonewx/wicket/services/memory"
	"github.com/gonewx/wicket/storage"
)

type pgStores struct {
	authorizationCode *store.AuthorizationCodeStore
	refreshToken      *store.RefreshTokenStore
	referenceToken    *store.ReferenceTokenStore
	userConsent       *store.UserConsentStore
	persistedGrant    *store.PersistedGrantStore
	deviceFlow        *store.DeviceFlowStore
	backchannel       *store.BackchannelAuthenticationRequestStore
	session           *store.SessionStore
	keyRecord         *store.KeyRecordStore
}

func newPGStores(pool *pgxpool.Pool, logger *slog.Logger) *pgStores {
	return &pgStores{
		authorizationCode: store.NewAuthorizationCodeStore(pool, logger),
		refreshToken:      store.NewRefreshTokenStore(pool, logger),
		referenceToken:    store.NewReferenceTokenStore(pool, logger),
		userConsent:       store.NewUserConsentStore(pool, logger),
		persistedGrant:    store.NewPersistedGrantStore(pool, logger),
		deviceFlow:        store.NewDeviceFlowStore(pool, logger),
		backchannel:       store.NewBackchannelAuthenticationRequestStore(pool, logger),
		session:           store.NewSessionStore(pool, logger),
		keyRecord:         store.NewKeyRecordStore(pool, logger),
	}
}

func (s *pgStores) inject(cfg *di.ServerConfig) {
	cfg.SetAuthorizationCodeStore(s.authorizationCode)
	cfg.SetRefreshTokenStore(s.refreshToken)
	cfg.SetReferenceTokenStore(s.referenceToken)
	cfg.SetUserConsentStore(s.userConsent)
	cfg.SetPersistedGrantStore(s.persistedGrant)
	cfg.SetDeviceFlowStore(s.deviceFlow)
	cfg.SetBackchannelAuthenticationRequestStore(s.backchannel)
	cfg.SetSessionStore(s.session)
	cfg.SetRecordStore(s.keyRecord) // 注意方法名是 SetRecordStore

	// wicket-pg 不覆盖的两个 seam,显式声明(理由见下节)
	cfg.SetAuthorizationParametersMessageStoreProvider(func() storage.AuthorizationParametersMessageStore {
		return nil // 不使用参数消息存储:授权参数直接编码进登录页的 returnUrl
	})
	cfg.SetDistributedCache(servicesmemory.NewMemoryDistributedCache())
}
go
// 示意:启动序列中的调用顺序
pg := newPGStores(pool, logger)
cfg := root.ServerConfig()
pg.inject(cfg)
// ... 客户端、资源、签名密钥 ...
cfg.Freeze() // 不调用 AllowInMemoryFallback 也能通过

上面这套装配已在 v0.1.4 + wicket-pg v0.1.3 + PostgreSQL 16 上实测:不声明内存回退时 Freeze() 通过,迁移重复执行无副作用,令牌签发、内省与清理均正常。

3. 剩下两个 seam 怎么处理 ​

授权参数消息存储(storage.AuthorizationParametersMessageStore):可选组件。注入时,wicket 把授权请求参数存进去,只把一个句柄放进登录页的 returnUrl;为 nil 时,参数直接以查询串形式编码进 returnUrl。wicket 没有提供它的内存实现——开发环境走内存回退时它本来就是 nil。用一个返回 nil 的 provider 注入,行为与开发环境一致,同时满足 Freeze 的校验。如果授权请求参数很长、returnUrl 超出你的网关限制,再自行实现该接口(Write / Read / Delete 三个方法),并由宿主负责关闭它——容器不会释放这个 seam。

分布式缓存(services.DistributedCache):接口只有 Get、Set、Remove 三个方法。v0.1.4 中它只用于设备码轮询的限速。注入 services/memory.NewMemoryDistributedCache() 时,每个副本各有一份,限速按副本独立计算;如果你要在多副本下严格执行设备码限速,可用 Redis 等实现该接口后注入。

4. 校验适配器合规性(可选) ​

每个 wicket-pg store 都暴露 ConformsTo(),返回它所通过的契约测试套件版本。启动时与 wicket 的套件版本比对,可以防止适配器与 wicket 版本错配:

go
import (
	"github.com/gonewx/wicket/keymgmt/keymgmttest"
	"github.com/gonewx/wicket/session/sessiontest"
	"github.com/gonewx/wicket/storage/storagetest"
)

func (s *pgStores) checkConformance() error {
	checks := []struct{ name, got, want string }{
		{"authorization code", s.authorizationCode.ConformsTo(), storagetest.SuiteVersion},
		{"refresh token", s.refreshToken.ConformsTo(), storagetest.SuiteVersion},
		{"reference token", s.referenceToken.ConformsTo(), storagetest.SuiteVersion},
		{"user consent", s.userConsent.ConformsTo(), storagetest.SuiteVersion},
		{"persisted grant", s.persistedGrant.ConformsTo(), storagetest.SuiteVersion},
		{"device flow", s.deviceFlow.ConformsTo(), storagetest.SuiteVersion},
		{"backchannel", s.backchannel.ConformsTo(), storagetest.SuiteVersion},
		{"session", s.session.ConformsTo(), sessiontest.SuiteVersion},
		{"key record", s.keyRecord.ConformsTo(), keymgmttest.SuiteVersion},
	}
	for _, c := range checks {
		if c.got != c.want {
			return fmt.Errorf("adapter %s conforms to %q, wicket requires %q", c.name, c.got, c.want)
		}
	}
	return nil
}

过期清理由宿主驱动 ​

wicket 不启动后台 goroutine,也没有一个统一的 root.CleanupExpired 方法。过期判定始终在 wicket 核心侧完成(适配器不会在查询里按时间过滤),清理只是回收空间、不影响正确性。回收入口在各个 store 上:

  • storage 族的 7 个 store:RemoveExpired(ctx, cutoff time.Time) (int, error)
  • 会话 store:DeleteExpired(ctx, cutoff time.Time) (int, error)
  • 密钥记录 store:接口上没有按时间回收的方法,密钥管理器也不会删除记录;退役(retired)的密钥记录会一直保留,如需清理请宿主自行处理
go
type expirer interface {
	RemoveExpired(ctx context.Context, cutoff time.Time) (int, error)
}

func (s *pgStores) cleanup(ctx context.Context, now time.Time, logger *slog.Logger) {
	stores := map[string]expirer{
		"authorization_code": s.authorizationCode,
		"refresh_token":      s.refreshToken,
		"reference_token":    s.referenceToken,
		"user_consent":       s.userConsent,
		"persisted_grant":    s.persistedGrant,
		"device_flow":        s.deviceFlow,
		"backchannel":        s.backchannel,
	}
	for name, st := range stores {
		n, err := st.RemoveExpired(ctx, now)
		if err != nil {
			logger.Error("cleanup failed", "store", name, "err", err)
			continue
		}
		logger.Debug("cleanup done", "store", name, "removed", n)
	}
	if n, err := s.session.DeleteExpired(ctx, now); err != nil {
		logger.Error("cleanup failed", "store", "session", "err", err)
	} else {
		logger.Debug("cleanup done", "store", "session", "removed", n)
	}
}

// runCleanup 在宿主自己的 goroutine 中运行,ctx 取消即退出。
func runCleanup(ctx context.Context, s *pgStores, every time.Duration, logger *slog.Logger) {
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case now := <-t.C:
			cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
			s.cleanup(cctx, now, logger)
			cancel()
		}
	}
}
go
// 示意:在启动序列中启动清理循环
cleanupCtx, cancelCleanup := context.WithCancel(context.Background())
go runCleanup(cleanupCtx, pg, 10*time.Minute, logger)
// 关闭时:cancelCleanup()

多副本部署时每个副本都跑清理也是安全的(删除操作幂等),但会产生重复的数据库负载;可以只在一个副本上运行,或用数据库咨询锁做选主。

资源释放 ​

  • 连接池归宿主所有。 wicket-pg 的 store 不实现 Close,root.Close() 不会关闭 pgxpool.Pool。宿主在 root.Close() 之后自行 pool.Close()。
  • 宿主自己实现的 store 如果实现了 io.Closer,root.Close() 会按逆序调用它的 Close(授权参数消息存储除外,它始终由宿主关闭)。

关闭顺序见 嵌入宿主应用。

编写其他介质的适配器 ​

需要 MySQL、Redis 等其他介质时,请按契约测试套件实现端口,而不是在宿主里随手写一份。要点(不存在与故障必须可区分、写入默认 insert-only、版本号并发控制、过期判定留在核心侧等)见 编写存储适配器 与 存储端口。

相关阅读 ​

以 Apache License 2.0 发布 · 文档内容 © 2026 Wicket 作者