从订单超时到百万日活:私域分销系统的架构演进实录

📅 2026/6/26 7:11:05
从订单超时到百万日活:私域分销系统的架构演进实录
去年参与了一个私域电商项目的架构重构。客户原有的系统是单机部署日活在5000左右时还能勉强支撑。有一次大促活动瞬时流量上来订单支付回调接口P99飙到3秒以上用户付了款却看不到订单状态更新客服电话被打爆。更麻烦的是佣金计算阻塞在支付链路里数据库连接池被打满整个服务直接宕机。这篇文章复盘了整个重构过程——不是理论推演是真实踩坑后长出来的方案。适合正在做或准备做私域电商系统的后端同学参考。一、原来的系统长什么样简单说一下背景这是一套分销电商系统用户通过分享链接发展下级下级下单后上级拿佣金。核心流程就三条下单支付、佣金计算、提现。原系统技术栈PHP MySQL单库部署在单台云服务器上。架构长这样text用户请求 → Nginx → PHP-FPM → MySQL单库↓支付回调里同步算佣金↓递归查上级关系链查N次DB↓写入佣金明细 更新用户余额三个致命问题佣金计算和支付在同一个事务里支付成功后要等佣金算完才返回结果。算一个订单的佣金平均耗时200ms如果订单商品多、分销层级深能到500ms以上。用户关系链存储在user表里只有parent_id一个字段。查上级的上级需要递归查询分销深度是10级的话一次佣金计算要查10次数据库。所有数据在同一个库订单表、佣金表都是单表。大促期间行锁竞争严重数据库CPU长期100%。这些问题不是理论上的——是真实发生过、真实导致过宕机的。二、重构的核心思路重构不是推翻重来是在现有业务约束下做手术。核心思路就三条原则 具体做法同步变异步 支付回调只改订单状态佣金计算丢到消息队列后面慢慢算递归变查表 用户关系链存一份路径表查所有上级用一次SELECT单表变分片 订单和佣金按用户ID哈希分表压力打散下面逐个展开。三、怎么解决支付等佣金的问题问题现场原来的代码大致长这样phppublic function handlePayNotify($orderId) {DB::beginTransaction();$order-status ‘paid’;$order-save();// 同步计算所有上级佣金阻塞this−calcCommission(this-calcCommission(this−calcCommission(order);DB::commit();return ‘success’;}calcCommission里面要查关系链、算比例、写佣金明细、更新余额。一个订单算完200-500ms刚好卡在数据库连接池的等待超时阈值上。解决方式把佣金计算剥离到异步队列。支付成功后发一条消息消费者拿到消息后再算。重构后的流程text支付完成 → 更新订单状态 → 发消息到队列 → 立即返回用户感知不到延迟↓消费者取消息↓幂等校验防止重复消费↓计算佣金 写入明细这里有一个关键点幂等。消息队列有重试机制同一个消息可能被消费多次。如果没有幂等保护同一个订单可能被重复分佣资金就乱了。幂等的做法很简单每条消息带一个全局唯一的msg_id消费时先在Redis里写一个标记24小时过期。如果标记已存在说明这条消息已经被消费过直接跳过。phppublic function consume($msg) {$key ‘msg:’ .msg−id;//RedisSETNX原子操作if(!Redis::setnx(msg-id; // Redis SET NX原子操作 if (!Redis::setnx(msg−id;//RedisSETNX原子操作if(!Redis::setnx(key, ‘1’)) {return; // 已消费过跳过}Redis::expire($key, 86400); // 24小时后自动清理// 正常计算佣金…}这个改动上线后支付回调接口的P99从3.2秒降到了320毫秒。用户付完款立即看到订单状态更新体验问题解决了。四、怎么解决递归查上级的问题问题现场用户关系链原来的存储方式sqlCREATE TABLE user (id INT PRIMARY KEY,parent_id INT, – 直系上级…);查询某个用户的所有上级用于算佣金phpfunction getAncestors($userId) {result[];while(result []; while (result[];while(userId) {userDB::select(′SELECT∗FROMuserWHEREid?′,[user DB::select(SELECT * FROM user WHERE id ?, [userDB::select(′SELECT∗FROMuserWHEREid?′,[userId]);if (!user∣∣!user || !user∣∣!user-parent_id) break;$userId $user-parent_id;$result[] $user;}return $result;}分销深度10级算一个订单的佣金就要查10次数据库。单次查询1ms10次就是10ms看起来不多——但乘以并发量呢一次大促活动每秒1000单数据库连接池瞬间被占满。解决方式加一张路径表把每个用户和所有上级的关系提前存好sqlCREATE TABLE user_tree (user_id INT,ancestor_id INT, – 上级IDdistance INT, – 隔了几级1直推2二代…PRIMARY KEY (user_id, ancestor_id));用户注册时把新用户的直系上级以及所有间接上级都插入这张表phpfunction registerUser($userId,KaTeX parse error: Expected }, got EOF at end of input: …S (?, ?, 0), [userId, $userId]);// 所有上级的path记录ancestorsDB::select(′SELECTancestorid,distance1FROMusertreeWHEREuserid?′,[ancestors DB::select(SELECT ancestor_id, distance 1 FROM user_tree WHERE user_id ?, [ancestorsDB::select(′SELECTancestori​d,distance1FROMusert​reeWHEREuseri​d?′,[parentId]);foreach ($ancestors asKaTeX parse error: Expected }, got EOF at end of input: … [userId, $a-ancestor_id, $a-distance]);}}查询某个用户的所有上级变成了一次SELECTsqlSELECT ancestor_id, distance FROM user_tree WHERE user_id ? ORDER BY distance;10次查库变1次慢查询从TOP10里消失了。五、怎么解决单表太大的问题问题现场订单表1500万行佣金明细表800万行都是单表。后台查订单列表带一个时间范围筛选执行时间稳定在5秒以上。索引重建过三次每次都需要停机维护。解决方式分库分表。选择按user_id哈希取模分成16张表。sql– 订单表CREATE TABLE order_0, order_1, …, order_15;– 路由规则table_index user_id % 16选user_id作为分片键的理由大部分查询都带user_id查我的订单、查我的佣金。不带user_id的查询比如后台查全平台今日订单走ES搜索引擎不走MySQL。分表后的变化单表数据量从1500万降到100万左右1500万/16查询不走索引也能在100ms内返回索引维护可以在线做不需要停机一个坑分表后全局ID不能再用数据库自增换成了雪花算法Snowflake。每个服务节点启动时分配不同的机器ID保证生成的ID全局唯一且趋势递增。六、合规是怎么做的分销系统有一个红线分销层级不能超过两级。这个不是技术问题是监管问题。系统里做了三层防护注册时拦截用户绑定上级时检查上级的当前层级深度。如果上级已经是第2级新用户只能绑定为普通会员不能继续发展下级。计算时拦截佣金计算逻辑里硬编码检查层级超过2级的分佣直接跳过。定时巡检每天凌晨扫一遍用户关系链如果发现异常数据比如层级超过2级自动标记并告警。这三层防护不依赖业务人员自觉全部在代码层面固化了。七、重构后的效果项目从启动到完成用了两个月上线后经历过两次大促验证指标 重构前 重构后支付回调P99 3.2s 320ms关系链查询耗时 递归10次~15ms 单次查询~2ms订单列表查询带条件 5s 100ms大促期间最高可用性 99.2%宕机一次 99.99%数据是真实的但更重要的收获是过程里踩过的坑——哪些方案能落地、哪些只是理论好看。八、几个容易被忽略的细节Redis标记的过期时间幂等标记设了24小时过期。太短的话如果消息消费特别慢比如下游服务挂了标记过期了还没消费完重试时幂等失效可能重复分佣。太长的话Redis内存占用越来越大。24小时是折中值覆盖了绝大多数异常场景。分表扩容预分16张表短期内够用。如果数据继续增长扩容的方式是把16张表变成32张迁移时只迁移需要跨分片的数据取模规则变了的那部分不需要全量迁移。具体实现用了Sharding-JDBC的自动分片迁移能力。消息消费失败消费者处理佣金时如果抛出异常比如数据库连接断了消息会重新进入队列重试。为了防止重试无限循环设置了最大重试次数3次超过后进入死信队列人工介入处理。对账每天凌晨跑定时任务把订单表和佣金明细表做一次全量对账。逻辑是找出已支付但没有佣金记录的订单、佣金金额和订单金额比例不匹配的记录。这些异常数据会被单独列出来系统自动补算或通知运营人工处理。九、总结私域分销系统的架构设计核心不是技术炫技是做好三件事异步解耦核心链路和重业务分开用户不感知延迟存储优化关系链用空间换时间海量数据用分片换性能资金安全幂等 对账 合规拦截三道防线这套方案的代码实现已经在多个项目中落地验证过。技术选型没有追求最新用的是经过大规模生产验证的成熟组件——PHP MySQL Redis RabbitMQ组合在一起足够应对绝大多数业务场景。