本文以一个真实项目「瓷韵」为基础,复盘其架构设计、核心技术实现与踩过的坑。
一、项目背景与技术栈
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:
- 原子性:4 步(状态校验 → 一人一单 → DECR → SET 标记)在 Redis 单线程里一次性执行完,不会被其他请求打断
- 网络往返从 4 次 RTT 降到 1 次:1000 并发下网络开销省了 75%
- 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_b,messages 表故意不加 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。卖家发货时:
- 调 8003 轨迹地图版即时查询,获取初始轨迹 + 地图 URL + 经纬度
- 调 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_after 比 from/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 的逻辑:
- 收到
Upgrade: websocket请求头 - 完成 HTTP → WebSocket 协议升级握手
- 建立客户端 ↔ chat-service 的双向字节流管道
- 后续 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)
- 没有慢查询日志
根因:开发期聚焦功能,运维侧能力空缺。
改进建议:
- FastAPI 加
prometheus-fastapi-instrumentator - APISIX 启用 prometheus 插件
- 引入 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,没改
改进建议:
- 限制 IP 白名单
- 轮换 admin key
- 生产环境关闭 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 最大的遗憾
如果重来一次,最想补的三件事:
- 可观测性优先:开发第一天就上 Prometheus + Grafana + 链路追踪,而不是等功能写完再补
- APISIX 真正发挥能力:jwt-auth + limit-count + service discovery,而不是退化成反向代理
- 实时聊天支持多实例:第一天就设计 Redis Pub/Sub 跨实例广播,避免后续重构
7.4 给学习者的建议
如果你想做一个类似的项目练手,建议按这个顺序:
- 先把单服务跑通:一个 FastAPI + MySQL + Redis,能完成 CRUD
- 再拆微服务:拆出 2-3 个服务,引入 Nacos + APISIX
- 加 MQ:先实现 publish/consume,再加 Outbox 模式
- 加秒杀:先 Redis DECR,再加 Lua 脚本,最后加 Outbox 异步建单
- 加聊天:先单实例 WebSocket,再加 Redis 在线状态,最后考虑多实例
- 加搜索:先 MySQL LIKE,再上 ES
- 最后补可观测性:Prometheus + Grafana + 链路追踪
不要一上来就追求"工业级",先把"能跑"做到,再迭代到"能扛",最后才是"能扩"。
写在最后:这个项目并不完美,文档里设计的能力有些没落地,有些地方明显是"先跑通再优化"的痕迹。但正因如此,它才是一个真实的学习项目------不是 demo,不是玩具,而是一个能让你看到"从理想到现实落差"的项目。