阿飞商城:一个微服务电商后端的完整记录
写完这个项目,我最值钱的不是"会用 Seata",而是搞清楚了几件事为什么必须这么设计。
为什么做它
学技术栈的时候,问题一个个都能跑 demo:Nacos 能注册、Seata 能回滚、Redis 能加锁。但把它们拼成一个真实业务,问题就变了:库存扣了订单没建怎么办?消息发出去服务挂了怎么办?用户手快点了两下提交怎么办?
这些没有 demo 可抄,只能自己想明白。所以这个项目的目标从一开始就定成:覆盖「用户 → 商品 → 购物车 → 订单 → 支付 → 搜索 → 通知」这条完整购物链路,每个环节都至少有一处需要动脑子的地方。
它长什么样
9 个模块(含一个公共模块),60 个接口,6550 行 Java。
| 模块 | 端口 | 干什么 | 自己的库 |
|---|---|---|---|
| mall-common | — | 统一返回体、异常、Feign 客户端、工具 | — |
| mall-gateway | 8000 | 路由转发 + 统一鉴权 + 透传身份 | — |
| mall-auth | 8010 | 注册、登录、登出、刷新 | 主要在 Redis |
| mall-user | 8020 | 用户资料、收货地址、头像 | afei_user(2 表) |
| mall-product | 8030 | 分类、品牌、SPU/SKU、规格、库存 | afei_product(8 表) |
| mall-cart | 8040 | 购物车 | afei_cart(1 表)+ Redis |
| mall-order | 8050 | 下单、订单状态、超时关单 | afei_order(5 表) |
| mall-pay | 8060 | 支付、支付回调、金额校验 | afei_pay(3 表) |
| mall-search | 8070 | Elasticsearch 商品搜索 | — |
| mall-notify | 8080 | 站内信、可靠投递 | afei_notify(1 表) |
划分的四条硬规矩
这几条是踩过之后才定下来的,不是设计图上抄的。
1. 每个服务各持一份数据库,不共享
mall-order 想看商品,只能通过 mall-product 的 HTTP 接口拿,不能直接连对方的库。这条规矩一立,afei_* 之间就没有跨库 join,undo_log 也能各自独立——Seata AT 模式要求的正是这个。
2. 跨服务调用只走 HTTP(OpenFeign),不跨库
宁可多一次网络往返,也不要让两个服务的数据耦合在一起。代价是链路变长,好处是任何一个服务挂掉,只影响它自己那段链路。
3. 购物车放 Redis,不入库
购物车是高频写、低价值、允许丢的数据。放 Redis 之后,"加购"这个高频操作不再打 MySQL,数据库的压力小了很多。
4. 公共的东西放 mall-common,但只放"确定不会变"的
统一返回体、全局异常、Feign 接口声明放 common。但业务规则不放——common 一旦塞进业务逻辑,所有服务都被它绑住了。
几个真正的硬活
防超卖:两层防线,缺一不可
第一层是原子 SQL,这是真正的不变量:
UPDATE sku SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num}
stock >= num 这个条件让判断和扣减在同一条 SQL 里完成,不存在"查出来还有 5 份、然后 6 个人同时扣"的窗口。
第二层是 Redisson 分布式锁(stock:lock:{skuId}),作用是减少无效竞争:
RLock lock = redissonClient.getLock("stock:lock:" + skuId);
locked = lock.tryLock(3, TimeUnit.SECONDS);
两层各管一件事:SQL 保证正确性,锁保证高并发下大部分请求能被"快速失败"而不是都去撞数据库。
各自会怎么失效我也想过:锁会因为 Redis 主从切换丢 key 而失效;SQL在极端并发下依然安全。所以锁可以优化,SQL 不能省。
幂等:Redis 键 + 失败立即释放
下单接口用 setIfAbsent(key, "1", TTL) 抢占,第一个请求拿到就往下走,后面的直接返回"订单提交中"。
有个细节是后来补的:业务失败要立刻删掉幂等键。
try {
return doCreateOrder(userId, dto);
} catch (Exception e) {
stringRedisTemplate.delete(idemKey); // ← 不删的话,用户重试会被自己上一次的失败挡住
throw e;
}
不删这个键,用户第一次失败后再也提交不了,客服都查不出来。
订单快照:把商品信息冻结在下单那一刻
order_item 里直接存了 sku_name、sku_pic、price(单位分),不 JOIN 商品表。
代价是冗余存储,收益是历史订单永远是对的。如果只存 sku_id,商家改了价格、下了架,历史订单展示会跟着变——财务对不上账,用户也会来投诉"我下单时不是这个价"。
→ 展开:订单快照设计
订单超时关闭:三层兜底
光靠延迟消息不够——消息可能丢、消费者可能重启。所以做了三层:
- 延迟消息(RabbitMQ
x-delayed-message):主路径,30 分钟未支付自动关单 - 定时任务:每分钟扫一遍超时订单,兜住消息丢失的情况
- 库存回补防护:单独建了
order_stock_retry表,重复回补会被挡掉
三层里任何一层单独用都会漏。
→ 展开:订单超时关闭
下单这一条链路
它把幂等、Seata、金额重算、快照四件事串在一起,是整个项目最复杂的一段。
金额必须重算,不能信前端传来的价格:
// 前端传的 price 一律不用,以服务端从商品服务查到的为准
BigDecimal realAmount = skuInfo.getPrice().multiply(BigDecimal.valueOf(qty));
Seata AT 模式保证"扣库存 + 建订单"要么都成功要么都回滚。它不是"数据库自动回滚",而是每个库都有一张 undo_log,全局事务结束时对比前后镜像,反向生成补偿 SQL——三个库里都有这张表就是这个方案落地过的证据。
→ 展开:下单这条链路
JWT 必须带 jti
同一个坑:JWT 载荷里只有 iat/exp,精确到秒且没有随机成分,于是同一秒内签发的两个 Token 完全相同。
后果是:用户"登出 → 立刻重新登录",会拿到刚刚被拉黑的那个 Token,直接被拦在门外。
修复很简单,加一个随机 UUID 当 jti。而这类 bug 手工测很难撞上——我是做全量回归脚本时被自动撞出来的。
→ 展开:JWT 载荷里必须带 jti
Gateway 统一鉴权
网关做全局过滤器:白名单放行,其余校验 JWT,解析出用户身份后通过 X-User-Id、X-User-Role 请求头透传给下游服务。
下游服务因此不需要再解析 JWT 也不用查用户表——鉴权集中在入口,内部服务信任网关注入的头。风险是要防止外部伪造这两个头,所以网关要把它们覆盖掉而不是"有就透传"。
站内信:本地消息表
"订单支付成功后发站内信",如果直接发 MQ,服务在"发完消息"和"写业务数据"之间挂了,消息丢了用户永远收不到。
做法是同一个事务里写消息表,再由后台任务投递 MQ,失败重试。这样"发通知"这件事和业务数据同生共死,最终一致。
搜索:ES + MQ 异步同步索引
商品数据在 MySQL,搜索在 ES,两边不可能强一致。做法是商品变更后发 MQ,消费者同步索引。代价是有个延迟窗口,商品刚改完几秒内搜不到——对电商搜索可以接受。
限流:Sentinel
下单接口挂 @SentinelResource,配 SentinelResourceAspect 注册切面,让限流降级走统一的异常处理,返回友好的提示而不是直接 500。
数字
9 个模块(+1 公共) 60 个接口 8 条网关路由
6550 行 Java 170 个文件 26 次提交
7 个独立数据库 20 张业务表
Seata / RabbitMQ / Elasticsearch / Redisson / Nacos / Sentinel / Gateway 全部实际接入
我知道它还差什么
不吹的部分:
- 没有压测数据。所有"高并发下不会超卖"都是推理出来的,没有真正打过 1 万 QPS
- 没有分布式事务的异常演练。Seata 回滚我是主动抛异常验证的,没模拟过网络分区、TM 宕机这些真实故障
- 没有链路追踪。一个请求跨 5 个服务,出问题只能靠逐个服务翻日志
- 没有 CI。全量回归是我写脚本手动跑的,不是流水线
面试怎么讲这个项目
如果只有两分钟,我会这么讲:
我做了一个微服务电商后端,9 个服务各持一份数据库,跨服务只走 HTTP 调用。
最花时间的是三处:防超卖我用了原子 SQL 加 Redisson 锁两层,并且能说清各自失效的场景;
订单超时关单做了延迟消息、定时任务、库存回补防护三层,因为单靠任何一层都会漏;
还有个 JWT 的坑,同一秒签发的 Token 完全相同,导致登出后立刻重登被拦——这类问题手工测很难发现,是我做自动回归时撞出来的。
技术栈在简历上只占一行,这三个点才是这个项目真正的价值。
代码和架构图在 GitHub。