多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

电商平台软件架构落地:从架构风格到高并发设计的完整路径

电商平台软件架构落地:从架构风格到高并发设计的完整路径 简介这份《电商平台软件架构.pdf》是一份系统梳理电商核心系统设计的入门级资料适合刚接触架构设计、想了解中台与基础服务协同方式的开发或运维人员。包内仅1个PDF文件压缩包约342KB虽体量不大但内容覆盖从订单、库存、支付到消息队列、缓存、数据存储等完整链路适合作为快速认识电商全局架构的参考手册。已有103人学习下载。PDF重点展开中台服务、分布式缓存、消息队列、数据读写分离与Ogg同步等关键模块并配以“黄金超市系统架构图”这类整体视图可帮读者建立从客户端、第三方接口到中央主库、读库、写库的立体认知其中订单流程、库存扣减、支付状态同步等环节也结合图示做了梳理便于理解大型电商系统如何保证高并发下的数据一致与稳定扩展。1. 电商平台软件架构.pdf地图不是路面落地才是分水岭「电商平台软件架构.pdf」这类文档我每年都会打开好几次商品、订单、库存、支付几个方框整整齐齐箭头指向也讲得通。但真正动手的人很快会发现图上的每一条连线在真实系统里都要被超时、重试、幂等、并发这些细节填满。方块之间谁依赖谁、状态放在谁手里、数据怎么流动才是这份架构能不能活下去的关键。这篇文章不替某一份PDF背书而是按这类架构文档最常见的设计路线讲清楚从选型到落地的完整路径。适合刚接手电商后端、或者正在做架构评审想把方案推给团队的人。下文顺序就是我落地一套电商后端的实际顺序先定软件架构风格与边界再落交易链路接着处理高并发读写最后用排查和压测把这些决定验证一遍而不是拿到图就开始写代码。2. 软件架构风格与分层先定风格再把PDF里的方框变成模块PDF里最常见的图是分层图Web层、业务层、数据层再来几条虚线表示消息队列。这个层面的图没有错但它没有回答一个前置问题这套系统到底采用哪种软件架构风格。风格决定后续每一个服务的边界、一致性成本、以及你团队能不能维护得住。风格选错了后面所有模块图都只是看起来合理。2.1 软件架构风格怎么选三种常见风格的选型对比我见过的电商架构风格绝大多数落在三种形态里分层单体、微服务、事件驱动。它们不是互斥关系而是不同阶段和不同链路的组合但很多人把“画了微服务”当成目标这是最大的误判。软件架构风格典型形态一致性成本扩展性适合什么时候分层单体单库单应用Controller-Service-DAO最低数据库事务兜底中等只能整机扩团队10人以内业务边界还在剧烈变化微服务每个业务域独立服务、独立存储高要处理分布式事务和最终一致高可独立扩缩容团队规模大部署冲突频繁资源瓶颈集中在单模块事件驱动通过MQ广播领域事件下游订阅高需要事件幂等和溯源取决于事件流设计只推荐用于特定链路如订单履约通知、积分变动我一般不会一上来就拆微服务。新项目或者刚接手的存量项目先用模块化单体把业务跑通模块之间用接口隔离。等到并发量压到单库单应用的极限、并且团队消化得过来再按领域边界拆。真正需要事件驱动的只是少数链路比如下单成功后要通知物流、营销、数据分析这些链路天然可以异步才值得引入消息队列。不要把整个系统都设计成事件驱动否则排查问题时你连订单当前状态在哪一步都说不清。2.2 从上下文图开始做依赖盘点别急着写代码拿到架构PDF后第一件事不是画新图而是列一张依赖盘点表。这个习惯我从做支付系统时养成的能避免后面大量返工。做法是把你已知的外部依赖、内部模块、数据存储全部列出来标清楚方向、同步异步和故障影响。依赖对象调用方向同步/异步故障影响支付渠道订单域 → 支付渠道同步但必须配合回调用户无法支付订单停留在初始态物流服务订单域 → 物流域异步MQ发货延迟不影响下单短信/消息通知订单域 → 通知服务异步MQ用户收不到通知不影响核心链路库存系统下单链路 → 库存域同步强一致无法下单或超卖直接影响成交这张表的价值在于把PDF里那些笔直的箭头还原成真实系统中的「风险点」。同步调用意味着链路故障会直接放大异步调用意味着消费者必须幂等。依赖方向反了是翻车现场比如让商品域反向依赖订单域查销量订单域一波动商品详情页也跟着抖。理清之后你会发现核心链路的依赖必须保持单向外部依赖永远在边界上不允许业务核心反向感知它们。2.3 把分层翻译成工程目录依赖方向必须向内很多团队把“分层”理解成 Controller-Service-DAO 三层结果所有逻辑都堆在 Service 里订单、库存、营销代码互相引用。我用的更接近六边形思路的简化版接口层、应用层、领域层、基础设施层。PDF里的逻辑分层落到工程上应该是这样的结构com.example.mall ├── interfaces # 接口层Controller、MQ消费、定时任务入口 ├── application # 应用层用例编排、事务边界、DTO转换 ├── domain # 领域层实体、值对象、状态机、领域服务 └── infrastructure # 基础设施层Repository实现、RPC客户端、缓存、MQ这个结构最关键的是依赖方向domain 不依赖任何外层infrastructure 去实现 domain 定义的接口application 只编排 domain 里的能力。这样做的好处是哪天要把订单域拆出去独立部署复制 domain 和 application 的代码就能走不用在 Controller 里挖业务逻辑。代码审查时我会直接看 import如果 domain 里出现了 Redis、MQ、第三方 SDK 的包名说明边界已经破了要立刻改。2.4 拆微服务的触发信号忍住别拆也是一种架构拆服务不是架构师的 KPI。我判断是否要拆只看三个信号第一发布频繁互相踩一个小改动要拉六个团队对时间第二某个模块的资源瓶颈独立于其他模块比如商品搜索把 CPU 打满订单服务被迫跟着扩容第三团队人数多到代码评审已经走不过来了。三个信号都没出现时模块化单体依然是最好的架构。真要拆也别按 PDF 的方框一个个拆。我一般先拆读多写少的域比如商品、搜索、商品详情页缓存因为这些域对外依赖少拆出去风险低订单和支付这类强一致性的域留着最后拆而且拆的时候必须先把状态机和幂等方案设计好否则线上对账对不上返工成本极高。3. 核心交易链路商品、库存、订单、支付的状态机与数据模型架构风格定完之后真正花时间的是交易链路的数据模型。电商系统里商品可以乱一点搜索可以慢一点但订单和库存绝对不能出错。这一章把核心域拆清楚然后重点落到订单状态机、表结构、以及下单链路的一致性方案。3.1 先划清有界上下文哪些表该归哪个域电商平台软件架构里最容易出问题的不是代码写得差而是数据放错了地方。我见过把库存字段放在商品表里、把优惠金额塞进订单表的架构前几个月没事一旦促销活动叠加对账就会乱。业务域典型实体主要消费方一致性要求商品域商品、SKU、类目、品牌前台详情页、搜索、购物车最终一致即可库存域库存流水、库存快照下单、履约、采购强一致扣减必须原子订单域订单、订单明细、状态变更记录用户端、支付、售后强一致状态流转受约束支付域支付单、退款单、渠道流水订单、对账、财务强一致幂等优先营销域优惠券、活动、促销规则订单计价、用户中心弱一致但需要有快照用户域用户、地址、账户所有域最终一致一个容易被忽略的归属问题是价格。商品域管的是市场价和展示价营销域管的是优惠计算规则订单域只负责在生成订单那一刻把最终成交价、优惠明细、商品快照固化下来。千万不要让订单实时去算营销价格否则营销改规则历史订单全变样。数据归属清楚了服务边界才可能清楚。3.2 订单状态机先定义流转表再写业务代码订单状态是交易链路的核心状态我强烈建议所有状态流转都收敛到一个状态机里禁止在业务代码里散落 if/else 去改状态。以最常见的电商订单为例主流程是已创建 → 已支付 → 已发货 → 已完成周边还有已取消、退款中等状态。当前状态触发事件新状态约束条件已创建用户支付成功回调已支付必须是创建态且金额一致已创建超时未支付已取消由定时任务扫描触发已支付商家发货已发货必须已支付且校验物流单号已发货用户确认收货已完成必须已发货已支付用户申请退款退款中必须已支付且未发货落到代码里我用一个枚举加一张流转表就能把规则表达清楚public enum OrderStatus { CREATED(10), PAID(20), SHIPPED(30), COMPLETED(40), CANCELED(50); private final int code; private static final MapString, OrderStatus NEXT new HashMap(); static { NEXT.put(CREATED:PAY_SUCCESS, PAID); NEXT.put(CREATED:TIMEOUT_CANCEL, CANCELED); NEXT.put(PAID:SHIP, SHIPPED); NEXT.put(SHIPPED:CONFIRM, COMPLETED); } public OrderStatus next(String event) { OrderStatus target NEXT.get(this.name() : event); if (target null) { throw new IllegalStateException(非法订单状态流转: this - event); } return target; } }这段代码的逻辑很简单所有合法流转都显式定义在映射表里非法流转直接抛异常。线上出问题时你只需要看异常信息而不是去几十个 Service 方法里翻状态赋值。参数说明枚举里的 code 是落库的整型值方便数据库索引事件名用大写常量风格和支付回调、MQ 消息里的 event 一一对应。注意点在于状态机的校验必须放在应用层事务的最前面不能等写完流水再校验否则并发回调会穿透。3.3 订单表结构从一份DDL看分库分表边界订单表是交易系统的地基设计上有个原则主表尽量瘦流水独立存。下面这份 DDL 是电商订单主表的常见形态字段不追求多但要覆盖交易闭环所需的关键信息CREATE TABLE order_info ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号对外展示, buyer_id BIGINT NOT NULL COMMENT 买家ID分库分表键, shop_id BIGINT NOT NULL COMMENT 店铺ID, status TINYINT NOT NULL COMMENT 订单状态对应状态机code, total_amount DECIMAL(12,2) NOT NULL COMMENT 商品总额, pay_amount DECIMAL(12,2) NOT NULL COMMENT 实付金额, pay_time DATETIME DEFAULT NULL COMMENT 支付时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_buyer_created (buyer_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电商订单主表;字段设计上有几个点我会特别较真。订单号用独立业务字段而不是数据库自增 ID因为自增 ID 会泄露销量也不会带分片信息金额用 DECIMAL(12,2)不要用 FLOAT否则对账差一分钱查半天分库键选 buyer_id 而不是 shop_id因为买家视角的查询远多于商家视角同一个买家的订单必须落在同一分片这样查「我的订单」不需要聚合。索引上uk_order_no 保证唯一idx_buyer_created 服务买家列表页不要在每个字段上都建索引。至于什么时候分表我的阈值判断是单表订单量超过千万或者写入 TPS 超过 1000 且持续增长再去考虑分库分表在这之前用合适的索引和归档策略完全能撑住。过早分表只会让查询、事务、统计全部变复杂收益却为零。3.4 下单链路一致性本地消息表与幂等消费下单不是一个接口调用而是一条链路校验商品 → 扣库存 → 创建订单 → 发消息通知下游。最怕的是前三步做了一半进程挂掉。我对这个问题用的最稳的方案是本地消息表也叫 outbox 模式。核心思想是订单主表和待发送消息表放在同一个数据库事务里写入事务提交后后台任务再把消息投递到 MQ。Transactional public Long createOrder(CreateOrderCmd cmd) { // 1. 用库存域的接口原子扣减库存失败则抛异常回滚 inventoryService.deduct(cmd.getSkuId(), cmd.getQuantity()); // 2. 写订单主表和明细表 Long orderId orderRepository.insert(cmd.toOrder()); // 3. 写 outbox 消息表与订单同事务 outboxRepository.insert(new OutboxMessage(order.created, cmd.toPayload())); return orderId; }第一步扣库存和第三步写 outbox都在这个本地事务里任何一个失败都会整体回滚。这样做的好处是不需要引入分布式事务中间件也能保证「扣了库存就一定会有订单消息」。投递过程由单独的定时任务扫描 outbox 表成功投递的消息标记为已发送超过重试上限的进死信人工处理。消费端的规矩是必须先做幂等再执行业务。最简单的方式是在消费逻辑里查订单号对应的处理记录或者用数据库唯一键兜底确保同一订单的消息重复投递不会产生两次发货。我在消费端通常写一个幂等表主键就是业务键比如order_no event_type插入成功才继续处理失败直接返回成功。4. 高并发读写模型缓存、Lua扣库存与异步化设计架构图里最唬人的部分是「高并发支持」但真正落到代码上无非是解决两件事读路径怎么抗住流量写路径怎么保证数据不出错。电商是典型的读多写少系统商品详情页的请求量可能是下单量的几百倍如果所有流量都打到数据库再大的机器也扛不住。4.1 读多写少场景的缓存分层与失效策略商品详情页是最典型的读多写少场景我会按数据层级从上到下放四层缓存浏览器本地、CDN、Redis、进程内缓存最后才是数据库。每一层的成本和命中率不同不能混为一谈。缓存层级适合的数据过期策略失效成本浏览器/CDN静态图片、CSS、JS、秒杀页面配置化版本号刷新低不涉及业务数据Redis商品基本信息、SKU、库存热点数据TTL 主动淘汰中需要保证DB与缓存一致进程内缓存类目树、店铺配置、低频字典TTL 短通常30秒高多实例间不一致但可容忍数据库所有数据最终态无无缓存更新我坚持用 Cache Aside 模式先更新数据库再删除缓存而不是更新缓存。因为更新缓存会有并发写覆盖问题删除缓存则可以让下一次读请求自然回源。删除失败的情况用延迟双删兜底先删一次过几百毫秒再删一次避免读请求把旧值重新填回。商品详情这类数据TTL 设置在 10 到 30 分钟之间比较合适库存相关的 key 不过期由业务主动维护因为库存变化太快TTL 会让缓存里的库存和真实库存长期不一致。4.2 用RedisLua做原子扣库存脚本与参数说明库存扣减是电商架构里最容易出问题的点。常见错误是先查询库存在代码里判断是否足够再执行 update。这个逻辑在多线程并发下必有超卖因为两次查询之间其他请求已经改掉了库存。我推荐的方案是直接把扣减动作放进 Redis 的 Lua 脚本利用 Redis 单线程执行保证原子性。-- KEYS[1]: 库存key例如 stock:sku:1001 -- ARGV[1]: 本次扣减数量 -- ARGV[2]: 库存key过期时间单位秒 local stock tonumber(redis.call(GET, KEYS[1]) or 0) local qty tonumber(ARGV[1]) if stock qty then redis.call(DECRBY, KEYS[1], qty) redis.call(EXPIRE, KEYS[1], ARGV[2]) return 1 end return 0这段脚本的逻辑很直接先读出当前库存如果库存足够就一次性完成扣减和过期时间刷新返回 1不够就返回 0业务端收到 0 直接提示「库存不足」。参数说明里有两个细节要重视ARGV[1] 必须从请求参数透传不能在 Lua 里做复杂计算ARGV[2] 是过期时间一般设为 24 小时防止冷门商品永远占着内存。脚本返回 1 之后再异步把扣减流水写入数据库保证 Redis 和数据库最终一致。这个方案的代价是库存数据在极端故障下可能丢失但对大多数电商场景来说Redis 持久化配置得当风险可控。4.3 写链路削峰为什么消息要最后发、怎么发才稳下单链路的流量是突发的促销开始那一分钟QPS 可能是平时的几十倍。削峰填谷的常见做法是让下单接口只完成「占单」动作把耗时的下游操作全部异步化。所谓占单就是写一条状态为已创建的订单记录扣减库存成功后立即返回「下单成功」至于优惠券核销、积分赠送、消息通知都通过 MQ 异步消费。消息发送的时机是关键。我踩过的坑是有人把 MQ 发送放在事务提交之前一旦事务回滚消息已经发出去了消费者拿到一个不存在的订单号。正确顺序是本地事务提交成功之后再投递消息或者在事务内写 outbox提交后由后台投递也就是前面说的 outbox 模式。消费者侧必须配置合理的参数消费线程数按下游接口的耗时估算单个消息重试 3 次超过重试次数进死信队列由人工或者补偿任务处理。不要盲目加大消费线程数否则下游数据库先被打垮。4.4 搜索与分页读模型从数据库翻页到ES的迁移要点商品搜索是另一个典型的读多写少场景。用户在搜索页的请求会用多个条件组合过滤和排序数据库里用 where 拼条件能写但一旦数据到百万级、条件复杂索引就撑不住了。电商平台的商品搜索几乎都会引入 Elasticsearch 做读模型数据库负责写入ES 负责检索。一个简化版的商品索引 mapping 长这样{ mappings: { properties: { title: { type: text, analyzer: ik_max_word }, categoryId: { type: long }, price: { type: double }, status: { type: integer } } } }这里的核心是把数据库表结构「翻译」成适合搜索的文档结构。title 用 text 类型并配置 ik 分词支持中文搜索categoryId 用 long支持精确过滤price 用 double支持范围查询。数据同步用 binlog 订阅或者双写都行我倾向 binlog 订阅避免业务代码侵入。参数上refresh_interval 设置成 1 秒写入时数据有最多 1 秒的延迟对商品搜索完全够用副本数设 2保证一台节点挂掉不丢查询能力。从数据库翻页到 ES最重要的路线是MySQL 始终是数据源ES 只做查询出口所有写操作只走 MySQL。5. 落地排查电商架构最常见的5个坑这一章我从自己踩过的坑里挑出最典型的五个都是描述一个问题出现的现象、根因和解决方式。你会发现这些坑几乎都能在上面的设计里找到预防措施但线上往往因为某一步省略了就翻车了。5.1 缓存穿透一次空查询就让数据库背锅现象是数据库压力突然飙升Redis 命中率却很低监控上看到大量请求在查询不存在的商品。原因很简单如果用户传了一个不存在的商品 ID缓存里没有数据库里也没有每次请求都会穿透缓存打到数据库。攻击者可以利用这点用随机 ID 打爆数据库。解决方式是两层。第一层把不存在的 key 也缓存起来value 存一个空标记TTL 设置得很短比如 30 秒避免恶意流量反复穿第二层在查询入口加布隆过滤器把所有有效商品 ID 预加载进去过滤器判断不存在就直接返回。注意空值缓存不能设置太长 TTL否则商品真正上架后用户还会在 30 秒内看到「商品不存在」。数据量不大时我更喜欢先用空值缓存兜底布隆过滤器放在后面加。5.2 库存超卖先查后扣在并发下必翻车现象是后台库存显示剩余 10 件订单却生成了 15 笔。原因是代码里写了「先查询库存、判断大于0、再扣减」两个并发请求同时读到库存 1都通过了判断最后各扣一次库存变成 -1。这个时序问题在单库单应用时就存在拆了微服务之后只会更隐蔽。解决方式有两个层次。低并发场景直接用数据库的原子更新兜底SQL 写成UPDATE sku_stock SET stock stock - #{qty} WHERE sku_id #{skuId} AND stock #{qty}受影响行数为 0 就说明库存不足高并发场景用前面提到的 Redis Lua 脚本先扣再异步回写数据库。重点在于扣减动作必须和库存判断在同一个原子操作里完成不能用两次独立请求拼接。5.3 支付回调重复通知订单状态更新为什么不幂等现象是支付渠道回调到达两次第二次把订单状态从「已发货」覆盖回「已支付」或者同一个订单触发了两次发货。原因很简单支付渠道为了保证送达会按照一定的时间间隔重复推送结果如果回调处理逻辑直接执行UPDATE order SET status paid WHERE order_no ?它不会检查当前状态就会把下游已经推进的状态冲掉。解决方式依赖状态机约束回调处理时必须校验当前状态只有「已创建」状态才允许流转到「已支付」状态机里定义好的非法流转直接抛异常。同时用订单号做幂等键处理成功的回调记录写进幂等表重复回调直接返回成功。这两者缺一不可状态机保证业务正确幂等表保证性能和数据不重复写。5.4 深翻页慢查询limit 100000, 10 到底慢在哪现象是订单列表页翻到 50 页以后接口响应时间从 200ms 涨到 3 秒数据库 CPU 升高。原因是 MySQL 执行LIMIT 100000, 10时必须先把前 100000 行全部扫描出来然后才丢弃这个成本随着页码线性增长。用户翻不深但 B 端后台的导出和运营查询经常翻到底问题就暴露了。解决方式是放弃传统页码的分页改成游标分页查询条件增加id #{lastId} ORDER BY id LIMIT 10每次把上一次返回的最后一条记录的 id 带给下一次查询。这个方案依赖主键的有序性翻页再深也只扫 10 行。B 端后台确实需要跳页的我一般建议限制只能看前 200 页或者直接走离线数仓而不是让数据库硬扛。5.5 MQ重复消费库存被多扣的根因现象是库存流水里出现两条相同的扣减记录用户没有重复下单但库存少了两次。原因是消费者在处理消息时业务逻辑执行成功但还没提交 ack消费者进程挂了MQ 重启后重新投递同一条消息消费端没有识别出「这消息我已经处理过」。解决方式只有一个核心消费端必须幂等。最简单的实现是在处理逻辑开头查一下消息处理记录表如果biz_key已经存在就直接返回。更稳的做法是让业务操作本身具备幂等性比如库存扣减带上全局唯一的扣减流水号数据库给流水号加唯一索引重复插入会直接报错。幂等设计要做到消息处理整个链路里不是只在接口入口判断一次因为后续的异步子任务也可能重复。6. 上线前的容量验证把压测和全链路追踪用起来架构方案是否成立最终要压测说了算而不是评审会上的 PPT。我见过太多方案在图上完美一上线被流量打回原形。这里分享一套成本可控的验证方法。6.1 三种压测看清架构的真实水平第一种是读接口压测针对商品详情、搜索接口第二种是写接口压测针对下单、支付回调第三种是稳定性压测混合流量跑 30 分钟以上观察有没有内存泄漏和连接池耗尽。读压测用 wrk 就能做wrk -t8 -c256 -d60s --latency http://gateway.local/api/v1/products/1001参数说明-t 是线程数8 个线程-c 是并发连接数256 个连接-d 是压测时长60 秒--latency 会输出 P50、P95、P99 分位值。看结果时我从来不看平均 QPS因为平均值会骗人P99 超过 500ms 的接口在真实用户体验里已经卡了。写接口压测用 JMeter 更合适因为下单接口需要构造多参数请求体。压到目标之后还要做两个破坏性验证停掉下游依赖看服务会不会雪崩把消息队列停掉看消息堆积后恢复速度。这些就是压测里最玄学的部分不试一次永远不知道限流参数合不合理。6.2 用traceId贯穿全链路故障就不再是黑匣子全链路追踪会让排查效率翻倍。我在所有入口生成 traceId写入 HTTP Header、日志、MQ 消息体下游服务原样透传。这样一条订单从下单到支付到发货所有日志可以用一个 ID 拉出来。我自己踩过的血泪教训是只压读接口不压写接口上线后写链路先崩而写链路的日志因为没有 traceId排查了三个小时才定位到是库存扣减超时。希望帮到你。本文还有配套的精品资源点击获取
返回列表