Files
configcenter/config-center-etcd-implementation.md

18 KiB
Raw Blame History

自建配置中心实现方案etcd + PostgreSQL + Redis + Go

对应架构:

                  Web Console
                       │
                       ▼
                Config Server (Go)
                       │
        ┌──────────────┼──────────────┐
        │              │              │
     PostgreSQL       etcd         Redis
   配置/版本/RBAC     生效配置       缓存/事件
   发布记录/审计       Watch

一、核心设计原则

PostgreSQL 是控制面source of truthetcd 是数据面(运行时分发)。

配置的"编辑态"(草稿)永远只存在 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_outboxDB 提交即成功返回给用户)
   │
   ▼
Outbox Worker异步、可重试
   │
   ▼
etcd.Put(key, snapshot)  ──成功──▶ 回写 etcd_revisionrelease.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

不直接连 etcdetcd 官方不维护 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 表结构是对齐的,接入真实后端时只需要:

  1. 把内存里的 useState 数据源换成对 grpc-gateway 生成的 REST 接口的 fetch 调用;
  2. 发布按钮调用 POST /v1/publish,历史列表调用 GET /v1/releases,回滚调用 POST /v1/rollback
  3. 因为发布是异步落 etcdoutboxWeb Console 发布后可以轮询 release 的 status 字段pending → applied或者用 Redis pub/sub + SSE 做实时状态推送。

十、部署与运维要点

  • etcd 独立部署3 或 5 节点集群,不与业务系统共享,专门服务配置分发;
  • 控制 etcd 数据量:只放"当前生效配置",历史/审计一律留在 PGetcd 库大小尽量控制在几个 GB 以内;
  • 定期 compact + defragetcd 的 MVCC 历史 revision 会持续占用磁盘,需要定期 etcdctl compactdefrag
  • 监控etcd 自带 /metricsPrometheus重点关注 db 大小、leader 变更次数、watch 连接数Config Server 侧关注发布成功率、outbox 积压量、gRPC 延迟;
  • 无状态水平扩展Config Server 本身无状态,可以随意加实例,配合前面的 WatchHub 设计,不会导致 etcd 连接数暴涨;
  • 灾备etcd 定期 snapshot savePostgreSQL 走常规主从 + 定期备份;
  • 安全etcd 只对 Config Server 开放访问不直接暴露给业务服务Config Server 与 etcd 之间开 TLS。

十一、分阶段落地路线图

阶段 内容 周期建议
Phase 1 PG 建表 + CRUD REST API + Web Console 接入真实接口(发布先只落 PG不接 etcd 23 周
Phase 2 接入 etcdOutbox Worker、GetConfig 接口、Go/Python SDK先只支持 Get不支持 Watch 2 周
Phase 3 WatchConfig 全链路WatchHub 扇出、SDK 长连接自动重连、SDK 本地文件缓存兜底Server/etcd 故障时业务进程仍能用最后一次缓存启动这是必须做的Apollo/Nacos 都有) 2 周
Phase 4 RBAC应用级角色、审计日志、灰度发布规则 23 周
Phase 5 高可用加固etcd 多机房、监控告警、压测 持续

Phase 1 完全可以复用你现在这份前端 Demo 直接改造,不用重写界面。