Skip to content

数据存储

PostgreSQL 16 是事实源 · Valkey 8 只做缓存/限流/短锁 · COS 存文件和备份


存储全景

text
api-server / admin-api / worker

       ├── PostgreSQL 16
       │   ├── users / products / inventory / orders / payments
       │   ├── outbox_events
       │   ├── idempotency_keys
       │   └── audit_logs

       ├── Valkey 8
       │   ├── token version / blacklist
       │   ├── rate limit
       │   ├── short locks
       │   └── hot cache

       └── COS + CDN
           ├── 商品图片
           ├── 检疫证明
           ├── 评价图片
           └── PostgreSQL 备份

原则:Valkey 丢失不能导致订单、支付、库存事实丢失。所有不可丢数据必须落 PostgreSQL。


数据 Ownership

模块Owner 表
authuser_credentials, admin_accounts, admin_roles, admin_permissions
userusers, user_addresses, points_logs
catalogproducts, categories, trace_records
inventoryproduct_batches, inventory, inventory_locks, inventory_movements
orderorders, order_items, after_sales, reviews
paymentpayments, payment_notifications, refunds
couponcoupon_templates, user_coupons
deliverydelivery_tasks
assetassets
sharedoutbox_events, outbox_consumed, idempotency_keys, audit_logs

非 owner 模块不能直接写其他模块的表。跨模块写入必须调用 owner service。


用户与认证

