一个微服务电商+社区项目(瓷韵app)的技术深度复盘

本文以一个真实项目「瓷韵」为基础,复盘其架构设计、核心技术实现与踩过的坑。


一、项目背景与技术栈

1.1 项目简介

瓷韵(Porcelain Rhyme) 是一个面向瓷器爱好者的小型电商 + 社区平台。它把「内容社区」和「商品交易」放在同一套架构里:用户既能发笔记、关注匠人、点赞评论,也能开店卖瓷器、参与秒杀、即时聊天。

这个项目最大的价值不在于功能多寡,而在于它用一个学习级项目的体量,复刻了工业级微服务的几乎所有典型场景

  • 高并发秒杀(Redis Lua + 异步建单)
  • 实时通信(WebSocket + 在线状态)
  • 事件驱动(RabbitMQ + Outbox 模式)
  • 全文检索(Elasticsearch)
  • 分布式事务(Saga 同步补偿)
  • 第三方支付(支付宝沙箱)
  • 第三方物流(快递鸟 API)
  • AI 能力接入(Dify 多智能体)

1.2 技术栈一览

层级 技术选型 选型理由
后端框架 FastAPI(异步) 原生 async/await,性能接近 Go;Pydantic 数据校验;自动 OpenAPI 文档
API 网关 APISIX 3.10 云原生高性能,动态路由 + etcd 配置中心,Lua 插件生态
服务治理 Nacos 服务注册发现 + 配置中心二合一,Spring Cloud Alibaba 生态
消息队列 RabbitMQ 支持延迟消息插件,天然适配订单超时取消;消息可靠性高
缓存 Redis 秒杀原子操作、在线状态、频控、缓存
数据库 MySQL 8.0 InnoDB 行锁适配并发扣减库存
搜索引擎 Elasticsearch 商品全文搜索 + Completion Suggester 联想
对象存储 阿里云 OSS 图片直传 + STS 临时凭证
前端 UniApp + Vue 3 + TS 一套代码编译到 iOS/Android/H5/小程序
运营后台 Vue 3 + Element Plus 独立 SPA,通过 admin-bff 聚合
支付 支付宝沙箱 RSA2 验签,APP + H5 双模式
物流 快递鸟 API 真实接口,含轨迹地图 + 订阅推送
AI Dify 多智能体 SSE 流式输出

1.3 微服务拆分

后端一共 10 个独立 FastAPI 微服务,每个服务有独立的数据库、独立的端口、独立的 Nacos 注册:

服务 端口 职责
user-service 8001 用户、地址、关注关系
content-service 8002 笔记、评论、点赞、收藏、举报
product-service 8003 商品、分类、评价、ES 搜索
order-service 8004 订单、购物车、支付、物流
flashsale-service 8005 秒杀活动、Lua 抢购、Outbox
chat-service 8006 WebSocket 私聊、会话管理
message-service 8007 系统通知、APNs/FCM 推送
ai-service 8008 Dify 多智能体 SSE 流式对话
file-service 8009 OSS STS 凭证签发
admin-bff 8010 运营后台聚合层(不走 APISIX)

二、整体架构总览

2.1 架构分层

#mermaid-svg-OwG6Ml4JCIQhpMVN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OwG6Ml4JCIQhpMVN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OwG6Ml4JCIQhpMVN .error-icon{fill:#552222;}#mermaid-svg-OwG6Ml4JCIQhpMVN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OwG6Ml4JCIQhpMVN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OwG6Ml4JCIQhpMVN .marker.cross{stroke:#333333;}#mermaid-svg-OwG6Ml4JCIQhpMVN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OwG6Ml4JCIQhpMVN p{margin:0;}#mermaid-svg-OwG6Ml4JCIQhpMVN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OwG6Ml4JCIQhpMVN .cluster-label text{fill:#333;}#mermaid-svg-OwG6Ml4JCIQhpMVN .cluster-label span{color:#333;}#mermaid-svg-OwG6Ml4JCIQhpMVN .cluster-label span p{background-color:transparent;}#mermaid-svg-OwG6Ml4JCIQhpMVN .label text,#mermaid-svg-OwG6Ml4JCIQhpMVN span{fill:#333;color:#333;}#mermaid-svg-OwG6Ml4JCIQhpMVN .node rect,#mermaid-svg-OwG6Ml4JCIQhpMVN .node circle,#mermaid-svg-OwG6Ml4JCIQhpMVN .node ellipse,#mermaid-svg-OwG6Ml4JCIQhpMVN .node polygon,#mermaid-svg-OwG6Ml4JCIQhpMVN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OwG6Ml4JCIQhpMVN .rough-node .label text,#mermaid-svg-OwG6Ml4JCIQhpMVN .node .label text,#mermaid-svg-OwG6Ml4JCIQhpMVN .image-shape .label,#mermaid-svg-OwG6Ml4JCIQhpMVN .icon-shape .label{text-anchor:middle;}#mermaid-svg-OwG6Ml4JCIQhpMVN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OwG6Ml4JCIQhpMVN .rough-node .label,#mermaid-svg-OwG6Ml4JCIQhpMVN .node .label,#mermaid-svg-OwG6Ml4JCIQhpMVN .image-shape .label,#mermaid-svg-OwG6Ml4JCIQhpMVN .icon-shape .label{text-align:center;}#mermaid-svg-OwG6Ml4JCIQhpMVN .node.clickable{cursor:pointer;}#mermaid-svg-OwG6Ml4JCIQhpMVN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OwG6Ml4JCIQhpMVN .arrowheadPath{fill:#333333;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OwG6Ml4JCIQhpMVN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OwG6Ml4JCIQhpMVN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OwG6Ml4JCIQhpMVN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OwG6Ml4JCIQhpMVN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OwG6Ml4JCIQhpMVN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OwG6Ml4JCIQhpMVN .cluster text{fill:#333;}#mermaid-svg-OwG6Ml4JCIQhpMVN .cluster span{color:#333;}#mermaid-svg-OwG6Ml4JCIQhpMVN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OwG6Ml4JCIQhpMVN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OwG6Ml4JCIQhpMVN rect.text{fill:none;stroke-width:0;}#mermaid-svg-OwG6Ml4JCIQhpMVN .icon-shape,#mermaid-svg-OwG6Ml4JCIQhpMVN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OwG6Ml4JCIQhpMVN .icon-shape p,#mermaid-svg-OwG6Ml4JCIQhpMVN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OwG6Ml4JCIQhpMVN .icon-shape .label rect,#mermaid-svg-OwG6Ml4JCIQhpMVN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OwG6Ml4JCIQhpMVN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OwG6Ml4JCIQhpMVN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OwG6Ml4JCIQhpMVN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 基础设施层
微服务层
网关层
入口层
客户端层
/api/
/api/admin
读取
http://apisix:9080
UniApp App/H5/小程序
运营后台 Vue3
Nginx :80

静态资源 + 反代
APISIX :9080

动态路由 + CORS + WS
etcd

路由配置
user 8001
content 8002
product 8003
order 8004
flashsale 8005
chat 8006
message 8007
ai 8008
file 8009
admin-bff 8010
Nacos

注册+配置
RabbitMQ

事件驱动
Redis

缓存+原子操作
MySQL

9个独立库
Elasticsearch

商品搜索
阿里云 OSS

图片存储

2.2 三条核心请求链路

链路 1:用户端 API(App/小程序)

复制代码
App → Nginx :80 → APISIX :9080 → 某微服务 :800x

Nginx 剥掉 /api 前缀,APISIX 根据 etcd 里的路由表(如 /api/v1/products*product-service:8003)转发到对应服务。

链路 2:服务间调用

