18 KiB
自建配置中心实现方案(etcd + PostgreSQL + Redis + Go)
对应架构:
Web Console
│
▼
Config Server (Go)
│
┌──────────────┼──────────────┐
│ │ │
PostgreSQL etcd Redis
配置/版本/RBAC 生效配置 缓存/事件
发布记录/审计 Watch
一、核心设计原则
PostgreSQL 是控制面(source of truth),etcd 是数据面(运行时分发)。
配置的"编辑态"(草稿)永远只存在 PostgreSQL 里;只有点了「发布」,才会把该 namespace 当前生效的完整配置写进 etcd。etcd 里任何时刻只保存"当前生效"的那一份,历史版本、审计、RBAC 全部留在 PG。
这样划分带来两个好处:
- etcd 数据量可控(etcd 官方建议库大小控制在几个 GB 内,不适合塞历史/审计数据);
- 复杂查询(按部门筛选应用、按时间查审计日志)交给关系型数据库,etcd 只做它最擅长的事:强一致 + Watch。
二、发布语义与一致性(Outbox 模式)
发布动作要跨两个存储写入,如果直接「先写 PG 再写 etcd」,中间进程崩溃会导致状态不一致(PG 说已发布,etcd 里其实是旧值)。
解决办法:Outbox 模式——发布在同一个 PG 事务里写入 releases 记录和 release_outbox 记录;一个独立的异步 worker 轮询 outbox,把内容幂等地 Put 进 etcd,成功后回写 etcd_revision 并标记 applied。这样即使 Config Server 中途重启,未完成的发布也能被 worker 续上,不会丢失,也不会重复产生副作用(Put 本身是幂等的)。
发布请求
│
▼
PG 事务:写 releases + release_outbox(DB 提交即成功返回给用户)
│
▼
Outbox Worker(异步、可重试)
│
▼
etcd.Put(key, snapshot) ──成功──▶ 回写 etcd_revision,release.status = applied
└─失败──▶ retry_count++,下一轮重试
三、etcd Key 设计
推荐按 namespace 整体做一个 blob,而不是每个 key 单独存一条,理由:一次发布往往同时改多个 key,整体 blob 能保证客户端拿到的永远是"某个版本的完整快照",不会出现 watch 到一半、配置项之间不一致的中间态。
/config/{env}/{app}/{namespace} -> JSON: {"server.port":"8080", "log.level":"INFO", ...}
/config/{env}/{app}/{namespace}/__meta -> JSON: {"version":12,"releaseId":88,"publishedAt":"...","publishedBy":"admin"}
客户端只 watch /config/{env}/{app}/{namespace} 这一个 key,收到事件后解码出完整 map,本地直接替换。
四、PostgreSQL 数据模型
CREATE TABLE applications (
id BIGSERIAL PRIMARY KEY,
app_code VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(128) NOT NULL,
department VARCHAR(128),
owner VARCHAR(64),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE environments (
id BIGSERIAL PRIMARY KEY,
env_code VARCHAR(32) UNIQUE NOT NULL, -- DEV/TEST/STAGING/PROD
name VARCHAR(64) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
description TEXT
);
CREATE TABLE namespaces (
id BIGSERIAL PRIMARY KEY,
app_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
name VARCHAR(64) NOT NULL,
format VARCHAR(16) NOT NULL DEFAULT 'properties', -- properties/yaml/json/xml
description TEXT,
UNIQUE(app_id, name)
);
-- 草稿态配置项:value 是编辑中的值,released_value 是最近一次发布生效的值
CREATE TABLE config_items (
id BIGSERIAL PRIMARY KEY,
namespace_id BIGINT NOT NULL REFERENCES namespaces(id) ON DELETE CASCADE,
env_id BIGINT NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
key VARCHAR(256) NOT NULL,
value TEXT NOT NULL,
released_value TEXT, -- NULL 表示从未发布过(待发布·新增)
pending_delete BOOLEAN NOT NULL DEFAULT false,
comment TEXT,
updated_by VARCHAR(64),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(namespace_id, env_id, key)
);
CREATE TABLE releases (
id BIGSERIAL PRIMARY KEY,
namespace_id BIGINT NOT NULL REFERENCES namespaces(id),
env_id BIGINT NOT NULL REFERENCES environments(id),
version INT NOT NULL,
snapshot JSONB NOT NULL, -- 该版本完整的 key -> value
diff_added INT DEFAULT 0,
diff_modified INT DEFAULT 0,
diff_removed INT DEFAULT 0,
comment TEXT,
operator VARCHAR(64) NOT NULL,
etcd_revision BIGINT, -- 写入 etcd 成功后回填,用于对账
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/applied/failed
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(namespace_id, env_id, version)
);
-- Outbox:保证 DB 事务与 etcd 写入之间的最终一致
CREATE TABLE release_outbox (
id BIGSERIAL PRIMARY KEY,
release_id BIGINT NOT NULL REFERENCES releases(id),
etcd_key VARCHAR(512) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/done/failed
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
display_name VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(32) UNIQUE NOT NULL -- admin / app-owner / viewer
);
CREATE TABLE user_app_roles ( -- 用户对某个应用的角色(应用级 RBAC)
user_id BIGINT NOT NULL REFERENCES users(id),
app_id BIGINT NOT NULL REFERENCES applications(id),
role_id BIGINT NOT NULL REFERENCES roles(id),
PRIMARY KEY (user_id, app_id)
);
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
actor VARCHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL, -- create/update/delete/publish/rollback
target_type VARCHAR(32) NOT NULL, -- app/namespace/env/config/release
target_id BIGINT,
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 灰度发布规则(可选,Phase 4 再做)
CREATE TABLE gray_rules (
id BIGSERIAL PRIMARY KEY,
namespace_id BIGINT NOT NULL REFERENCES namespaces(id),
env_id BIGINT NOT NULL REFERENCES environments(id),
rule_type VARCHAR(16) NOT NULL, -- ip / instance / percentage
rule_value JSONB NOT NULL, -- {"ips": [...]} 或 {"percentage": 20}
overrides JSONB NOT NULL, -- 灰度期间覆盖的 key -> value
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
五、Protobuf / gRPC 接口定义
建议用 grpc-gateway 从同一份 proto 同时生成 gRPC(给 Go/Python SDK 用)和 REST(给 Web Console 用),避免维护两套接口。
syntax = "proto3";
package configcenter.v1;
option go_package = "github.com/yourorg/configcenter/pkg/proto/v1;configcenterv1";
message ConfigItem {
string key = 1;
string value = 2;
}
// ---- 读取 / 监听 ----
message GetConfigRequest {
string env = 1;
string app = 2;
string namespace = 3;
}
message GetConfigResponse {
repeated ConfigItem items = 1;
int64 revision = 2;
int64 release_version = 3;
}
message WatchConfigRequest {
string env = 1;
string app = 2;
string namespace = 3;
int64 start_revision = 4; // 断线重连时从该 revision 继续,避免错过中间事件
}
message ConfigEvent {
enum EventType { FULL_SYNC = 0; UPDATED = 1; }
EventType type = 1;
repeated ConfigItem items = 2;
int64 revision = 3;
}
service ConfigService {
rpc GetConfig(GetConfigRequest) returns (GetConfigResponse);
rpc WatchConfig(WatchConfigRequest) returns (stream ConfigEvent);
}
// ---- 发布 / 回滚(管理面,Web Console 也走这里)----
message PublishRequest {
string env = 1;
string app = 2;
string namespace = 3;
string comment = 4;
string operator = 5;
}
message PublishResponse {
int64 release_version = 1;
int64 etcd_revision = 2;
}
message RollbackRequest {
string env = 1;
string app = 2;
string namespace = 3;
int64 target_version = 4;
string operator = 5;
}
message RollbackResponse {
int64 new_release_version = 1;
}
service AdminService {
rpc PublishConfig(PublishRequest) returns (PublishResponse);
rpc RollbackConfig(RollbackRequest) returns (RollbackResponse);
// 应用/命名空间/环境/配置项的 CRUD 接口类似,此处略
}
六、Go Config Server 实现要点
目录结构
configcenter/
├── cmd/server/main.go
├── internal/
│ ├── api/ # gRPC handler + grpc-gateway REST
│ ├── service/ # 发布 / 回滚 / watch 扇出等业务逻辑
│ ├── store/
│ │ ├── postgres/ # repository(推荐 sqlc 生成,类型安全)
│ │ └── etcdstore/ # etcd client 封装
│ ├── outbox/ # outbox worker
│ ├── cache/ # redis 封装(读缓存 + 内部事件)
│ └── auth/ # JWT + RBAC 中间件
├── pkg/
│ ├── proto/v1/ # protoc 生成代码
│ └── sdk/go/ # Go SDK,独立 module,方便业务方单独引用
└── web/ # Web Console 前端
发布流程
func (s *ConfigService) Publish(ctx context.Context, req *PublishRequest) (*PublishResponse, error) {
return s.db.WithTx(ctx, func(tx *sql.Tx) (*PublishResponse, error) {
items, err := s.repo.ListConfigItems(tx, req.NamespaceID, req.EnvID)
if err != nil {
return nil, err
}
pending := filterPending(items) // pendingDelete || releasedValue==nil || value!=releasedValue
if len(pending) == 0 {
return nil, ErrNoPendingChanges
}
version := s.repo.NextReleaseVersion(tx, req.NamespaceID, req.EnvID)
snapshot := buildSnapshot(items) // 去掉 pendingDelete 的 key
releaseID, err := s.repo.InsertRelease(tx, version, snapshot, req.Comment, req.Operator)
if err != nil {
return nil, err
}
// 关键:写 outbox 而不是直接写 etcd,和 DB 事务在同一个提交单元里
etcdKey := buildEtcdKey(req.Env, req.App, req.Namespace)
if err := s.repo.InsertOutbox(tx, releaseID, etcdKey, snapshot); err != nil {
return nil, err
}
s.repo.MarkConfigItemsReleased(tx, pending)
return &PublishResponse{ReleaseVersion: version}, nil
})
}
Outbox Worker(异步写 etcd,失败可重试)
func (w *OutboxWorker) Run(ctx context.Context) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
rows, _ := w.repo.FetchPendingOutbox(ctx, 50)
for _, row := range rows {
payload, _ := json.Marshal(row.Payload)
resp, err := w.etcd.Put(ctx, row.EtcdKey, string(payload))
if err != nil {
w.repo.MarkOutboxFailed(ctx, row.ID) // retry_count++
continue
}
w.repo.MarkOutboxDone(ctx, row.ID)
w.repo.UpdateReleaseRevision(ctx, row.ReleaseID, resp.Header.Revision, "applied")
}
}
}
}
Watch 扇出(避免 N 个 Server 实例 = N 倍 etcd watch 连接)
type WatchHub struct {
mu sync.RWMutex
subs map[string]map[chan *pb.ConfigEvent]struct{}
etcd *clientv3.Client
}
func (h *WatchHub) Subscribe(key string) (<-chan *pb.ConfigEvent, func()) {
ch := make(chan *pb.ConfigEvent, 8)
h.mu.Lock()
if h.subs[key] == nil {
h.subs[key] = map[chan *pb.ConfigEvent]struct{}{}
go h.watchKey(key) // 同一个 key 只建一条 etcd watch
}
h.subs[key][ch] = struct{}{}
h.mu.Unlock()
return ch, func() { h.unsubscribe(key, ch) }
}
func (h *WatchHub) watchKey(key string) {
wc := h.etcd.Watch(context.Background(), key)
for resp := range wc {
for _, ev := range resp.Events {
evt := &pb.ConfigEvent{Type: pb.ConfigEvent_UPDATED, Items: decode(ev.Kv.Value), Revision: ev.Kv.ModRevision}
h.broadcast(key, evt)
}
}
}
gRPC 层只需要订阅 Hub 并转发给客户端流:
func (s *ConfigService) WatchConfig(req *pb.WatchConfigRequest, stream pb.ConfigService_WatchConfigServer) error {
key := buildEtcdKey(req.Env, req.App, req.Namespace)
initial, rev := s.loadCurrent(key)
if err := stream.Send(&pb.ConfigEvent{Type: pb.ConfigEvent_FULL_SYNC, Items: initial, Revision: rev}); err != nil {
return err
}
ch, cancel := s.hub.Subscribe(key)
defer cancel()
for {
select {
case evt := <-ch:
if err := stream.Send(evt); err != nil {
return err
}
case <-stream.Context().Done():
return nil
}
}
}
回滚
回滚不是"覆盖旧记录",而是生成一条新的 release(内容等于目标历史版本的 snapshot),走和发布完全一样的 outbox 流程——这样回滚本身也留痕,历史可追溯。
七、Go SDK
package configsdk
type Client struct {
stub pb.ConfigServiceClient
cache sync.Map // namespace -> map[string]string
}
func New(addr string) (*Client, error) {
conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
return &Client{stub: pb.NewConfigServiceClient(conn)}, nil
}
func (c *Client) GetString(namespace, key, defaultVal string) string {
if m, ok := c.cache.Load(namespace); ok {
if v, ok := m.(map[string]string)[key]; ok {
return v
}
}
return defaultVal
}
// 后台常驻 goroutine,断线自动重连
func (c *Client) WatchAndSync(ctx context.Context, env, app, namespace string) {
for {
stream, err := c.stub.WatchConfig(ctx, &pb.WatchConfigRequest{Env: env, App: app, Namespace: namespace})
if err == nil {
for {
evt, err := stream.Recv()
if err != nil {
break
}
c.applyEvent(namespace, evt)
}
}
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second): // 重连退避
}
}
}
八、Python SDK
不直接连 etcd(etcd 官方不维护 Python client),而是通过 gRPC 连 Go Config Server:
import grpc
import threading
import time
from configcenter.v1 import config_pb2, config_pb2_grpc
class ConfigClient:
def __init__(self, addr: str):
self._channel = grpc.insecure_channel(addr)
self._stub = config_pb2_grpc.ConfigServiceStub(self._channel)
self._cache = {}
self._lock = threading.Lock()
def get(self, namespace: str, key: str, default=None):
with self._lock:
return self._cache.get(namespace, {}).get(key, default)
def start_background_watch(self, env: str, app: str, namespace: str):
t = threading.Thread(target=self._watch_loop, args=(env, app, namespace), daemon=True)
t.start()
def _watch_loop(self, env, app, namespace):
req = config_pb2.WatchConfigRequest(env=env, app=app, namespace=namespace)
while True:
try:
for event in self._stub.WatchConfig(req):
with self._lock:
self._cache[namespace] = {i.key: i.value for i in event.items}
except grpc.RpcError:
time.sleep(2) # 断线重连
九、Web Console 对接方式
之前给你做的前端 Demo(应用/命名空间/环境/配置项 CRUD + 发布 diff 预览 + 发布历史回滚)里的数据结构和这里的 PG 表结构是对齐的,接入真实后端时只需要:
- 把内存里的
useState数据源换成对grpc-gateway生成的 REST 接口的fetch调用; - 发布按钮调用
POST /v1/publish,历史列表调用GET /v1/releases,回滚调用POST /v1/rollback; - 因为发布是异步落 etcd(outbox),Web Console 发布后可以轮询 release 的
status字段(pending → applied),或者用 Redis pub/sub + SSE 做实时状态推送。
十、部署与运维要点
- etcd 独立部署:3 或 5 节点集群,不与业务系统共享,专门服务配置分发;
- 控制 etcd 数据量:只放"当前生效配置",历史/审计一律留在 PG,etcd 库大小尽量控制在几个 GB 以内;
- 定期 compact + defrag:etcd 的 MVCC 历史 revision 会持续占用磁盘,需要定期
etcdctl compact和defrag; - 监控:etcd 自带
/metrics(Prometheus),重点关注 db 大小、leader 变更次数、watch 连接数;Config Server 侧关注发布成功率、outbox 积压量、gRPC 延迟; - 无状态水平扩展:Config Server 本身无状态,可以随意加实例,配合前面的 WatchHub 设计,不会导致 etcd 连接数暴涨;
- 灾备:etcd 定期
snapshot save;PostgreSQL 走常规主从 + 定期备份; - 安全:etcd 只对 Config Server 开放访问(不直接暴露给业务服务),Config Server 与 etcd 之间开 TLS。
十一、分阶段落地路线图
| 阶段 | 内容 | 周期建议 |
|---|---|---|
| Phase 1 | PG 建表 + CRUD REST API + Web Console 接入真实接口(发布先只落 PG,不接 etcd) | 2–3 周 |
| Phase 2 | 接入 etcd:Outbox Worker、GetConfig 接口、Go/Python SDK(先只支持 Get,不支持 Watch) | 2 周 |
| Phase 3 | WatchConfig 全链路:WatchHub 扇出、SDK 长连接自动重连、SDK 本地文件缓存兜底(Server/etcd 故障时业务进程仍能用最后一次缓存启动,这是必须做的,Apollo/Nacos 都有) | 2 周 |
| Phase 4 | RBAC(应用级角色)、审计日志、灰度发布规则 | 2–3 周 |
| Phase 5 | 高可用加固:etcd 多机房、监控告警、压测 | 持续 |
Phase 1 完全可以复用你现在这份前端 Demo 直接改造,不用重写界面。