sql
CREATE TABLE users (
    id             BIGSERIAL PRIMARY KEY,
    nickname       VARCHAR(64),
    avatar_url     VARCHAR(512),
    phone          VARCHAR(256),              -- 加密值
    phone_hash     VARCHAR(128),              -- 查询用 hash
    member_level   VARCHAR(32) NOT NULL DEFAULT 'free',
    points         INT NOT NULL DEFAULT 0,
    status         VARCHAR(32) NOT NULL DEFAULT 'active',
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_users_phone_hash ON users(phone_hash) WHERE phone_hash IS NOT NULL;

CREATE TABLE user_credentials (
    id              BIGSERIAL PRIMARY KEY,
    user_id         BIGINT NOT NULL REFERENCES users(id),
    auth_type       VARCHAR(32) NOT NULL,       -- wechat / password / driver
    openid          VARCHAR(128),
    unionid         VARCHAR(128),
    password_hash   VARCHAR(256),
    token_version   BIGINT NOT NULL DEFAULT 0,
    status          VARCHAR(32) NOT NULL DEFAULT 'active',
    locked_until    TIMESTAMPTZ,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_creds_openid ON user_credentials(openid) WHERE openid IS NOT NULL;
CREATE INDEX idx_creds_user ON user_credentials(user_id);

CREATE TABLE user_addresses (
    id             BIGSERIAL PRIMARY KEY,
    user_id        BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    tag            VARCHAR(32),
    contact        VARCHAR(64) NOT NULL,
    phone          VARCHAR(256) NOT NULL,       -- 加密值
    phone_hash     VARCHAR(128) NOT NULL,
    province       VARCHAR(32),
    city           VARCHAR(32),
    district       VARCHAR(32),
    detail         TEXT NOT NULL,
    lat            DOUBLE PRECISION,
    lng            DOUBLE PRECISION,
    is_default     BOOLEAN NOT NULL DEFAULT false,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_addresses_user ON user_addresses(user_id);

商品与溯源

sql
CREATE TABLE categories (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(64) NOT NULL,
    code        VARCHAR(64) UNIQUE NOT NULL,
    sort_order  INT NOT NULL DEFAULT 0,
    status      VARCHAR(32) NOT NULL DEFAULT 'active'
);

CREATE TABLE products (
    id             BIGSERIAL PRIMARY KEY,
    category_id    BIGINT REFERENCES categories(id),
    name           VARCHAR(128) NOT NULL,
    part           VARCHAR(64),
    price_fen      BIGINT NOT NULL,
    unit           VARCHAR(32) NOT NULL DEFAULT '500g',
    description    TEXT,
    images         JSONB NOT NULL DEFAULT '[]',
    status         VARCHAR(32) NOT NULL DEFAULT 'draft',  -- draft / active / off_shelf
    sales          INT NOT NULL DEFAULT 0,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_products_category_status ON products(category_id, status);
CREATE INDEX idx_products_search ON products USING GIN(to_tsvector('simple', name || ' ' || coalesce(description, '')));

CREATE TABLE trace_records (
    id                 BIGSERIAL PRIMARY KEY,
    product_id         BIGINT NOT NULL REFERENCES products(id),
    batch_no           VARCHAR(64) NOT NULL,
    farm_name          VARCHAR(128) NOT NULL,
    farm_address       TEXT,
    breed              VARCHAR(64),
    feed_info          TEXT,
    slaughter_date     DATE NOT NULL,
    quarantine_cert_asset_id BIGINT,
    created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_trace_product ON trace_records(product_id);
CREATE INDEX idx_trace_batch ON trace_records(batch_no);

库存

生鲜库存必须按批次管理,并且每一次变动必须有流水。

sql
CREATE TABLE product_batches (
    id                 BIGSERIAL PRIMARY KEY,
    product_id          BIGINT NOT NULL REFERENCES products(id),
    batch_no            VARCHAR(64) UNIQUE NOT NULL,
    supplier            VARCHAR(128),
    slaughter_date      DATE,
    production_date     DATE,
    expire_at           TIMESTAMPTZ,
    status              VARCHAR(32) NOT NULL DEFAULT 'active',
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_batches_product ON product_batches(product_id, status);

CREATE TABLE inventory (
    id              BIGSERIAL PRIMARY KEY,
    product_id      BIGINT NOT NULL REFERENCES products(id),
    batch_no        VARCHAR(64) NOT NULL REFERENCES product_batches(batch_no),
    total_qty       INT NOT NULL DEFAULT 0,
    reserved_qty    INT NOT NULL DEFAULT 0,
    version         BIGINT NOT NULL DEFAULT 0,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(product_id, batch_no)
);
CREATE INDEX idx_inventory_product ON inventory(product_id);

CREATE TABLE inventory_locks (
    lock_id       VARCHAR(64) PRIMARY KEY,
    order_no      VARCHAR(32) NOT NULL,
    product_id    BIGINT NOT NULL,
    batch_no      VARCHAR(64) NOT NULL,
    quantity      INT NOT NULL,
    status        VARCHAR(32) NOT NULL DEFAULT 'active', -- active / confirmed / released
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    confirmed_at  TIMESTAMPTZ,
    released_at   TIMESTAMPTZ
);
CREATE INDEX idx_inventory_locks_order ON inventory_locks(order_no);

CREATE TABLE inventory_movements (
    id                 BIGSERIAL PRIMARY KEY,
    product_id          BIGINT NOT NULL,
    batch_no            VARCHAR(64) NOT NULL,
    movement_type       VARCHAR(32) NOT NULL, -- inbound / lock / confirm / release / refund_rollback / adjust
    quantity            INT NOT NULL,
    lock_id             VARCHAR(64),
    order_no            VARCHAR(32),
    operator_id         BIGINT DEFAULT 0,
    balance_total       INT NOT NULL,
    balance_reserved    INT NOT NULL,
    note                TEXT,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_movements_product ON inventory_movements(product_id, created_at);
CREATE INDEX idx_movements_order ON inventory_movements(order_no);
CREATE INDEX idx_movements_lock ON inventory_movements(lock_id);

订单与售后

sql
CREATE TABLE orders (
    id                 BIGSERIAL PRIMARY KEY,
    order_no           VARCHAR(32) UNIQUE NOT NULL,
    user_id            BIGINT NOT NULL REFERENCES users(id),
    address_snapshot   JSONB NOT NULL,
    total_fen          BIGINT NOT NULL,
    discount_fen       BIGINT NOT NULL DEFAULT 0,
    delivery_fee_fen   BIGINT NOT NULL DEFAULT 0,
    final_fen          BIGINT NOT NULL,
    status             VARCHAR(32) NOT NULL DEFAULT 'created',
    stock_lock_id      VARCHAR(64),
    coupon_id          BIGINT,
    payment_no         VARCHAR(64),
    delivery_time      TSRANGE,
    remark             TEXT,
    version            BIGINT NOT NULL DEFAULT 0,
    paid_at            TIMESTAMPTZ,
    cancelled_at       TIMESTAMPTZ,
    delivered_at       TIMESTAMPTZ,
    completed_at       TIMESTAMPTZ,
    created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);
CREATE INDEX idx_orders_status_created ON orders(status, created_at);

CREATE TABLE order_items (
    id              BIGSERIAL PRIMARY KEY,
    order_id        BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id      BIGINT NOT NULL REFERENCES products(id),
    product_name    VARCHAR(128) NOT NULL,
    price_fen       BIGINT NOT NULL,
    quantity        INT NOT NULL,
    batch_no        VARCHAR(64),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_order_items_order ON order_items(order_id);

CREATE TABLE after_sales (
    id             BIGSERIAL PRIMARY KEY,
    order_no       VARCHAR(32) NOT NULL,
    user_id        BIGINT NOT NULL,
    type           VARCHAR(32) NOT NULL, -- refund / return / complaint
    reason         TEXT NOT NULL,
    images         JSONB NOT NULL DEFAULT '[]',
    status         VARCHAR(32) NOT NULL DEFAULT 'applied',
    refund_fen     BIGINT NOT NULL DEFAULT 0,
    admin_remark   TEXT,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_after_sales_order ON after_sales(order_no);

address_snapshot 是下单时的地址快照,不能依赖用户后续修改后的地址。


支付与退款

sql
CREATE TABLE payments (
    id                BIGSERIAL PRIMARY KEY,
    payment_no        VARCHAR(64) UNIQUE NOT NULL,
    order_no          VARCHAR(32) UNIQUE NOT NULL,
    user_id           BIGINT NOT NULL,
    amount_fen        BIGINT NOT NULL,
    channel           VARCHAR(32) NOT NULL DEFAULT 'wechat',
    status            VARCHAR(32) NOT NULL DEFAULT 'created',
    wechat_prepay_id  VARCHAR(128),
    wechat_txn_id     VARCHAR(128),
    paid_at           TIMESTAMPTZ,
    version           BIGINT NOT NULL DEFAULT 0,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_payments_wechat_txn ON payments(wechat_txn_id) WHERE wechat_txn_id IS NOT NULL;
CREATE INDEX idx_payments_status ON payments(status, created_at);

CREATE TABLE payment_notifications (
    id             BIGSERIAL PRIMARY KEY,
    notify_id      VARCHAR(128) UNIQUE NOT NULL,
    payment_no     VARCHAR(64),
    order_no       VARCHAR(32),
    channel        VARCHAR(32) NOT NULL,
    event_type     VARCHAR(64) NOT NULL,
    raw_headers    JSONB NOT NULL,
    raw_body       JSONB NOT NULL,
    verified       BOOLEAN NOT NULL DEFAULT false,
    processed      BOOLEAN NOT NULL DEFAULT false,
    error_msg      TEXT,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payment_notifications_order ON payment_notifications(order_no);

CREATE TABLE refunds (
    id              BIGSERIAL PRIMARY KEY,
    refund_no       VARCHAR(64) UNIQUE NOT NULL,
    payment_no      VARCHAR(64) NOT NULL,
    order_no        VARCHAR(32) NOT NULL,
    amount_fen      BIGINT NOT NULL,
    reason          TEXT NOT NULL,
    status          VARCHAR(32) NOT NULL DEFAULT 'created',
    wechat_refund_id VARCHAR(128),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refunds_order ON refunds(order_no);

支付回调原始数据必须留存,便于对账和事故复盘。


优惠券与配送

sql
CREATE TABLE coupon_templates (
    id              BIGSERIAL PRIMARY KEY,
    name            VARCHAR(64) NOT NULL,
    type            VARCHAR(32) NOT NULL, -- fixed / discount / free_shipping
    value           INT NOT NULL,
    min_amount_fen  BIGINT NOT NULL DEFAULT 0,
    total_qty       INT NOT NULL,
    remain_qty      INT NOT NULL DEFAULT 0,
    start_at        TIMESTAMPTZ NOT NULL,
    end_at          TIMESTAMPTZ NOT NULL,
    status          VARCHAR(32) NOT NULL DEFAULT 'active',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE user_coupons (
    id            BIGSERIAL PRIMARY KEY,
    user_id       BIGINT NOT NULL REFERENCES users(id),
    template_id   BIGINT NOT NULL REFERENCES coupon_templates(id),
    status        VARCHAR(32) NOT NULL DEFAULT 'unused', -- unused / locked / used / expired
    locked_at     TIMESTAMPTZ,
    order_no      VARCHAR(32),
    used_at       TIMESTAMPTZ,
    expires_at    TIMESTAMPTZ NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_coupons_user_status ON user_coupons(user_id, status);

CREATE TABLE delivery_tasks (
    id                BIGSERIAL PRIMARY KEY,
    order_no          VARCHAR(32) UNIQUE NOT NULL,
    time_slot         VARCHAR(32) NOT NULL,
    status            VARCHAR(32) NOT NULL DEFAULT 'pending', -- pending / claimed / picked_up / delivered / cancelled
    driver_id         BIGINT,
    address_summary   VARCHAR(256) NOT NULL,
    contact_masked    VARCHAR(64) NOT NULL,
    phone_masked      VARCHAR(32) NOT NULL,
    lat               DOUBLE PRECISION,
    lng               DOUBLE PRECISION,
    remark            TEXT,
    claimed_at        TIMESTAMPTZ,
    picked_up_at      TIMESTAMPTZ,
    delivered_at      TIMESTAMPTZ,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_delivery_slot_status ON delivery_tasks(time_slot, status);
CREATE INDEX idx_delivery_driver_status ON delivery_tasks(driver_id, status);

Outbox、幂等、审计

sql
CREATE TABLE outbox_events (
    id              BIGSERIAL PRIMARY KEY,
    event_id        UUID UNIQUE NOT NULL,
    event_type      VARCHAR(64) NOT NULL,
    aggregate_type  VARCHAR(64) NOT NULL,
    aggregate_id    VARCHAR(128) NOT NULL,
    payload         JSONB NOT NULL DEFAULT '{}',
    status          VARCHAR(32) NOT NULL DEFAULT 'pending',
    retry_count     INT NOT NULL DEFAULT 0,
    max_retries     INT NOT NULL DEFAULT 10,
    scheduled_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    next_retry_at   TIMESTAMPTZ,
    locked_by       VARCHAR(64),
    locked_at       TIMESTAMPTZ,
    error_msg       TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    processed_at    TIMESTAMPTZ
);
CREATE INDEX idx_outbox_pending ON outbox_events(status, scheduled_at, id);
CREATE INDEX idx_outbox_retry ON outbox_events(status, next_retry_at, id);

CREATE TABLE outbox_consumed (
    event_id      UUID NOT NULL,
    consumer      VARCHAR(64) NOT NULL,
    consumed_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (event_id, consumer)
);

CREATE TABLE idempotency_keys (
    key          VARCHAR(128) PRIMARY KEY,
    scope        VARCHAR(64) NOT NULL,
    owner_id     VARCHAR(64) NOT NULL,
    response     JSONB NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at   TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);

CREATE TABLE audit_logs (
    id              BIGSERIAL PRIMARY KEY,
    operator_id     BIGINT NOT NULL,
    operator_name   VARCHAR(64) NOT NULL,
    action          VARCHAR(64) NOT NULL,
    target_type     VARCHAR(64) NOT NULL,
    target_id       VARCHAR(128) NOT NULL,
    before_value    JSONB,
    after_value     JSONB,
    reason          TEXT NOT NULL,
    ip_address      VARCHAR(45),
    trace_id        VARCHAR(64),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_target ON audit_logs(target_type, target_id);
CREATE INDEX idx_audit_operator ON audit_logs(operator_id, created_at);

Worker 抢任务必须使用:

sql
SELECT *
FROM outbox_events
WHERE status = 'pending'
  AND scheduled_at <= NOW()
ORDER BY scheduled_at, id
LIMIT 50
FOR UPDATE SKIP LOCKED;

Valkey 规则

KeyTTL用途
token_version:{user_id}登出/改密码后让旧 token 失效
token_blacklist:{jti}15m并发窗口内屏蔽旧 access token
rate:{scope}:{id}1m登录、下单、支付、退款限流
idempotency:{scope}:{owner}:{key}24h幂等结果热缓存
product:{id}5m商品详情缓存
product:hot5m热门商品列表
inventory:{product_id}:stock30s展示库存缓存,不作为事实
lock:timeout:{order_no}30sworker 超时处理短锁
lock:admin:{target}30s后台人工敏感操作短锁

COS 路径

类别路径前缀说明
商品图片products/{product_id}/商品主图和详情图
检疫证明quarantine/{batch_no}/图片或 PDF
用户头像avatars/{user_id}/头像
售后图片after-sales/{order_no}/用户上传凭证
数据库备份backups/postgres/pg_dump 归档

上传流程:前端请求预签名 URL → 直传 COS → COS 回调或前端确认 → asset 模块写 assets 表。


备份与恢复

对象策略
PostgreSQL 全量每日 pg_dump -Fc 到 COS,保留 30 天
PostgreSQL WAL生产期启用 WAL 归档,保留 7 天
COS 文件开启版本控制或定期清单
ValkeyRDB 快照即可,丢失可接受

目标:RPO < 24 小时,RTO < 4 小时。每月至少做一次恢复演练:新建空库,从备份恢复,跑核心冒烟测试。


相关链接

鼎味肉市 · 纯线上猪肉零售