复制代码
content-service → HttpClient(http://apisix:9080) → APISIX → user-service /internal/users/*

服务间调用统一走 APISIX (而非直连目标服务),用 X-Internal-Token 共享密钥校验。这样所有跨服务调用都经过网关,便于统一治理。

链路 3:运营后台

复制代码
运营后台 → Nginx :8081 → /api/admin → admin-bff :8010 → 调 APISIX → 各微服务 /internal/*

admin-bff 是个 BFF(Backend For Frontend)聚合层,不走 APISIX(由 Nginx 直连),但调下游服务时仍走 APISIX。设计上隔离了 App 端和运营端的鉴权模型。

2.3 数据库拆分

每个服务一个独立库,严格禁止跨库 join

服务 数据库 主要表
user-service porcelain_user users, user_profiles, addresses, follows
content-service porcelain_content posts, comments, likes, favorites, topics, reports
product-service porcelain_product products, categories, reviews, browse_history
order-service porcelain_order orders, order_items, carts, shippings, mq_outbox
flashsale-service porcelain_flashsale flashsales, mq_outbox
chat-service porcelain_chat conversations, messages
message-service porcelain_message notifications
ai-service porcelain_ai (未实际使用)
file-service porcelain_file (未实际使用)

跨服务数据需要通过 /internal/* 接口聚合,不直接读别人的库


三、核心技术点深度剖析

3.1 秒杀系统:Lua 原子抢购 + Outbox 异步建单

秒杀是这个项目最硬核的部分。它的核心难题是:1000 个用户同时抢 10 件商品,怎么保证不超卖、不少卖、响应够快?

3.1.1 整体方案

order-service RabbitMQ MySQL Redis flashsale-service 用户App order-service RabbitMQ MySQL Redis flashsale-service 用户App #mermaid-svg-k0VJV5Rl7jtGJ5Fa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .error-icon{fill:#552222;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .marker.cross{stroke:#333333;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa p{margin:0;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .sequenceNumber{fill:white;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa #sequencenumber{fill:#333;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .messageText{fill:#333;stroke:none;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .labelText,#mermaid-svg-k0VJV5Rl7jtGJ5Fa .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .loopText,#mermaid-svg-k0VJV5Rl7jtGJ5Fa .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .noteText,#mermaid-svg-k0VJV5Rl7jtGJ5Fa .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .actorPopupMenu{position:absolute;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa .actor-man circle,#mermaid-svg-k0VJV5Rl7jtGJ5Fa line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-k0VJV5Rl7jtGJ5Fa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原子执行:1. 校验活动状态2. 一人一单判断3. DECR 库存4. SET 临时订单标记 par后台异步 alt库存不足/活动未开始/重复下单抢购成功 POST /api/v1/flashordersEVALSHA Lua脚本错误码 -1/-2/-3错误响应code=0 + temp_order_idINSERT outbox (flashsale.order.created)UPDATE sold_count + 1temp_order_id + 支付截止时间Outbox Dispatcher publish消费 flashsale.order.created创建正式订单(15分钟超时)发延迟消息(超时取消)

3.1.2 Lua 原子脚本:4 步一次执行
lua 复制代码
-- KEYS[1] = fs:status:{aid}    活动状态
-- KEYS[2] = fs:stock:{aid}     剩余库存
-- KEYS[3] = fs:order:{aid}:{uid}  一人一单标记
-- ARGV[1] = temp_order_id      临时订单号
-- ARGV[2] = ttl_seconds        到活动结束的剩余秒数

local status = redis.call('GET', KEYS[1])
if status ~= 'ONGOING' then
    return {-1, '活动未开始或已结束'}
end

if redis.call('EXISTS', KEYS[3]) == 1 then
    return {-2, '每人限购一件'}
end

local stock = redis.call('GET', KEYS[2])
if not stock or tonumber(stock) <= 0 then
    return {-3, '已售罄'}
end

local remaining = redis.call('DECR', KEYS[2])
if remaining < 0 then
    redis.call('INCR', KEYS[2])  -- 回滚
    return {-3, '已售罄'}
end

redis.call('SET', KEYS[3], ARGV[1], 'EX', ARGV[2])
return {0, ARGV[1], remaining}

为什么用 Lua

  1. 原子性:4 步(状态校验 → 一人一单 → DECR → SET 标记)在 Redis 单线程里一次性执行完,不会被其他请求打断
  2. 网络往返从 4 次 RTT 降到 1 次:1000 并发下网络开销省了 75%
  3. DECR 后再判 < 0 回滚:这是关键设计,避免「GET 判断 + DECR」两步之间的竞态
3.1.3 Redis Key 设计
Key 类型 TTL 用途
porcelain:fs:status:{aid} STRING 活动结束+7天 活动状态(UPCOMING/ONGOING/ENDED/SOLD_OUT)
porcelain:fs:stock:{aid} STRING 活动结束+7天 实时库存(DECR 原子减)
porcelain:fs:order:{aid}:{uid} STRING 到活动结束 一人一单标记,值为 temp_order_id
porcelain:fs:remind:{aid} SET --- 开窑提醒用户列表
porcelain:fs:remind:pushed:{aid} STRING 600s 提醒已推送防重
3.1.4 Outbox 模式:异步建单 + 事务一致性

抢购成功后,flashsale-service 不直接调 order-service,而是写一条 outbox 记录:

python 复制代码
async def _publish_order_created(self, db, activity_id, user_id, temp_order_id, flash_price, product_id):
    """INSERT outbox: 'flashsale.order.created' MQ 消息
    
    改造: 原 publish → INSERT outbox (与 sold_count 同事务 commit)
    - Dispatcher 后台轮询 PENDING → publish → 更新 SENT
    - event_id 预生成, 消费者端 Redis SETNX 兜底去重
    """
    event_id = str(uuid.uuid4())
    db.add(MqOutbox(
        exchange_name=EXCHANGE_FLASHSALE,
        routing_key=ORDER_CREATED_QUEUE,
        body={
            "activity_id": activity_id,
            "user_id": user_id,
            "temp_order_id": temp_order_id,
            "flash_price": str(flash_price),
            "product_id": product_id,
            "event_id": event_id,
        },
        status="PENDING",
    ))

为什么用 Outbox 而不是直接 publish

方案 问题
直接 publish DB commit 成功但 MQ publish 失败 → 用户扣了库存却没订单
先 publish 后 commit MQ 消费者先创建了订单,但 DB rollback 了 → 用户有订单但库存没扣
Outbox INSERT outbox 与 UPDATE sold_count 同事务,要么都成功要么都失败

后台 Dispatcher 每 2 秒轮询一次 PENDING 记录,publish 成功就更新为 SENT:

python 复制代码
async def _poll_once(self):
    """轮询一次: 查询 PENDING → publish → 更新 SENT
    
    使用 FOR UPDATE SKIP LOCKED 行级锁, 防止多实例 Dispatcher 重复消费
    """
    async with factory() as db:
        stmt = (
            select(MqOutbox)
            .where(MqOutbox.status == "PENDING")
            .order_by(MqOutbox.created_at)
            .limit(self._batch_size)
            .with_for_update(skip_locked=True)   # 关键: 多实例时跳过被锁记录
        )
        records = (await db.execute(stmt)).scalars().all()
        
        for record in records:
            ok = await publish(record.exchange_name, record.routing_key, record.body)
            if ok:
                record.status = "SENT"
                record.sent_at = datetime.now(CHINA_TZ)
            # 失败不改 status, 下次重试
        
        await db.commit()

关键设计

  • FOR UPDATE SKIP LOCKED:多实例部署时,每个 Dispatcher 拿到不同的记录,避免重复发送
  • publish 失败不改 status:下次轮询自动重试,event_id 不变,消费者端 SETNX 兜底去重
  • 每 100 次轮询清理 7 天前 SENT 记录:防止表膨胀
3.1.5 MQ 失败回滚

如果 outbox INSERT 都失败了(极少见),flashsale-service 会主动回滚 Redis:

python 复制代码
try:
    await self._publish_order_created(db, ...)
except Exception as mq_err:
    # 回滚: 释放库存 + 清除一人一单标记
    await redis.incr(f"{_FS}stock:{activity_id}")
    await redis.delete(f"{_FS}order:{activity_id}:{user_id}")
    raise BusinessException(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试")
3.1.6 取消回滚链路

秒杀订单 15 分钟未支付 → order-service 发 order.cancelled MQ → flashsale-service 消费,回滚 Redis:

python 复制代码
async def rollback_order(self, db, activity_id, user_id, redis):
    """秒杀订单超时取消回滚
    
    流程 (消费 order.cancelled MQ):
      1. INCR fs:stock:{aid}  (释放库存)
      2. DEL fs:order:{aid}:{uid}  (清除一人一单标记)
      3. UPDATE sold_count -1
    """
    order_key = f"{_FS}order:{activity_id}:{user_id}"
    exists = await redis.exists(order_key)
    if exists:
        await redis.incr(f"{_FS}stock:{activity_id}")
        await redis.delete(order_key)
        await repo.decrement_sold_count(activity_id)

关键 :先 EXISTS 判断再回滚,防止重复消费导致库存被多次释放。


3.2 实时聊天:WebSocket + Redis 在线状态 + MQ 离线推送

实时聊天的核心难题是:怎么在不轮询的情况下,把消息实时推到对方?对方不在线怎么办?

3.2.1 整体架构

#mermaid-svg-8Gn0QlgrrLCuP23c{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8Gn0QlgrrLCuP23c .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8Gn0QlgrrLCuP23c .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8Gn0QlgrrLCuP23c .error-icon{fill:#552222;}#mermaid-svg-8Gn0QlgrrLCuP23c .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8Gn0QlgrrLCuP23c .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8Gn0QlgrrLCuP23c .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8Gn0QlgrrLCuP23c .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8Gn0QlgrrLCuP23c .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8Gn0QlgrrLCuP23c .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8Gn0QlgrrLCuP23c .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8Gn0QlgrrLCuP23c .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8Gn0QlgrrLCuP23c .marker.cross{stroke:#333333;}#mermaid-svg-8Gn0QlgrrLCuP23c svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8Gn0QlgrrLCuP23c p{margin:0;}#mermaid-svg-8Gn0QlgrrLCuP23c .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8Gn0QlgrrLCuP23c .cluster-label text{fill:#333;}#mermaid-svg-8Gn0QlgrrLCuP23c .cluster-label span{color:#333;}#mermaid-svg-8Gn0QlgrrLCuP23c .cluster-label span p{background-color:transparent;}#mermaid-svg-8Gn0QlgrrLCuP23c .label text,#mermaid-svg-8Gn0QlgrrLCuP23c span{fill:#333;color:#333;}#mermaid-svg-8Gn0QlgrrLCuP23c .node rect,#mermaid-svg-8Gn0QlgrrLCuP23c .node circle,#mermaid-svg-8Gn0QlgrrLCuP23c .node ellipse,#mermaid-svg-8Gn0QlgrrLCuP23c .node polygon,#mermaid-svg-8Gn0QlgrrLCuP23c .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8Gn0QlgrrLCuP23c .rough-node .label text,#mermaid-svg-8Gn0QlgrrLCuP23c .node .label text,#mermaid-svg-8Gn0QlgrrLCuP23c .image-shape .label,#mermaid-svg-8Gn0QlgrrLCuP23c .icon-shape .label{text-anchor:middle;}#mermaid-svg-8Gn0QlgrrLCuP23c .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8Gn0QlgrrLCuP23c .rough-node .label,#mermaid-svg-8Gn0QlgrrLCuP23c .node .label,#mermaid-svg-8Gn0QlgrrLCuP23c .image-shape .label,#mermaid-svg-8Gn0QlgrrLCuP23c .icon-shape .label{text-align:center;}#mermaid-svg-8Gn0QlgrrLCuP23c .node.clickable{cursor:pointer;}#mermaid-svg-8Gn0QlgrrLCuP23c .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8Gn0QlgrrLCuP23c .arrowheadPath{fill:#333333;}#mermaid-svg-8Gn0QlgrrLCuP23c .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8Gn0QlgrrLCuP23c .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8Gn0QlgrrLCuP23c .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8Gn0QlgrrLCuP23c .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8Gn0QlgrrLCuP23c .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8Gn0QlgrrLCuP23c .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8Gn0QlgrrLCuP23c .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8Gn0QlgrrLCuP23c .cluster text{fill:#333;}#mermaid-svg-8Gn0QlgrrLCuP23c .cluster span{color:#333;}#mermaid-svg-8Gn0QlgrrLCuP23c div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8Gn0QlgrrLCuP23c .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8Gn0QlgrrLCuP23c rect.text{fill:none;stroke-width:0;}#mermaid-svg-8Gn0QlgrrLCuP23c .icon-shape,#mermaid-svg-8Gn0QlgrrLCuP23c .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8Gn0QlgrrLCuP23c .icon-shape p,#mermaid-svg-8Gn0QlgrrLCuP23c .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8Gn0QlgrrLCuP23c .icon-shape .label rect,#mermaid-svg-8Gn0QlgrrLCuP23c .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8Gn0QlgrrLCuP23c .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8Gn0QlgrrLCuP23c .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8Gn0QlgrrLCuP23c :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} WebSocket
WebSocket
在线状态
在线状态
在线推送
离线
用户A
chat-service 实例1
用户B
chat-service 实例2
ConnectionManager

内存: uid→ws
ConnectionManager

内存: uid→ws
Redis

ws:conn:uid
MessageService
MySQL

conversations + messages
RabbitMQ

message.offline
message-service
APNs/FCM

推送到 B

3.2.2 ConnectionManager:单点登录 + 三层心跳
python 复制代码
class ConnectionManager:
    """WebSocket 连接管理器(单例)"""
    def __init__(self):
        self._connections: dict[int, WebSocket] = {}   # user_id → WebSocket
        self._heartbeats: dict[int, float] = {}        # user_id → 最后心跳时间戳

    async def connect(self, websocket, user_id, redis):
        await websocket.accept()
        
        # 单点登录: 踢掉旧连接
        old_ws = self._connections.pop(user_id, None)
        if old_ws is not None:
            try:
                await old_ws.close(code=4000, reason="新设备登录")
            except Exception:
                pass
        
        self._connections[user_id] = websocket
        self._heartbeats[user_id] = time.time()
        
        # 写入 Redis 在线状态 (跨实例可见)
        await redis.set(f"porcelain:ws:conn:{user_id}", "chat-service-1", ex=150)
        await redis.set(f"porcelain:ws:heartbeat:{user_id}", str(time.time()), ex=150)

三层心跳防护

层级 机制 周期
① 客户端心跳 客户端发 {"type":"heartbeat"} 30s 一次
② 服务端 pong handle_heartbeat 更新时间戳 即时响应
③ 后台扫描 broadcast_heartbeat_check 清理超时 60s 一次扫描,90s 超时断连

为什么 TTL 设 150 秒:心跳间隔 30s + 超时 90s = 120s,留 30s 缓冲。即使后台清理任务延迟,Redis Key 也会自动过期。

3.2.3 消息发送 7 步流程
python 复制代码
async def send_message(self, db, sender_id, conversation_id=None, target_user_id=None, ...):
    # 1. 确定 target_user_id (从 conversation 反查)
    # 2. sender == target → 拒绝 (70001 不能给自己发消息)
    # 3. 互关判断 + 频控 (非互关每日限20条)
    await self._check_rate_limit(sender_id, target_user_id)
    
    # 4. 查找或创建会话 (LEAST/GREATEST 保证唯一)
    conv = await ConversationRepo.get_or_create(db, sender_id, target_user_id)
    
    # 5. 图片消息 URL 白名单校验
    if msg_type == MessageType.IMAGE:
        if not validate_oss(content):
            raise param_invalid("图片 URL 不在允许的白名单内")
    
    # 6. 插入消息
    msg = await MessageRepo.create_message(db, ...)
    
    # 7. 更新会话(预览+未读+1+时间, 一步 UPDATE)
    await ConversationRepo.update_after_message(db, conv, sender_id, preview)
    
    await db.commit()
    return WsMessageOut(...).model_dump()
3.2.4 防骚扰频控:互关不限流,陌生人每日 20 条
python 复制代码
async def _check_rate_limit(self, sender_id, target_id):
    is_mutual = await self._is_mutual_following(sender_id, target_id)
    if is_mutual:
        return  # 互关用户不限流
    
    key = f"porcelain:chat:limit:{sender_id}{target_id}"   # 按 (sender, target) 维度
    count = await self.redis.incr(key)
    if count == 1:
        await self.redis.expire(key, 86400)   # 24h TTL
    
    if count > 20:
        raise forbidden("互相关注后可无限畅聊", code=70004)

互关判断复用 user-service 的 following Set

python 复制代码
async def _is_mutual_following(self, uid_a, uid_b):
    """通过 user-service 的 Redis Set SISMEMBER 判断 (O(1))"""
    pipe = self.redis.pipeline()
    pipe.sismember(f"porcelain:following:{uid_a}", str(uid_b))
    pipe.sismember(f"porcelain:following:{uid_b}", str(uid_a))
    results = await pipe.execute()
    return bool(results[0]) and bool(results[1])
3.2.5 离线推送:MQ 解耦
python 复制代码
# 在线推送失败 → 离线推送
pushed = await svc.push_to_target(target_id, result)
if not pushed:
    from core.mq_publisher import publish_offline_message
    await publish_offline_message(
        target_user_id=target_id,
        sender_id=sender_id,
        conversation_id=result["conversation_id"],
        content=result["content"],
        sender_nickname=sender_info.get("nickname", ""),
    )

关键设计 :message.offline 事件不入 notifications 表,message-service 直接走 APNs/FCM 推送,避免聊天消息污染通知中心。

3.2.6 会话级已读模型

conversations 表存 unread_count_a / unread_count_b / last_read_time_a / last_read_time_bmessages 表故意不加 is_read 字段

判断"某条消息是否已读"用 message.created_at <= conversation.last_read_time_{a|b},避免逐条 UPDATE。

发一条消息 = 1 INSERT messages + 1 UPDATE conversations(预览+未读+1+时间一步完成),2 SQL 完成。


3.3 订单系统:Saga 同步补偿 + 6 态状态机 + 支付宝沙箱

3.3.1 6 态状态机

#mermaid-svg-qY06tmOATSkJZ2I7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qY06tmOATSkJZ2I7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qY06tmOATSkJZ2I7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qY06tmOATSkJZ2I7 .error-icon{fill:#552222;}#mermaid-svg-qY06tmOATSkJZ2I7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qY06tmOATSkJZ2I7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qY06tmOATSkJZ2I7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qY06tmOATSkJZ2I7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qY06tmOATSkJZ2I7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qY06tmOATSkJZ2I7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qY06tmOATSkJZ2I7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qY06tmOATSkJZ2I7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qY06tmOATSkJZ2I7 .marker.cross{stroke:#333333;}#mermaid-svg-qY06tmOATSkJZ2I7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qY06tmOATSkJZ2I7 p{margin:0;}#mermaid-svg-qY06tmOATSkJZ2I7 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-qY06tmOATSkJZ2I7 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-qY06tmOATSkJZ2I7 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-qY06tmOATSkJZ2I7 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-qY06tmOATSkJZ2I7 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-qY06tmOATSkJZ2I7 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-qY06tmOATSkJZ2I7 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-qY06tmOATSkJZ2I7 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-qY06tmOATSkJZ2I7 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-qY06tmOATSkJZ2I7 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-qY06tmOATSkJZ2I7 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-qY06tmOATSkJZ2I7 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-qY06tmOATSkJZ2I7 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-qY06tmOATSkJZ2I7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qY06tmOATSkJZ2I7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qY06tmOATSkJZ2I7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qY06tmOATSkJZ2I7 .edgeLabel .label text{fill:#333;}#mermaid-svg-qY06tmOATSkJZ2I7 .label div .edgeLabel{color:#333;}#mermaid-svg-qY06tmOATSkJZ2I7 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-qY06tmOATSkJZ2I7 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-qY06tmOATSkJZ2I7 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-qY06tmOATSkJZ2I7 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-qY06tmOATSkJZ2I7 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-qY06tmOATSkJZ2I7 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qY06tmOATSkJZ2I7 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qY06tmOATSkJZ2I7 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qY06tmOATSkJZ2I7 .cluster-label,#mermaid-svg-qY06tmOATSkJZ2I7 .nodeLabel{color:#131300;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-qY06tmOATSkJZ2I7 .note-edge{stroke-dasharray:5;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-note text{fill:black;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagram .edgeLabel{color:red;}#mermaid-svg-qY06tmOATSkJZ2I7 #dependencyStart,#mermaid-svg-qY06tmOATSkJZ2I7 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-qY06tmOATSkJZ2I7 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qY06tmOATSkJZ2I7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 创建订单
支付宝回调
买家取消/超时
卖家发货
买家确认
系统自动/手动
待付款
已付款
已取消
已发货
已签收
已完成

状态码 状态 触发条件
0 待付款 创建订单
1 已付款 支付宝回调成功
2 已发货 卖家填写运单号
3 已签收 买家确认收货
4 已完成 系统自动/手动
5 已取消 买家取消/超时未付
3.3.2 普通下单:Saga 同步补偿

普通下单同步调用 product-service 扣库存,失败则补偿回滚:

python 复制代码
async def create_order(self, buyer_id, req):
    # 0. Redis 防重锁 SETNX EX 3
    lock_key = f"porcelain:order:create:lock:{buyer_id}:cart"
    locked = await self._redis.set(lock_key, "1", ex=3, nx=True)
    if not locked:
        raise param_invalid("请勿重复提交")
    
    try:
        # 1. 调 product-service batch 获取商品信息
        product_info_list = await self._fetch_product_batch(product_ids)
        
        deducted_products = []  # 记录已扣库存的商品, 用于补偿
        try:
            # 2. 逐项扣减库存
            for item_req in req.items:
                await self._deduct_stock(pid, item_req.quantity)
                deducted_products.append({"product_id": pid, "quantity": item_req.quantity})
            
            # 3-7. 获取地址快照 → 生成订单号 → INSERT orders + order_items → 清购物车
            order = await self._order_repo.create_order(...)
            for item_req in req.items:
                await self._order_repo.create_order_item(...)
            
            # 8. 发延迟消息(超时取消)
            await self._publish_timeout_event(order.id, expire_at)
            
            return OrderCreateResponse(...)
        
        except BusinessException:
            # 补偿: 回滚已扣减的库存
            for dp in deducted_products:
                await self._restore_stock(dp["product_id"], dp["quantity"])
            raise
    finally:
        await self._redis.delete(lock_key)

关键设计

  • 防重锁SETNX EX 3 防止用户快速双击重复下单
  • 补偿列表 deducted_products:记录已扣库存的商品,异常时反向回滚
  • 30 分钟超时:订单创建时发延迟消息,30 分钟后触发取消
3.3.3 支付宝沙箱集成
python 复制代码
class PaymentService:
    @property
    def alipay(self) -> AliPay:
        """懒加载 AliPay 实例,自动处理 PKCS8→PKCS1 转换"""
        if self._alipay is None:
            pk = _to_pkcs1(self._settings.alipay_private_key)  # PKCS8 → PKCS1
            pub = _wrap_public_key(self._settings.alipay_public_key)  # 裸 base64 → PEM
            self._alipay = AliPay(
                appid=self._settings.alipay_app_id,
                app_notify_url=self._settings.alipay_notify_url,
                app_private_key_string=pk,
                alipay_public_key_string=pub,
                sign_type="RSA2",
                debug=self._settings.debug,
            )
        return self._alipay
    
    async def create_payment(self, order_id, order_no, total_amount, platform="app"):
        """发起支付:APP 返回 orderStr,H5 返回 pay_url"""
        # 防并发锁
        lock_key = f"porcelain:order:pay:lock:{order_no}"
        locked = await self._redis.set(lock_key, "1", ex=30, nx=True)
        if not locked:
            raise param_invalid("支付处理中, 请勿重复提交")
        
        try:
            if platform == "h5":
                signed_query = self.alipay.api_alipay_trade_page_pay(
                    out_trade_no=order_no,
                    total_amount=str(total_amount),
                    subject=f"瓷韵订单-{order_no}",
                    return_url=return_url,
                    timeout_express="30m",
                )
                return {"pay_url": f"{self._settings.alipay_gateway_url}?{signed_query}"}
            else:
                order_str = self.alipay.api_alipay_trade_app_pay(...)
                return {"order_str": order_str}
        finally:
            await self._redis.delete(lock_key)

回调验签 + 幂等

python 复制代码
async def verify_callback(self, raw_data):
    """支付宝异步回调验签 (O4: 幂等)"""
    sign = raw_data.pop("sign", "")
    verified = self.alipay.verify(raw_data, sign)  # RSA2 验签
    
    if not verified:
        return False, ""
    
    trade_status = raw_data.get("trade_status", "")
    if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
        return False, ""
    
    return True, raw_data.get("out_trade_no", "")

幂等关键handle_payment_success 先查订单状态,如果已经是 STATUS_PAID 就直接返回成功,避免重复回调导致重复处理。

3.3.4 超时取消:延迟消息 + 乐观锁
python 复制代码
async def timeout_cancel(self, order_id):
    """超时取消(O5: 乐观锁防竞态)
    
    affected_rows=0 说明已被支付覆盖(竞态输给支付宝回调) → 什么都不做
    """
    success = await self._order_repo.timeout_cancel(order_id)
    if success:
        order = await self._order_repo.get_by_id(order_id)
        if order and order.order_type == 1:
            # 秒杀订单需通知 flashsale-service 回滚库存
            await self._publish_cancelled_event(order_id)
    return success

timeout_cancel 在 SQL 层用 WHERE status = 0(待付款)做乐观锁:

sql 复制代码
UPDATE orders 
SET status = 5, cancelled_at = NOW() 
WHERE id = ? AND status = 0

如果支付宝回调先到了把状态改成 1,这条 UPDATE 的 affected_rows 就是 0,取消操作自动跳过,完美解决了支付回调和超时取消的竞态

3.3.5 物流查询:真实快递鸟 API(非 mock)
python 复制代码
class LogisticsAdapter:
    """快递鸟即时查询 + 轨迹地图 + 预计到达时间 + 订阅推送
    
    支持接口:
      - 即时查询 (8001): 查询物流轨迹节点
      - 轨迹地图版即时查询 (8003): 查询轨迹 + 经纬度 + 预计到达时间 + 地图URL
      - 预计到达时间 (6006): 发货后时效预估
      - 订阅推送 (8008): 订阅轨迹变更,快递鸟回调推送
      - 订阅推送地图版 (8005): 订阅轨迹变更,回调含经纬度 + 地图URL
    """
    PROD_URL = "https://api.kdniao.com/api/dist"
    SANDBOX_URL = "http://183.62.170.46:8660/api/dist"
    
    # 顺丰/跨越/中通 需要传 CustomerName (手机号后四位)
    CUSTOMER_NAME_REQUIRED_CODES = {"SF", "KYS", "ZTO"}

真实接口,不是 mock。卖家发货时:

  1. 调 8003 轨迹地图版即时查询,获取初始轨迹 + 地图 URL + 经纬度
  2. 调 8005 订阅推送地图版,快递鸟后续自动回调推送轨迹更新
python 复制代码
async def ship_order(self, order_id, seller_id, req):
    success = await self._order_repo.ship_order(order_id)
    
    # 调用快递鸟获取初始轨迹 (非阻塞, 失败不影响发货)
    try:
        adapter = LogisticsAdapter(
            api_key=settings.express_bird_api_key,
            business_id=settings.express_bird_business_id,
            sandbox=settings.express_bird_sandbox,
        )
        
        # 1. 即时查询 --- 获取初始轨迹+地图数据
        result = adapter.query_tracking_map(
            tracking_number=req.tracking_number,
            express_code=req.express_company,
            sender_city="景德镇市",  # 默认发件地
            receiver_city=addr.city,
            customer_name=addr.phone[-4:] if req.express_company in {"SF", "KYS", "ZTO"} else "",
        )
        
        # 2. 订阅推送 --- 快递鸟自动回调推送后续更新
        adapter.subscribe_map(
            tracking_number=req.tracking_number,
            express_code=req.express_company,
            callback_url=settings.express_bird_callback_url,
            sender=sender_info,
            receiver=receiver_info,
        )
    except Exception as e:
        logger.warning("[ship] 快递鸟调用异常(非致命,不影响发货): {}", e)
    
    # 创建物流记录
    await self._shipping_repo.create_shipping(...)

3.4 社区 Feed 流:微博方案推拉结合

社区推送是这个项目另一个有亮点的部分。核心问题是:大V 发一条笔记,10 万粉丝,怎么把笔记推到所有粉丝的 Feed 里?

项目采用了微博方案 (推拉结合),用 creator:level Hash 标签区分大小V,分别走 push/pull 策略。

3.4.1 推拉结合方案
创作者类型 判断依据 写入策略 读取策略
小V(strategy=push) HGET creator:level:{uid} strategy == "push" 写扩散:推到所有粉丝 inbox 直接读自己的 inbox
大V(strategy=pull) HGET creator:level:{uid} strategy == "pull" 只写 outbox,不推 inbox 读自己的 inbox + 实时拉关注的大V outbox

阈值与迟滞区间shared/constants/__init__.py:42):

python 复制代码
FEED_PUSH_THRESHOLD = 100   # 粉丝数阈值: 超过用推拉结合
FEED_VIP_UPPER = 110        # 超过升级为大V
FEED_VIP_LOWER = 90         # 低于降级为小V

迟滞区间设计:避免粉丝数在 100 附近横跳导致频繁切换策略。涨到 110 才升级大V,降到 90 才降级小V。

3.4.2 Redis 数据结构
Key 类型 用途
porcelain:feed:timeline:push:{uid} ZSET 收件箱(小V 推过来的帖子,score=时间戳)
porcelain:creator:feed:list:{uid} ZSET 发件箱(作者自己的帖子,大V 专属)
porcelain:creator:level:{uid} Hash 创作者标签(fans_count/strategy/push_threshold)
porcelain:following:{uid} SET 关注列表(也用于聊天互关判断)
porcelain:followers:{uid} SET 粉丝列表(推送时遍历)
porcelain:user:follow:push:{uid} ZSET 关注的小V列表(score=互动值)
porcelain:user:follow:pull:{uid} ZSET 关注的大V列表(score=关注时间戳)
porcelain:user:push:status:{uid} SET 已推送内容ID(防策略切换重复推送)
porcelain:user:pull:record:{uid} Hash 大V outbox 增量拉取记录
porcelain:post:like:{pid} SET 点赞用户列表
porcelain:user:cache:{uid} STRING 用户信息缓存(5min TTL)

关键设计 :ZSET 用时间戳作 score,ZRANGEBYSCORE 天然支持游标分页,比 LIST 的 LRANGE 更灵活。

3.4.3 发布流程
python 复制代码
async def _push_to_feed(self, post: Post) -> None:
    """发布笔记后推送到粉丝收件箱(推拉结合 - 微博方案)

    - 用 HGET creator:level:{作者} strategy 替代 SCARD followers 判断大V
    - strategy=push(小V) → 推所有粉丝 inbox
    - strategy=pull(大V) → 只写 outbox,不推 inbox
    """
    timestamp_ms = int(post.created_at.timestamp() * 1000) if post.created_at else 0
    
    # 查创作者标签判断推拉策略(1次HGET,比SCARD快)
    strategy = await self._get_creator_strategy(post.user_id)
    
    # 始终写发件箱,确保拉模式补全能拉到历史帖子
    outbox_key = f"{REDIS_KEY_PREFIX}creator:feed:list:{post.user_id}"
    pipe = self.redis.pipeline()
    pipe.zadd(outbox_key, {str(post.id): timestamp_ms})
    pipe.zremrangebyrank(outbox_key, 0, -(self._settings.feed_inbox_max + 1))  # 裁剪至500条
    await pipe.execute()
    
    if strategy == "push":
        # 小V推模式: 推到所有粉丝收件箱
        followers_key = f"{REDIS_KEY_PREFIX}followers:{post.user_id}"
        follower_ids = await self.redis.smembers(followers_key)
        pipe = self.redis.pipeline()
        for fid in follower_ids:
            inbox_key = f"{REDIS_KEY_PREFIX}feed:timeline:push:{fid}"
            pipe.zadd(inbox_key, {str(post.id): timestamp_ms})
            pipe.zremrangebyrank(inbox_key, 0, -(self._settings.feed_inbox_max + 1))  # 裁剪至500条
            pipe.sadd(f"{REDIS_KEY_PREFIX}user:push:status:{fid}", str(post.id))  # 防策略切换重复
        await pipe.execute()
    # strategy == "pull" → 大V拉模式: 只写 outbox,跳过推 inbox

_get_creator_strategy 的延迟初始化post_service.py:847-869):

python 复制代码
async def _get_creator_strategy(self, uid: int) -> str:
    """标签不存在时用 SCARD 判断并初始化(避免每次发帖都 SCARD)"""
    key = f"{REDIS_KEY_PREFIX}creator:level:{uid}"
    strategy = await self.redis.hget(key, "strategy")
    if strategy is None:
        # 标签不存在: 用 SCARD 判断并初始化
        fan_count = int(await self.redis.scard(f"{REDIS_KEY_PREFIX}followers:{uid}"))
        strategy = "pull" if fan_count > self._settings.feed_push_threshold else "push"
        await self.redis.hset(key, mapping={
            "fans_count": fan_count,
            "strategy": strategy,
            "push_threshold": self._settings.feed_push_threshold,
        })
    return strategy
3.4.4 读取流程:三路合并

读 Feed 时合并三个来源(post_service.py:420-539):

python 复制代码
async def get_feed(self, user_id, max_time, offset, size):
    post_ids_with_scores = []
    
    # ① 读自己的 inbox (小V推过来的)
    inbox_key = f"{REDIS_KEY_PREFIX}feed:timeline:push:{user_id}"
    inbox_results = await self.redis.zrange(
        name=inbox_key, start=max_bound, end=0,
        desc=True, withscores=True, byscore=True,
        offset=offset, num=size + 1,
    )
    post_ids_with_scores.extend(inbox_results)
    
    # ② 查关注话题帖子
    topic_results = await self._fetch_followed_topic_post_ids_with_cursor(...)
    post_ids_with_scores.extend(topic_results)
    
    # ③ 拉模式补全: 遍历 user:follow:pull ZSET(关注的大V)
    pull_key = f"{REDIS_KEY_PREFIX}user:follow:pull:{user_id}"
    pull_ids = [int(vid) for vid in await self.redis.zrange(pull_key, 0, -1)]
    
    # 增量拉取: 首页刷新时用 user:pull:record 记录每个大V的上次拉取时间
    is_incremental = (max_time is None)  # 只有首页/刷新时用增量拉取
    pull_record_key = f"{REDIS_KEY_PREFIX}user:pull:record:{user_id}"
    pull_records = {k: int(v) for k, v in (await self.redis.hgetall(pull_record_key)).items()}
    
    # 分批并行拉取大V outbox (BATCH_SIZE=50)
    for vid in pull_ids:
        outbox_key = f"{REDIS_KEY_PREFIX}creator:feed:list:{vid}"
        # 增量模式: 只拉 (last_pull, query_max] 的增量
        lower_bound = f"({pull_records[str(vid)]}" if is_incremental and str(vid) in pull_records else "-inf"
        extra = await self.redis.zrange(
            name=outbox_key, start=max_bound, end=lower_bound,
            desc=True, withscores=True, byscore=True, offset=offset, num=size,
        )
        post_ids_with_scores.extend(extra)
    
    # 合并 + 去重 + 按 score 排序 + 取 size 条
    # ...

增量拉取的关键设计

  • 首页刷新时只拉 (last_pull, now] 的增量,不全量拉
  • 翻页时用游标 max_time 自然隔断,不依赖 pull_record
  • 大V outbox 为空时触发 DB 补推(_backfill_outbox
3.4.5 MQ 事件区分大小V
  • 小V 发 post.created → message-service 扇出通知给所有粉丝
  • 大V 发 post.created.vip不推通知,避免万级粉丝轰炸
python 复制代码
# 特殊处理: post.created 扇出给作者的每一位粉丝(小V 专属,大V 不发此事件)
3.4.6 粉丝列表自愈

post_service.py:822-845:Redis 粉丝列表为空时从 user-service 拉取重建:

python 复制代码
async def _heal_followers(self, uid, followers_key):
    """Redis 粉丝列表为空时从 user-service 拉取并重建"""
    http = HttpClient(base_url=settings.apisix_url)
    resp = await http.get(f"/internal/users/{uid}/follower-ids", ...)
    ids = resp.get("data", [])
    pipe = self.redis.pipeline()
    for fid in ids:
        pipe.sadd(followers_key, fid)
    await pipe.execute()

3.5 ES 商品搜索:multi_match + Completion Suggester + MySQL 降级

商品搜索是这个项目里 ES 唯一的应用场景,但实现得相当完整,包含 ES 优先 + MySQL LIKE 降级兜底。

3.5.1 索引设计
python 复制代码
INDEX_MAPPING = {
    "settings": {
        "number_of_shards": 1,
        "number_of_replicas": 0,
    },
    "mappings": {
        "properties": {
            # 搜索字段(ik_smart 中文分词,"青花瓷茶杯" → "青花瓷"+"茶杯")
            "title": {"type": "text", "analyzer": "ik_smart"},
            "description": {"type": "text", "analyzer": "ik_smart"},
            "kiln": {"type": "text", "analyzer": "ik_smart"},
            "craft": {"type": "text", "analyzer": "ik_smart"},
            "era": {"type": "text", "analyzer": "ik_smart"},
            # 过滤/排序字段
            "price": {"type": "double"},
            "status": {"type": "integer"},
            "created_at": {"type": "date"},
            "category_id": {"type": "integer"},
            "is_free_shipping": {"type": "boolean"},
            # 展示字段(不参与搜索,仅存储返回)
            "id": {"type": "integer"},
            "images": {"type": "keyword", "index": False},
            "seller_id": {"type": "integer", "index": False},
            "view_count": {"type": "integer", "index": False},
            "favorite_count": {"type": "integer", "index": False},
            "sales_count": {"type": "integer", "index": False},
            # Completion Suggester 联想字段(standard 按字分词,适合前缀匹配)
            "suggest": {
                "type": "completion",
                "analyzer": "standard",
                "preserve_separators": True,
            },
        }
    },
}

关键设计

  • 搜索字段用 ik_smart(粗粒度分词,"青花瓷茶杯" → "青花瓷"+"茶杯"),适合搜索召回
  • suggest 字段用 standard(按字分词),适合前缀联想匹配
  • 展示字段(images/seller_id/view_count 等)设 index: False,不建倒排索引,节省空间
3.5.2 multi_match 加权搜索
python 复制代码
SEARCH_FIELDS = ["title^2", "description^1", "kiln^0.5", "craft^0.5"]

body = {
    "size": size + 1,  # 多查一条判断 has_more
    "query": {
        "bool": {
            "must": [{
                "multi_match": {
                    "query": query,
                    "fields": SEARCH_FIELDS,
                    "type": "best_fields",
                }
            }],
            "filter": filter_clauses,  # [{"term": {"status": ProductStatus.ON_SALE}}]
        }
    },
    "_source": True,
    "sort": [{"_score": "desc"}, {"created_at": "desc"}],
}

标题权重 2,描述权重 1,窑口/工艺权重 0.5:让标题匹配的商品排在前面。

3.5.3 Completion Suggester 联想
python 复制代码
SUGGEST_MAX = 10

body = {
    "size": 0,
    "suggest": {
        "product_suggest": {
            "prefix": prefix,
            "completion": {
                "field": "suggest",
                "size": limit,  # 默认 10
            },
        }
    },
}

关键 :Completion Suggester 用 FST(Finite State Transducer)数据结构,前缀匹配性能 O(1),适合实时联想。

3.5.4 search_after 游标分页
python 复制代码
# search_after 游标分页
if cursor:
    try:
        sort_values = json.loads(cursor)
        body["search_after"] = sort_values
    except (json.JSONDecodeError, TypeError):
        pass

resp = await self.es.search(index=self._settings.es_index, body=body)
hits = resp.get("hits", {}).get("hits", [])

has_more = len(hits) > size
next_cursor = None
if has_more and hits[-1].get("sort"):
    next_cursor = json.dumps(hits[-1]["sort"])  # 把 sort 值作为游标

关键 :ES 的 search_afterfrom/size 更高效,深分页不会性能退化。

3.5.5 MySQL LIKE 降级
python 复制代码
async def search(self, query, cursor, size, db, filters):
    if self.es:
        try:
            result = await self._es_search(query, cursor, size, filters)
        except Exception:
            logger.error("ES 搜索失败,降级到MySQL query=%s", query, exc_info=True)
            result = await self._mysql_fallback_search(query, cursor, size, db, filters)
    else:
        # ES 不可用,直接走 MySQL LIKE
        result = await self._mysql_fallback_search(query, cursor, size, db, filters)
    return result

MySQL 降级用 LIKE %query% 匹配 title/description/kiln/craft 四个字段,不依赖 FULLTEXT 索引,完美支持中文关键词。

关键设计 :ES 不可用时自动降级到 MySQL LIKE,保证搜索功能可用性。降级模式不返回精确总数(total = len(items))。


3.6 MQ 事件驱动:Outbox 模式 + 双 exchange + 延迟消息

3.6.1 三个 Exchange
Exchange 类型 用途
porcelain.notification TOPIC 通知类事件(推送给用户)
porcelain.order TOPIC 业务事件(其他服务消费,如 product-service 同步销量)
porcelain.flashsale TOPIC 秒杀业务事件(flashsale.order.created)
porcelain.delayed x-delayed-message 延迟消息(订单超时取消)
3.6.2 双 exchange 发布

某些事件同时是通知事件和业务事件 (如 order.paid),需要发到两个 exchange:

python 复制代码
async def publish_event(routing_key, payload, delay_ms=0):
    if delay_ms > 0:
        # 延迟消息: 发到 porcelain.delayed
        return await publish(EXCHANGE_DELAYED, routing_key, payload, delay_ms=delay_ms)
    
    is_notification = routing_key in _NOTIFICATION_EVENTS
    is_business = routing_key in _ORDER_BUSINESS_EVENTS
    
    if is_notification and is_business:
        # 双 exchange 发布: notification + order
        # 两次都成功才算成功
        ok1 = await publish(EXCHANGE_NOTIFICATION, routing_key, payload)
        ok2 = await publish(EXCHANGE_ORDER, routing_key, payload)
        if not (ok1 and ok2):
            logger.error("[MQ] 双 exchange 发布不一致: notification={} order={}", ok1, ok2)
            return False
        return True
    elif is_notification:
        return await publish(EXCHANGE_NOTIFICATION, routing_key, payload)
    # ...
3.6.3 BaseConsumer:DLX + 重试 + 幂等

shared/mq_consumer.py 实现了 BaseConsumer 基类:

  • DLX(死信队列):消费失败的消息进入死信队列,避免阻塞主队列
  • 重试机制:失败后自动重试 N 次,超过次数进死信
  • 幂等保证 :消费者端用 Redis SETNX event_id + DB UNIQUE 兜底去重
3.6.4 延迟消息:订单超时取消
python 复制代码
# 创建订单时发延迟消息 (30 分钟后触发)
await publish(
    EXCHANGE_DELAYED,
    "order.timeout",
    {"order_id": order_id, "event_id": str(uuid.uuid4())},
    delay_ms=30 * 60 * 1000  # 30 分钟
)

RabbitMQ 的 rabbitmq-delayed-message-exchange 插件支持延迟投递,到时间自动路由到队列。


四、Redis 巧妙设计汇总

Redis 在这个项目里不只是缓存,它承担了多种角色。下面是全项目 Redis 的巧妙用法汇总:

4.1 在线状态管理(chat-service)

python 复制代码
# 在线状态 + 心跳, TTL 150s (心跳30s + 超时90s + 30s缓冲)
await redis.set(f"porcelain:ws:conn:{uid}", server_id, ex=150)
await redis.set(f"porcelain:ws:heartbeat:{uid}", str(time.time()), ex=150)

巧妙点:用 Redis 而不是内存,支持跨实例查询在线状态,为水平扩展铺路。

4.2 秒杀库存原子操作(flashsale-service)

python 复制代码
# Lua 脚本一次性完成: 状态校验 + 一人一单 + DECR + SET 标记
result = await redis.evalsha(lua_sha, 3, status_key, stock_key, order_key, temp_id, ttl)

巧妙点:DECR 是原子操作,配合 Lua 把 4 步合为 1 步,避免超卖。

4.3 防骚扰频控(chat-service)

python 复制代码
# 陌生人每日限 20 条, 按 (sender, target) 维度
count = await redis.incr(f"porcelain:chat:limit:{sender}{target}")
if count == 1:
    await redis.expire(key, 86400)

巧妙点:首次 INCR 才设 TTL,避免每次都刷新 TTL 导致永远不重置。

4.4 互关判断复用(chat-service + content-service)

python 复制代码
# 复用 user-service 维护的 following Set, pipeline 两次 SISMEMBER
pipe = redis.pipeline()
pipe.sismember(f"porcelain:following:{uid_a}", str(uid_b))
pipe.sismember(f"porcelain:following:{uid_b}", str(uid_a))
results = await pipe.execute()

巧妙点:不自己存一份关系,直接复用现有 Set,pipeline 一次 RTT。

4.5 用户信息三级缓存(chat-service)

python 复制代码
# Redis 缓存(5min) → HTTP 调用 user-service → 失败回退默认值
cache_key = f"porcelain:user:cache:{uid}"
cached = await redis.get(cache_key)
if cached:
    return json.loads(cached)
# ... 调 user-service → 回写缓存

巧妙点:避免每条消息都跨服务调用,5min TTL 平衡实时性和性能。

4.6 token_version 主动失效(user-service)

python 复制代码
# user-service/core/deps.py:45-64
async def get_current_user_id(authorization, auth_svc, db):
    """校验 access_token + token_version, 返回 user_id"""
    payload = auth_svc.decode(token, expected_type="access")
    
    # token_version 校验: 需查 DB (Q17 决策)
    # 注释: 实际可通过 Redis HASH 缓存 user_id -> token_version
    result = await db.execute(
        select(User.token_version, User.status).where(User.id == payload.user_id)
    )
    row = result.one_or_none()
    if row is None:
        raise unauthorized("用户不存在")
    cur_tv, status = row
    if payload.token_version != cur_tv:
        raise unauthorized("token 已失效, 请重新登录")
    return payload.user_id

巧妙点 :用版本号实现"主动失效"------改密/登出/封禁时 token_version += 1,旧 token 立即失效,无需黑名单表。

现状:目前是查 DB,代码注释里提到"可通过 Redis HASH 缓存"但未实现。每次请求多一次 DB 查询,是个优化点。

4.7 收藏计数 Set(content-service + product-service)

python 复制代码
# 商品收藏: SADD, 取消: SREM, 计数: SCARD
await redis.sadd(f"porcelain:product:fav:{pid}", str(uid))
await redis.srem(f"porcelain:product:fav:{pid}", str(uid))
count = await redis.scard(f"porcelain:product:fav:{pid}")

巧妙点:Set 天然去重,SCARD 是 O(1),比 COUNT(*) 快几个数量级。

注意 :只有商品收藏维护 Redis Set(product:fav:{pid}),帖子收藏只存 DB favorites 表,不维护 Redis Set。帖子点赞才维护 Redis Set(post:like:{pid})。

4.8 防重锁(order-service)

python 复制代码
# 防止用户快速双击重复下单
locked = await redis.set(f"porcelain:order:create:lock:{buyer_id}:cart", "1", ex=3, nx=True)
if not locked:
    raise param_invalid("请勿重复提交")

巧妙点SET NX EX 三合一,原子操作完成"设置+过期+不存在才设置"。

4.9 提醒推送防重(flashsale-service)

python 复制代码
# 每个活动只推送一次开窑提醒
pushed_key = f"porcelain:fs:remind:pushed:{act.id}"
if await redis.set(pushed_key, "1", ex=600, nx=True) is None:
    continue  # 已标记, 跳过

巧妙点SET NX 失败返回 None,一行代码完成"标记+检查"。

4.10 浏览历史 HyperLogLog(product-service)

python 复制代码
# 商品 UV 统计, 用 HyperLogLog 节省内存 (12KB 存 2^64 个元素)
await redis.pfadd(f"porcelain:product:hll:view:{pid}", str(user_id))
uv = await redis.pfcount(f"porcelain:product:hll:view:{pid}")

巧妙点:HyperLogLog 有 0.81% 误差,但 UV 这种场景完全可接受,内存占用从 GB 级降到 KB 级。

4.11 Redis Key 总览

类别 Key 模式 类型 用途
聊天 ws:conn:{uid} STRING WebSocket 在线状态
聊天 ws:heartbeat:{uid} STRING 心跳时间戳
聊天 chat:limit:{s}{t} STRING (counter) 陌生人每日限额
秒杀 fs:status:{aid} STRING 活动状态
秒杀 fs:stock:{aid} STRING 实时库存
秒杀 fs:order:{aid}:{uid} STRING 一人一单标记
秒杀 fs:remind:{aid} SET 开窑提醒列表
秒杀 fs:remind:pushed:{aid} STRING 提醒已推送防重
订单 order:create:lock:* STRING 防重锁
订单 order:pay:lock:* STRING 支付防重锁
订单 order:remind_ship:* STRING 提醒发货冷却
社区 feed:timeline:push:{uid} ZSET 收件箱(小V推过来的帖子)
社区 creator:feed:list:{uid} ZSET 发件箱(大V自己的帖子)
社区 creator:level:{uid} Hash 创作者标签(strategy: push/pull)
社区 following:{uid} SET 关注列表
社区 followers:{uid} SET 粉丝列表
社区 user:follow:push:{uid} ZSET 关注的小V列表
社区 user:follow:pull:{uid} ZSET 关注的大V列表
社区 user:push:status:{uid} SET 已推送内容ID(防重复)
社区 user:pull:record:{uid} Hash 大V outbox 增量拉取记录
社区 post:like:{pid} SET 帖子点赞列表
商品 product:fav:{pid} SET 商品收藏列表
商品 product:hll:view:{pid} HyperLogLog 商品 UV
商品 product:cache:{pid} STRING 商品信息缓存
用户 user:cache:{uid} STRING 用户信息缓存

五、APISIX 网关:动态路由 + CORS + WebSocket

5.1 APISIX 在架构中的角色

APISIX 承担统一对外 API 网关 + 服务间通信枢纽两个角色:

复制代码
App → Nginx → APISIX → 各微服务
服务A → APISIX → 服务B (通过 /internal/* 接口)

5.2 配置:etcd 动态配置中心

docker/apisix/config.yaml

yaml 复制代码
apisix:
  node_listen: 9080
  enable_admin: true
  config_center: etcd   # 配置走 etcd, 通过 Admin API 修改后秒级生效

deployment:
  admin:
    admin_key:
      - name: admin
        key: edd1c9f034335f136f87ad84b625c8f1
        role: admin
  etcd:
    host: ["http://etcd:2379"]
    prefix: "/apisix"

关键 :路由/插件配置全部走 etcd,通过 Admin API (PUT /apisix/admin/routes/{id}) 修改后立即生效,无需重启 APISIX

5.3 路由表:35 条覆盖 9 个服务

backend/tools/init_apisix.py

python 复制代码
ROUTES = [
    # 对外 API
    (10, "auth",            "/api/v1/auth/*",              8001),
    (13, "product-list",    "/api/v1/products",             8003),
    (14, "product-detail",  "/api/v1/products/*",          8003),
    (28, "chat-api",        "/api/v1/conversations*",       8006),
    # WebSocket
    (33, "chat-ws",         "/ws/chat",                     8006),  # enable_websocket=True
    # 内部 API
    (50, "internal-users",      "/internal/users/*",       8001),
    (51, "internal-products",   "/internal/products/*",    8003),
    # ... 共 35 条
]

注册时:

python 复制代码
payload = {
    "name": name,
    "uri": uri,
    "upstream": {"type": "roundrobin", "nodes": {f"{service}:{port}": 1}},
}
if ws:
    payload["enable_websocket"] = True   # WebSocket 路由要显式启用

r = requests.put(f"{API}/{rid}", headers={"X-API-KEY": KEY}, json=payload)

5.4 全局 CORS 规则

python 复制代码
def setup_cors(s):
    """配置 APISIX 全局 CORS 插件(对所有路由生效)"""
    cors_payload = {
        "plugins": {
            "cors": {
                "allow_origins": "http://localhost:8080,http://localhost:5173,...",
                "allow_methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
                "allow_headers": "Authorization,Content-Type,...",
                "allow_credential": True,
            }
        }
    }
    s.put("http://127.0.0.1:9180/apisix/admin/global_rules/1", json=cors_payload)

关键 :用 global_rules/1 而不是给每条路由都加 CORS 插件,一次配置全路由生效。

5.5 WebSocket 代理

python 复制代码
(33, "chat-ws", "/ws/chat", 8006),  # enable_websocket=True

APISIX 处理 WebSocket 的逻辑:

  1. 收到 Upgrade: websocket 请求头
  2. 完成 HTTP → WebSocket 协议升级握手
  3. 建立客户端 ↔ chat-service 的双向字节流管道
  4. 后续 WS 帧不再经过插件链处理(性能优化)

Token 通过 query 参数传递:ws://apisix:9080/ws/chat?token=xxx(浏览器 WS 不支持自定义头)。

5.6 服务间调用统一走 APISIX

backend/shared/http_client/__init__.py

python 复制代码
class HttpClient:
    def __init__(self, base_url="http://apisix:9180", timeout=5.0, max_retries=2):
        self.base_url = base_url.rstrip("/")
    
    async def request(self, method, path, *, trace_id=None):
        merged_headers = {
            "X-Request-ID": trace_id or uuid.uuid4().hex,
            "X-Internal-Token": settings.internal_token,
            "Content-Type": "application/json",
        }
        # 重试 + 超时 + httpx.AsyncClient 连接池
        for attempt in range(self.max_retries + 1):
            try:
                resp = await self._client.request(method, path, headers=merged_headers)
                resp.raise_for_status()
                return resp.json()
            except (httpx.TimeoutException, httpx.NetworkError) as e:
                if attempt >= self.max_retries:
                    raise

⚠️ 注意 :默认 base_url 是 http://apisix:9180(Admin API 端口,不是 数据平面 9080)。这是个 bug,靠 Nacos 配置 apisix_url: http://apisix:9080 覆盖才正常工作。各服务实际用的是 settings.apisix_url 而非默认值。

所有跨服务调用必经 APISIX ,用 X-Internal-Token 共享密钥校验。

5.7 Nginx → APISIX 衔接

docker/nginx/nginx.conf

nginx 复制代码
server {
    listen 80;

    location / {
        root   /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://apisix:9080/;   # 剥掉 /api 前缀
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

运营后台特殊:admin-bff 不走 APISIX,由 Nginx 直连 :8010。


六、踩坑、缺陷与改进

这部分是诚实的复盘。一个项目最大的价值不只是"做对了什么",更在于"哪些地方做得简陋、为什么、怎么改进"。

6.1 APISIX 退化成"动态反向代理"

原设计:双层鉴权(网关 jwt-auth + 服务层 token_version)+ 限流插件 + 熔断插件 + Nacos 服务发现

实际现状

能力 原设计 实际现状
JWT 网关鉴权 jwt-auth 插件 全部 jwt_auth=False,靠服务层自救
限流插件 limit-req 秒杀兜底 没启用,全靠各服务 Redis 自己做
熔断插件 circuit-breaker 没启用
Nacos 服务发现 upstream 自动拉实例 写死 Docker 服务名,无法水平扩展
HTTPS 终结 :9443 终结 TLS 没配证书

6.2 实时聊天不支持多实例

问题ConnectionManager 是进程内单例,send_to_user 只能推本进程内的连接。

复制代码
A 连接 chat-service 实例1
B 连接 chat-service 实例2
A 发消息给 B → 实例1 找不到 B 的连接 → 推送失败 → 走离线推送

根因:没有 Redis Pub/Sub 跨实例广播。

后续改进计划 :发送消息时除了查本实例内存,还要 PUBLISH porcelain:ws:msg:{uid} payload,所有实例订阅这个频道收到后转发给本实例的连接。

6.3 没有可观测性

问题

  • 没有 Prometheus 指标暴露
  • 没有 Grafana 仪表盘
  • 没有链路追踪(SkyWalking/Zipkin)
  • 没有慢查询日志

根因:开发期聚焦功能,运维侧能力空缺。

改进建议

  1. FastAPI 加 prometheus-fastapi-instrumentator
  2. APISIX 启用 prometheus 插件
  3. 引入 OpenTelemetry + Jaeger

6.4 实时聊天的功能性缺陷

  • 没有群聊
  • 没有消息撤回/编辑
  • 没有消息已读回执(只有 last_read_time,没主动推送)
  • 没有输入中状态("对方正在输入...")
  • 没有富媒体消息(语音/视频/文件)
  • 没有消息搜索

6.5 频控维度太细

问题 :聊天频控按 (sender, target) 维度计数,意味着可以骚扰 100 个不同的人各 20 条。

改进建议 :加一个 sender 总维度限制(如陌生人每日总消息数 100 条)。

6.6 server_id 硬编码

问题server_id = "chat-service-1" 写死,多实例时无法区分。

改进建议:从环境变量或 Nacos 注册的实例 ID 读取。

6.7 admin API 安全风险

问题

  • allow_admin: 0.0.0.0/0 任意 IP 可访问 APISIX Admin API
  • admin key edd1c9f034335f136f87ad84b625c8f1 是 APISIX 默认示例 key,没改

改进建议

  1. 限制 IP 白名单
  2. 轮换 admin key
  3. 生产环境关闭 Admin API 或加 mTLS

七、总结

7.1 这个项目的价值

瓷韵这个项目最大的价值,不在于它做了多少功能,而在于它用一个学习级项目的体量,复刻了工业级微服务的几乎所有典型场景

  • 高并发:秒杀 Lua + Outbox 异步建单
  • 实时通信:WebSocket + Redis 在线状态 + MQ 离线推送
  • 事件驱动:RabbitMQ + Outbox 模式 + 双 exchange + 延迟消息
  • 分布式事务:Saga 同步补偿 + 乐观锁竞态防护
  • 全文检索:Elasticsearch multi_match + Completion Suggester
  • 第三方集成:支付宝沙箱 + 快递鸟 API + Dify AI
  • 缓存设计:Redis 11 种用法(在线状态/库存/频控/互关/缓存/防重/计数/HLL...)

7.2 学到的核心设计模式

模式 应用场景 核心思想
Outbox 模式 秒杀建单、订单事件 DB 写入和 MQ 发送同事务,保证一致性
Saga 模式 普通下单扣库存 同步调用 + 失败补偿回滚
读写分离(推拉结合) Feed 流 小V 写扩散,大V 读扩散
会话级已读 聊天已读 不在消息表存 is_read,用 last_read_time 判断
三层心跳 WebSocket 保活 客户端心跳 + 服务端 pong + 后台扫描
版本号失效 JWT 踢人 token_version +1,旧 token 失效,无需黑名单
游标分页 Feed/会话/消息列表 用 ID/时间作游标,避免数据漂移
延迟消息 订单超时取消 RabbitMQ x-delayed-message 插件
双 exchange 发布 order.paid 等事件 同时是通知和业务事件,发两个 exchange

7.3 最大的遗憾

如果重来一次,最想补的三件事:

  1. 可观测性优先:开发第一天就上 Prometheus + Grafana + 链路追踪,而不是等功能写完再补
  2. APISIX 真正发挥能力:jwt-auth + limit-count + service discovery,而不是退化成反向代理
  3. 实时聊天支持多实例:第一天就设计 Redis Pub/Sub 跨实例广播,避免后续重构

7.4 给学习者的建议

如果你想做一个类似的项目练手,建议按这个顺序:

  1. 先把单服务跑通:一个 FastAPI + MySQL + Redis,能完成 CRUD
  2. 再拆微服务:拆出 2-3 个服务,引入 Nacos + APISIX
  3. 加 MQ:先实现 publish/consume,再加 Outbox 模式
  4. 加秒杀:先 Redis DECR,再加 Lua 脚本,最后加 Outbox 异步建单
  5. 加聊天:先单实例 WebSocket,再加 Redis 在线状态,最后考虑多实例
  6. 加搜索:先 MySQL LIKE,再上 ES
  7. 最后补可观测性:Prometheus + Grafana + 链路追踪

不要一上来就追求"工业级",先把"能跑"做到,再迭代到"能扛",最后才是"能扩"。


写在最后:这个项目并不完美,文档里设计的能力有些没落地,有些地方明显是"先跑通再优化"的痕迹。但正因如此,它才是一个真实的学习项目------不是 demo,不是玩具,而是一个能让你看到"从理想到现实落差"的项目。

相关推荐
_abab1 小时前
Java面试宝典:从基础到架构2
java·面试·架构
会周易的程序员1 小时前
microLog 的 log_reader 架构解析:嵌入式日志检索引擎的设计之道
c++·物联网·架构·日志·iot·aiot
咕噜咕噜的猪大侠1 小时前
《K8s 核心知识梳理:Deployment 滚动更新、JobCronJob、DaemonSet 和 Service》
云原生·容器·kubernetes
虚心的百褶裙1 小时前
远程调用服务架构设计及zookeeper技术详解(下篇)
分布式·zookeeper·云原生
Geek-Chow1 小时前
微服务认证与授权:08 — OPA(PDP)
微服务·云原生·架构
阿标在干嘛1 小时前
100+微服务的统一出入口:政策快报平台的API网关设计
微服务·云原生·架构
摇滚侠2 小时前
云原生 Java 架构师的第一课 K8s+Docker+KubeSphere+DevOps 26-40
java·云原生·kubernetes
极***技2 小时前
2026云原生核心!Go高并发限流实战,从原理到落地
开发语言·云原生·golang
万联WANFLOW15 小时前
SD-WAN 控制平面高可用怎么做?SDWAN 控制器挂了,全网会发生什么
运维·网络·分布式·架构