拼单功能的设计实战
今天我们说一下拼单功能的设计实现。支付模型采用发起人统一支付支付完成后通过群收款向参与者收取各自的费用。拼单可以简单理解为是多人协作下一笔订单。多个人选各自的商品汇总成一笔订单统一履约比如统一配送到同一个地址由发起人统一支付。拼单改变的不是订单结构而是订单的生成过程多了一个「协作选品」的前置阶段。业务流程完整流程分三个阶段选品阶段发起人创建拼单组选择门店和配送方式系统生成一个10位唯一标识uniqueId发起人把拼单组链接分享给朋友朋友通过链接加入拼单组各自选商品选完的人点「确认」等待其他人选完发起人确认所有人选完后点「去下单」系统锁定拼单组下单支付阶段拼单组锁定后发起人进入正常下单流程提交订单时携带拼单组的uniqueId系统把各人的选品合并成一笔订单发起人支付支付和普通订单完全一致一个人付一笔钱费用分摊阶段支付完成后系统计算每个参与者应付的金额发起人通过群收款向参与者收取各自应付的费用几个关键约束一人一组同一用户在同一时间只能加入一个进行中的拼单组。创建新的之前必须退出已有的发起人不能退出只能取消整个拼单组。取消时系统会记录取消原因比如「选的人太少」「门店太远」等用于后续运营分析锁定与解锁发起人锁定拼单组后进入下单流程如果想修改可以解锁回到选品状态。但一旦订单已经生成就不能再解锁了拼单组过期拼单组创建后48小时内没有提交订单自动过期。用消息队列的延时任务处理订单域和支付域的改造这是最容易被过度设计的地方。拼单系统对订单域和支付域的改动极小小到可能出乎你的预期。订单域加一个类型值加一张关系表orders表的改动只有一处在已有的order_type字段里新增一个枚举值比如4拼单订单。不新增字段不改表结构。为什么需要这个类型值因为订单列表查询、客服后台筛选、运营数据统计都需要快速知道一笔订单是什么来源。如果每次都要JOIN关系表才能判断是不是拼单订单查询成本不合理。一个tinyint字段就能解决的事没必要搞复杂。拼单组和订单的详细关联用一张独立的关系表CREATETABLEorder_group_order(idbigintunsignedNOTNULLAUTO_INCREMENT,group_idbigintNOTNULLCOMMENT拼单组ID,order_idbigintNOTNULLCOMMENT订单ID,created_atdatetimeNOTNULLDEFAULTCURRENT_TIMESTAMP,PRIMARYKEY(id),KEYidx_group_id(group_id),KEYidx_order_id(order_id))COMMENT拼单组与订单关系表;order_type告诉你这是拼单订单order_group_order告诉你它属于哪个拼单组。两者各司其职。关系表的设计理由是拼单是一个可插拔的功能模块。它可能上线、可能下线、可能做灰度发布。拼单组的详细数据成员、选品、费用分摊都在独立表中管理不侵入订单核心表。哪天拼单功能下线这些表直接废弃即可。订单提交时前端在提交参数中直接标明订单类型为拼单同时携带拼单组的uniqueId。后端根据order_type写入订单表根据uniqueId创建关系记录并把拼单组状态改为「已提交」。支付域回调里加一行支付域唯一的改动是在支付成功回调里多调一个方法支付成功回调: → 正常标记订单已支付 → 更新拼单组状态为「已完成」 // 新增这一行 → 触发费用分摊计算不改支付链路、不改支付接口、不改退款逻辑。一行状态同步完事。这就是拼单系统的一个反直觉的点看起来「多人一起下单」应该对订单和支付产生很大影响但实际上这两个核心域几乎不需要改。所有复杂度都收敛在订单生成之前的协作阶段和支付完成之后的费用分摊。拼单组表设计拼单系统真正需要独立设计的数据模型在这里。拼单组表 order_group字段类型说明idbigint主键unique_idvarchar(10)唯一标识用于分享链接和缓存Keycreator_idbigint发起人用户IDshop_idbigint门店IDaddress_idbigint配送地址ID自取为0statustinyint0选品中 1已提交 2已完成 3已取消 4已过期create_channelvarchar(20)创建渠道weapp/alipay/appshare_channelvarchar(20)分享渠道expire_atdatetime过期时间创建时间48小时拼单组成员表 order_group_member字段类型说明idbigint主键group_idbigint拼单组IDuser_idbigint用户IDjoin_channelvarchar(20)加入渠道statustinyint0未选品 1选品中 2已确认 3已完成 4已取消 5已过期 6已退出用户选品明细表 order_group_item字段类型说明idbigint主键user_idbigint选购人order_idbigint订单生成后回填order_item_idbigint对应订单明细IDquantityint数量pricedecimal(10,2)结算价含折扣后origin_pricedecimal(10,2)原价discount_feedecimal(10,2)分摊优惠金额promo_codevarchar(50)命中的活动编码has_box_feetinyint是否需要包装费add_item_channelvarchar(20)选品渠道费用分摊表 order_group_fee_split字段类型说明idbigint主键group_idbigint拼单组IDorder_idbigint订单IDuser_idbigint应付人goods_amountdecimal(10,2)商品金额delivery_feedecimal(10,2)分摊配送费box_feedecimal(10,2)分摊包装费discount_amountdecimal(10,2)分摊优惠金额total_amountdecimal(10,2)应付总额表设计速查表核心职责数据写入时机order_group拼单组生命周期管理发起人创建时order_group_member参与者管理和状态追踪用户加入时order_group_item记录每人选了什么含最终价格订单提交时从Redis读取并持久化order_group_order关联拼单组和订单订单提交时order_group_fee_split记录每人应付多少支付成功后计算并写入ordersl订单类型枚举值加多一个拼单的类型正常订单流程选品阶段的协作设计选品阶段的数据全部走Redis缓存不落库。原因是选品阶段的数据变动非常频繁加商品、改数量、删商品而且有大量废弃数据用户退出、拼单取消如果每次操作都写数据库会产生大量无意义的IO。Redis的Key结构按天分片order:group:{日期}:members:{uniqueId} → Hash存储成员信息 order:group:{日期}:goods:{uniqueId} → Hash存储各人选品 order:group:{日期}:status:{uniqueId} → String拼单组状态快照选品数据只在一个时刻持久化到数据库发起人提交订单的那一刻。从Redis读取所有成员的选品数据合并成订单明细写入orders相关表同时写入order_group_item表记录「谁选了什么」。这个设计带来两个好处选品阶段的读写性能极高纯内存操作拼单取消或过期时不需要清理数据库Redis的TTL自动过期即可Redis缓存TTL设为1天配合拼单组48小时过期的业务规则缓存一定不会比业务状态先失效。费用分摊的计算费用分摊在支付成功后触发计算。每个人应付多少钱涉及四项费用的拆分商品费每个人自己选的商品结算总价已经在下单时确定。配送费分摊整单配送费按人头均分。配送费 ÷ 参与人数保留两位小数用HALF_UP舍入。包装费分摊按各人需要包装的商品件数占比分摊。如果A选了2杯需要包装的、B选了1杯需要包装的总包装费6元A承担4元B承担2元。有些商品不需要独立包装比如加料通过has_box_fee字段标记。优惠金额分摊满减、优惠券等整单优惠按各人商品原价占整单商品原价的比例分摊。公式用户优惠 用户商品原价 ÷ 全单商品原价 × 总优惠金额精度处理分摊计算用BigDecimal保留两位小数HALF_UP模式。计算完所有人后检查分摊优惠总和是否等于实际总优惠。如果有差额通常是一分钱把差额补到发起人的优惠里。如果发起人自己没选品只帮别人下单差额补给第一个参与者。每人最终应付商品原价 - 分摊优惠 分摊配送费 分摊包装费有一个边界校验如果某个参与者计算出来的应付金额为0或负数极端折扣场景群收款时会报错需要在前端提示发起人。分摊项分摊规则精度处理商品费各自商品结算总价无需分摊下单时已确定配送费人头均分HALF_UP保留两位小数包装费按需包装的商品件数占比HALF_UP保留两位小数优惠金额按商品原价占比差额归发起人群收款群收款不是微信的个人社交转账功能而是微信官方提供给小程序的拼单群收款APIPOST https://api.weixin.qq.com/wxa/business/groupBuy/createOrder这是一个正式的微信小程序接口需要小程序具备相应的权限。调用流程支付成功后计算每个参与者应付金额获取参与者的微信openId构造请求体包含每人的openId和金额调用微信API微信返回群收款页面发起人可以分享到群聊参与者在微信中看到收款通知自行付款群收款按钮的显示条件很严格当前用户必须是发起人客户端必须是微信小程序拼单组的创建渠道必须是微信小程序拼单组的分享渠道也必须是微信小程序只要有一个环节走了支付宝或原生App群收款按钮就不展示。这是因为微信群收款API只能在微信生态内闭环。对于非微信渠道的拼单发起人只能看到分摊明细自行和参与者结算。群收款的最大参与人数是100人。这是微信API的限制超过100人调用会报错。这个限制更多是防御性校验。群收款和订单履约是解耦的。发起人付完款订单就开始制作配送。参与者给不给钱是发起人和参与者之间的社交问题不影响订单流程。拼单天然依赖熟人关系做信任担保陌生人之间不会拼单。取消和退款取消流程取消有两个入口对应两种场景手动取消支付前用户在待支付状态取消订单触发拼单组状态变为「已取消」所有成员状态变为「已取消」清除Redis缓存。超时取消支付超时订单超时未支付由消息队列的延时消费者处理。拼单组状态变为「已过期」所有成员状态变为「已过期」。两种取消用不同的状态码区分取消3过期4方便运营统计哪些拼单是主动放弃、哪些是忘了支付。退款处理退款没有额外的拼单逻辑。退款走正常订单退款流程钱退到发起人账户。为什么不把退款直接退给对应的参与者因为参与者不是付款人。微信支付的退款只能退到原支付账户。参与者通过群收款给的钱是微信社交转账不在订单系统的支付链路里平台无法操作。退款后费用分摊记录不删除保留作为历史数据。发起人如果要把钱退给参与者自行在微信里处理。状态机拼单组状态状态值含义触发条件0选品中创建拼单组后的初始状态1已提交发起人点击下单订单生成2已完成关联订单支付成功3已取消发起人主动取消 / 支付前取消订单4已过期48小时未提交 / 订单支付超时成员状态状态值含义触发条件0未选品刚加入拼单组1选品中开始浏览菜单选商品2已确认选完点确认3已完成订单提交成功4已取消拼单组被取消5已过期拼单组超时6已退出主动退出拼单组两层状态之间只有一个联动点订单支付成功时拼单组从「已提交」变为「已完成」同时触发费用分摊计算。生产环境约束速查约束项规则原因拼单组过期时间创建后48小时防止僵尸拼单占用缓存和门店资源群收款人数上限100人微信API限制加入人数上限不限制加入时不校验群收款时才校验100人一人一组同一用户同一渠道同时只能在一个进行中的拼单组防止数据混乱发起人退出不允许退出只能取消发起人退出后无人能操作拼单组锁定后修改未生成订单可以解锁已生成订单不可逆防止订单和选品数据不一致群收款渠道约束全链路微信小程序才显示群收款按钮微信API只在微信生态内可用门店/地址修改发起人在提交前可修改灵活应对临时变更选品数据存储全部在Redis提交时才持久化高频读写大量废弃数据不适合直接落库取消原因记录独立表存储取消标签运营分析拼单取消原因小结拼单系统给人的第一印象是「多人协作下单」直觉上会觉得订单模型和支付流程需要大改。但看过生产级别的实现后会发现订单域和支付域的改造加起来不超过20行代码。一张关系表、一个支付回调hook就把两个核心域打通了。真正的工程量集中在两个地方选品阶段的实时协作体验Redis缓存方案、状态同步、多渠道支持和费用分摊的准确计算BigDecimal精度、差额处理、边界校验。这两块做好了产品体验就到位了。最近在知乎出了「应付6000万会员的秒杀系统专栏」和「几亿用户,百万并发的C端商品系统实战」「技术团队DDD领域驱动设计三年落地实战」专栏感兴趣的可以订阅一下。至于知识星球的可以搜老码头的技术浮生录它是一个能实际帮你解决难题的星球。有问题的找知心的Sam哥支持无限次语音一对一解决你遇到的难题。「另外后续我新写的所有对外的付费专栏在星球内都是免费的且可以拿到所有源代码。」当前星球里免费看的专栏有「几亿用户,百万并发的C端商品系统实战」「技术团队DDD领域驱动设计三年落地实战」知识星球内后续将推出20个付费专栏覆盖电商全链路选购线用户会员营销线中后台购物车服务营销系统订单系统商品服务用户系统支付系统菜单服务结算服务从前台选购到中后台结算星球成员全部免费后续新增也不额外收费。我的知乎账号:SamDeepThinking