阿飞的博客

阿飞商城:一个微服务电商后端的完整记录

写完这个项目,我最值钱的不是"会用 Seata",而是搞清楚了几件事为什么必须这么设计。

为什么做它

学技术栈的时候,问题一个个都能跑 demo:Nacos 能注册、Seata 能回滚、Redis 能加锁。但把它们拼成一个真实业务,问题就变了:库存扣了订单没建怎么办?消息发出去服务挂了怎么办?用户手快点了两下提交怎么办?

这些没有 demo 可抄,只能自己想明白。所以这个项目的目标从一开始就定成:覆盖「用户 → 商品 → 购物车 → 订单 → 支付 → 搜索 → 通知」这条完整购物链路,每个环节都至少有一处需要动脑子的地方。

它长什么样

9 个模块(含一个公共模块),60 个接口,6550 行 Java。

模块端口干什么自己的库
mall-common—统一返回体、异常、Feign 客户端、工具—
mall-gateway8000路由转发 + 统一鉴权 + 透传身份—
mall-auth8010注册、登录、登出、刷新主要在 Redis
mall-user8020用户资料、收货地址、头像afei_user(2 表)
mall-product8030分类、品牌、SPU/SKU、规格、库存afei_product(8 表)
mall-cart8040购物车afei_cart(1 表)+ Redis
mall-order8050下单、订单状态、超时关单afei_order(5 表)
mall-pay8060支付、支付回调、金额校验afei_pay(3 表)
mall-search8070Elasticsearch 商品搜索—
mall-notify8080站内信、可靠投递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 不能省。

→ 展开:防超卖:分布式锁和原子 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,商家改了价格、下了架,历史订单展示会跟着变——财务对不上账,用户也会来投诉"我下单时不是这个价"。

→ 展开:订单快照设计

订单超时关闭:三层兜底

光靠延迟消息不够——消息可能丢、消费者可能重启。所以做了三层:

  1. 延迟消息(RabbitMQ x-delayed-message):主路径,30 分钟未支付自动关单
  2. 定时任务:每分钟扫一遍超时订单,兜住消息丢失的情况
  3. 库存回补防护:单独建了 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。

标签 Spring Cloud微服务RedisSeata踩坑记录

评论

0
加载中…
无需登录 · 同一 IP 每分钟限 3 条