这是一篇关于「拼团交易系统」阶段一的总结。我会从产品经理、用户和应用(技术)三个视角讲清楚一件事——一个拼团小项目是怎么从「打开网页」走到「下单完成」的,最后再说说这个阶段我们是怎么防超售、扛并发的,以及这个阶段到底有哪些亮点。文章基于我已经部署上线到 https://yanshen.online/group-buy/ 的版本(源工程 fork 自小傅哥的 group-buy-market-yanshen),前端 + 后端 + 数据库我都跑通了,所以下面所有接口和流程你都可以直接在浏览器里验证。

一、作为产品经理视角:一条完整的拼团业务流

站在产品经理的视角,我想清楚一件事:这个产品到底要解决什么问题? 答案很简单——用「拼团折扣」撬动用户自发传播,用社交关系链降低获客成本。所以整个产品形态要满足三个核心诉求:

  1. 明确的优惠感知:用户一进来就能看到「直降 ¥20」「3 人成团」这种字眼;
  2. 极低的成团门槛:凑不齐人就自己开一个团,反正不亏;
  3. 清晰的订单状态:什么时候锁单、什么时候付款、什么时候拼团成功——每一步都要告诉用户。

围绕这三点,我把整条业务流程拆成 4 个环节:

阶段用户行为系统响应备注
浏览打开商品页返回商品 + 进行中拼团列表 + 拼团统计激励「再抢一下就成团了」
开团点「开团购买」创建拼团小队 + 锁单 + 唤起支付自己就是团长
参团点别人团里的「参与拼团」加入拼团小队 + 锁单 + 唤起支付teamId 由前端从列表里传
单独购买点「单独购买」不进拼团,直接锁单 + 唤起支付享受不了拼团价

这四种走向最终都会汇到同一个支付回调:用户扫码付钱 → 后端做「结算」动作 → 拼团人数到 3 人 → 整个团标记为「成功」→ 后端异步通知调用方(这里是个 mock 接口,但生产里一般是对账系统或者发券系统)。

PM 视角的关键判断:把「单独购买」和「拼团」放在同一个商品详情、用同一个支付弹窗,是有意的设计——用户看见的是「两个按钮」,但后端看到的只是 teamId 字段为不为空。这种「前端分流、后端同构」的策略,让后面的对账、统计、优惠计算逻辑都可以复用一套代码。

二、作为用户视角:实际打开一次拼团页面

我用真线上地址(https://yanshen.online/group-buy/)走一遍:

  1. 进站:先弹登录。用户名密码随便写(mock 登录,校验 userId 是否为空),把用户名写到 cookie 的 username 里;
  2. 看商品:登录后看到商品《手写 MyBatis:渐进式源码实践》,原价 ¥100,拼团价 ¥80;
  3. 看拼团:下面有进行中的拼团列表,比如「xfg01 还差 1 人成团,倒计时 00:14:42」;
  4. 点按钮:底部有「单独购买(¥100)」「开团购买(¥80)」两个按钮,列表里每个团右边还有一个「参与拼团(¥80)」按钮;
  5. 支付:点任何一个按钮都会弹二维码弹窗(这里是张占位图),里面有「取消支付」和「支付完成」两个按钮;
  6. 付钱:点「支付完成」,前端把 outTradeNo(前端随机生成的 12 位数字)传回去,后端做结算。

到这里就完成一次完整的拼团闭环。

用户视角的几个体感细节:

  • 倒计时是真的:前端在拼团列表里塞了一个 Countdown class,每秒减 1,到 0 就停。这样用户有紧迫感;
  • 不用重复登录:cookie 里塞了 userId,下次直接进;
  • 支付二维码是占位:生产里这里会接微信/支付宝 SDK,这里只是演示链路。

三、深入应用视角:从前端到后端,一行行看

下面这部分是技术同学最关心的——「这一段从前到后到底调用了什么、为什么这么调」。我会把前端、后端、数据库三端都画清楚。

3.1 工程结构:DDD 分层

整个后端工程是按 DDD 分层的(这是阶段一最重要的架构决策),模块拆成 6 个:

模块作用关键类
group-buy-market-yanshen-types公共枚举/常量/设计模式抽象ResponseCode、Constants、DCCValue 注解、BusinessLinkedList 责任链
group-buy-market-yanshen-api对外暴露的 API 接口 + DTOIMarketIndexService、GoodsMarketRequestDTO
group-buy-market-yanshen-domain领域服务、聚合根、规则过滤器TradeLockOrderService、TradeSettlementOrderService、GroupBuyOrderAggregate
group-buy-market-yanshen-infrastructure数据库、Redis、网关实现TradeRepository、RedissonService、GroupBuyNotifyService、DCCService
group-buy-market-yanshen-triggerHTTP 入口(Controller)+ 定时任务MarketIndexController、MarketTradeController、GroupBuyNotifyJob
group-buy-market-yanshen-appSpring Boot 启动、配置Application、DCCValueBeanFactory

依赖方向严格遵守 trigger → domain ← infrastructure,domain 不依赖任何外部框架,是纯粹的 Java 业务逻辑——这是后期能写单测、换存储、改消息中间件的根本保障。

3.2 打开网页,前端调用了什么接口?

我们打开 index.html,所有逻辑都在 js/index.js 和页面里的 <script> 块里。打开那一刻,前端做了一件事:

fetch(baseUrl + '/api/v1/gbm/index/query_group_buy_market_config', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        userId: 'xfg01',     // 从 cookie 取
        source: 's01',
        channel: 'c01',
        goodsId: '9890001'
    })
});

这个接口就是商品详情页的「数据总入口」,它一次返回前端要展示的所有东西:

{
  "code": "0000",
  "info": "成功",
  "data": {
    "goods": { "goodsId": "9890001", "originalPrice": 100, "deductionPrice": 0, "payPrice": 100 },
    "teamList": [
      { "userId": "xfg01", "teamId": "43375770", "targetCount": 3, "completeCount": 1, "lockCount": 2, "validTimeCountdown": "已结束", "outTradeNo": "..." }
    ],
    "teamStatistic": { "allTeamCount": 9, "allTeamCompleteCount": 1, "allTeamUserCount": 11 }
  }
}

我贴一段我刚刚用 curl 真实调用的返回(来自我部署的线上服务,2026-08-26 跑出来的):

{"code":"0000","info":"成功","data":{"goods":{"goodsId":"9890001","originalPrice":100.00,"deductionPrice":0.00,"payPrice":100.00},"teamList":[{"userId":"xfg01","teamId":"43375770","activityId":100123,"targetCount":3,"completeCount":1,"lockCount":2,"validStartTime":"2026-08-26T01:15:40.000+00:00","validEndTime":"2026-08-26T02:15:40.000+00:00","validTimeCountdown":"已结束","outTradeNo":"278793607273"}, ... ]}}
注意 validTimeCountdown 字段:「已结束」三个字是前端用 differenceDateTime2Str(new Date(), validEndTime) 现算的,前端拿到时间戳就能渲染倒计时,不必再问后端。

3.3 后端收到这个请求后做了什么?

后端入口是 MarketIndexController.queryGroupBuyMarketConfig,步骤是:

校验参数 → 试算 (trial) → 查本人拼团中订单 → 查全团统计 → 组装返回

关键在于第二步「试算」——它不只算价格,还决定「这个用户能不能买、能用哪个优惠」。

3.3.1 试算:indexMarketTrial 走的是策略树

调用 IIndexGroupBuyMarketServiceImpl.indexMarketTrial(),里面用了一个策略树 + 责任链的混合结构(DefaultActivityStrategyFactory)。整棵树大概长这样:

根节点 (Root)
├─ SwitchNode: 根据 source+channel+goodsId 查 sc_sku_activity 表
├─ NullReturnNode: 查不到就返回空
└─ MultiThreadStrategyRouter (并发执行下面两路):
   ├─ LeftNode: 查活动 + 折扣 → 算价格
   └─ RightNode: 人群标签过滤 (crowd_tags)

它把「查活动」「算价格」「人群过滤」三件事并发跑(用了 AbstractMultiThreadStrategyRouter),最后汇总。

3.3.2 算价格:策略模式 + 工厂方法

AbstractDiscountCalculateService 是抽象类,定义了「先看有没有人群标签限制 → 调具体算价方法」。具体算价有 4 种实现,靠 Spring 的 @Service("MJ") / @Service("ZK") / @Service("ZJ") / @Service("N") 区分:

Bean 名类型算法适用场景
MJ满减price - y (若原价 > x)满 100 减 20
ZK折扣price * ratio打 8 折
ZJ直减price - amount直接减 20
NN 元购N1 元购、9.9 元购

具体用哪个?看数据库里 group_buy_discount 表的 market_plan 字段——我这里写的是 ZJ,market_expr = 20,所以原价 100 - 20 = 80。

为什么这么做? 一个商品可能有多个优惠活动,不同渠道/不同人群用不同算法。把「算法选择」做成数据库配置 + 工厂注册,后期加活动不用改代码,只改库。这就是策略模式的精髓。

3.3.3 组装返回:3 张表 + 1 个聚合

MarketIndexController 自己从 repository 里查了三块:

  • userGroupBuyOrderDetailEntities:自己开/参与的拼团中订单(拼团列表那条橙色横条的数据来源)
  • teamStatisticVO:全团统计(allTeamCount / allTeamCompleteCount / allTeamUserCount)
  • goods:商品 + 算价结果

然后用 GoodsMarketResponseDTO.builder() 一拼,返回。

3.4 前端拿到数据后做了什么?

前端拿到 JSON 后做 4 件事:

  1. 更新顶部促销文案:promotionText.textContent = '直降 ¥${goods.deductionPrice},${teamStatistic.allTeamUserCount}人再抢,参与马上抢到'——这是「社会证明」式的紧迫感话术;
  2. 渲染拼团列表:teamList.forEach(team => groupList.innerHTML += ...),每个团右边画一个「参与拼团」按钮,绑 data-teamid;
  3. 底部按钮:买单独显示 goods.originalPrice,开团购买显示 goods.payPrice;
  4. 倒计时:列表里的 validTimeCountdown 字段丢进 new Countdown(el, el.textContent),每秒减 1。
这里有个幂等设计值得说:前端一加载就把「自己有没有进行中的拼团订单」扫一遍。如果有,就把这个订单的 outTradeNo 存到变量 outTradeNo 里,下次点「支付完成」直接用——这样用户刷新页面也不会丢订单。

3.5 拼团数据 & 锁单下单的实现

这是整个系统最关键的一段。点「开团购买」或「参与拼团」时,前端调用的是:

POST /api/v1/gbm/trade/lock_market_pay_order
{
  "userId": "xfg99",
  "teamId": null,          // 开团传 null,参团传具体 ID
  "activityId": 100123,
  "goodsId": "9890001",
  "source": "s01",
  "channel": "c01",
  "outTradeNo": "123456789012",   // 前端生成的 12 位随机数
  "notifyUrl": "https://yanshen.online/api/v1/test/group_buy_notify"
}

后端在 MarketTradeController.lockMarketPayOrder 里的处理是个典型的责任链 + 数据库事务组合。我把链路画清楚:

┌──────────────────────────────────────────────────────────┐
│ 1. 参数校验 (string not blank)                            │
│ 2. 幂等检查:outTradeNo 存在则直接返回 (REPEAT)            │
│ 3. 如果传了 teamId:查锁单进度,满人则 E0006              │
│ 4. 试算 (复用 query_group_buy_market_config 那套)         │
│ 5. 责任链 tradeRuleFilter.apply(...)                     │
│      ├─ ActivityUsabilityRuleFilter: 活动生效+在时间窗内 │
│      └─ UserTakeLimitRuleFilter: 用户参与次数 < takeLimit │
│ 6. TradeRepository.lockMarketPayOrder(@Transactional)    │
│      ├─ teamId 空 → 插入新团 (target=3, complete=0, lock=1)│
│      └─ teamId 非空 → UPDATE lock_count + 1              │
│                       返回 1 才算成功,否则 E0005         │
│ 7. 插入 group_buy_order_list 一行 (单个用户的订单)        │
│ 8. 返回 { orderId, deductionPrice, tradeOrderStatus }    │
└──────────────────────────────────────────────────────────┘

3.5.1 数据库表的角色

group_buy_order = 拼团小队(团长是创建者)

  • 主键自增,team_id 8 位唯一(uq_team_id)
  • 关键字段:target_count=3、complete_count、lock_count、status (0-拼单中/1-完成/2-失败)、valid_end_time
  • 一条记录代表「一团」

group_buy_order_list = 单个用户的订单

  • 主键自增,order_id 12 位唯一(uq_order_id)
  • 关键字段:user_id、team_id、out_trade_no(外部幂等号)、biz_id(活动_用户_序号)、status (0-锁单/1-完成/2-退单)
  • 一个团可以有 N 条这样的记录(N=团人数)

notify_task = 异步通知队列

  • 拼团成功后插入一条;定时任务轮询、回调 HTTP 或 MQ
  • 字段 notify_count < 5 时重试,>= 5 标记失败

3.5.2 biz_id 的小细节

仔细看 SQL 里这一段:

`biz_id` varchar(64) NOT NULL COMMENT '业务唯一ID'

入库时拼成 activityId + "_" + userId + "_" + (userTakeOrderCount+1)——意思是「这个用户参加这个活动的第几次」。配合 take_limit_count 字段(活动配置里写的「每人限购 1 次」),就能在前面的 UserTakeLimitRuleFilter 里挡住重复刷单。

3.6 用户从打开到下单的完整流程图

把上面所有片段拼起来,用户从打开 https://yanshen.online/group-buy/ 到下单完成走的是这条路:

[用户浏览器]
   │
   ├─ ① 打开 /group-buy/index.html
   │     └─ 读 cookie.username → 没值就跳 login.html
   │
   ├─ ② JS 调 query_group_buy_market_config
   │     └─ 后端返回 goods + teamList + teamStatistic
   │        └─ 前端渲染页面 + 倒计时
   │
   ├─ ③ 用户点「开团购买 / 参与拼团 / 单独购买」
   │     └─ 前端生成 outTradeNo (12 位随机)
   │     └─ 调 lock_market_pay_order
   │        ├─ 后端: 责任链 (活动可用 + 用户限购) 通过
   │        ├─ 后端: INSERT/UPDATE group_buy_order (团信息)
   │        ├─ 后端: INSERT group_buy_order_list (个人订单)
   │        └─ 返回 { orderId, status: CREATE }
   │     └─ 前端弹二维码弹窗
   │
   ├─ ④ 用户扫码 (mock) → 点「支付完成」
   │     └─ 调 settlement_market_pay_order
   │        ├─ 后端: 责任链 (SC黑名单 → outTradeNo存在 → 在有效期内)
   │        ├─ 后端: UPDATE group_buy_order_list.status = 1
   │        ├─ 后端: UPDATE group_buy_order.complete_count + 1
   │        └─ 如果 complete_count == target_count:
   │              ├─ UPDATE group_buy_order.status = 1 (团完成)
   │              └─ INSERT notify_task (异步回调)
   │
   └─ ⑤ (异步) GroupBuyNotifyJob 每 15 秒扫一次 notify_task
         └─ HTTP/MQ 回调调用方 (这里是 mock /api/v1/test/group_buy_notify)

四、整个过程如何防止超售?

超售的本质:两个用户同时看到「还剩 1 个名额」,同时点了「参与拼团」。这种场景在拼团里特别常见——一个 3 人团,前 2 人已经锁单,第 3 个名额引来 50 个人抢。防超售有三道防线:

4.1 第一道防线:Controller 层的乐观预检

MarketTradeController.lockMarketPayOrder 在进入事务之前先做了一次预检:

if (null != teamId) {
    GroupBuyProgressVo vo = tradeOrderService.queryGroupBuyProgress(teamId);
    if (null != vo && Objects.equals(vo.getLockCount(), vo.getTargetCount())) {
        return Response.<...>builder()
            .code(ResponseCode.E0006.getCode())  // "组队已满"
            .info(ResponseCode.E0006.getInfo()).build();
    }
}

简单说——如果进来就发现 lock_count == target_count,直接拒。

4.2 第二道防线:数据库行锁 + 事务

即便预检通过了,并发进来还是会撞车。这时候靠 group_buy_order 的 uq_team_id 唯一索引 + MySQL 的行锁。UPDATE group_buy_order SET lock_count = lock_count + 1 WHERE team_id = ? 这条 SQL:

  • MySQL 会先对那一行加排他锁;
  • 同一时刻第二个并发进来时必须等;
  • 一个一个排队执行,lock_count 严格递增;
  • 关键防御点在仓库层:
int updateAddLockCount = groupBuyOrderDao.updateAddLockCount(teamId);
if (1 != updateAddLockCount) {
    throw new AppException(ResponseCode.E0005);  // 抛异常,事务回滚
}

@Transactional(timeout = 500) 包裹整段,保证要么都成功、要么都回滚。

4.3 第三道防线:结算时的二次校验

就算锁单这一关被绕过去,结算时还有 4 个过滤器在排队:

SCRuleFilter (黑名单拦截)
   ↓
OutTradeNoRuleFilter (订单必须存在)
   ↓
SettableRuleFilter (必须在拼团有效期内)
   ↓
EndRuleFilter (返回上下文)

特别是 SettableRuleFilter——它会拿当前时间比对 group_buy_order.valid_end_time,超时就直接拒。这就是为什么 valid_time 字段(15 分钟默认)一旦过了,整个团即使有人锁了单也结不成。

4.4 阶段一防超售的局限性

说句实话,阶段一的这套防线是有缺陷的:

  • 预检(Controller)和更新(Repository)之间存在TOCTOU 时间窗——你读完 lock_count=2,在你更新前别人已经更新到 3 了;
  • 行锁保护住了「不会超 3」,但用户体验是「一直点一直失败」;
  • RedissonService 类里其实有 getLock(String key) / getSemaphore 等分布式锁 API,但阶段一完全没用上。

阶段二会做的优化:把行级预检升级成「UPDATE ... WHERE team_id = ? AND lock_count < target_count」,影响行数 = 1 才算成功。同时用 Redisson 的 RSemaphore 做一层前置信号量,把无效请求挡在数据库外面。这是后话了。

五、高并发处理:阶段一能扛多少?

老实说,阶段一不是为高并发设计的,它是一套「能跑通」的最小实现。但它在几个关键点上做了铺垫,让阶段二能很快补上:

5.1 已经做的

措施在哪作用
@Transactional(timeout = 500)TradeRepository单事务不超过 500ms,避免长事务
HikariCP 连接池 15~25application-dev.yml复用连接,控制并发 DB 连接数
自定义线程池 20~50ThreadPoolConfig异步任务有界队列 5000
幂等字段 out_trade_nogroup_buy_order_list防止用户重复点导致多笔订单
责任链按需加载TradeLockRuleFilterFactory不会因为一个过滤器拖垮整条链路
notify_task 异步队列 + 定时回调GroupBuyNotifyJob拼团完成的通知不和主链路耦合
DCC 动态配置(Redis Pub/Sub)DCCService + DCCValueBeanFactory活动期间能不停机调整白名单/降级开关

5.2 还没做的(阶段二、三要补的)

  • Redis 分布式锁:RedissonService.getLock(...) 已经写好,但 TradeLockOrderService 里没有调用;
  • 本地缓存(Caffeine):sku / group_buy_activity 这种几乎不变的数据,每次请求都查 DB 浪费;
  • 消息队列削峰:拼团成功后的通知走的是「数据库轮询」,应该换成 RabbitMQ 异步消费;
  • 限流:没有 Sentinel/Hystrix,瞬时流量进来会直接把 DB 打挂;
  • 降级开关:DCCService.downgradeSwitch 字段已经预埋,但没有任何业务逻辑读它——这是个伏笔。

六、阶段一的亮点到底在哪里?

聊完技术细节,我想单独拎出来说说这套阶段一设计里我个人最喜欢的几个点。它们不一定是最 fancy 的技术,但都是「能让我后续省事」的设计。

6.1 责任链 + 策略模式:业务的可扩展性

TradeLockRuleFilterFactory 用一个 LinkArmory 把所有规则串起来,新加一条规则只需要写一个 @Service 类、在工厂里加一行,不用动现有代码。AbstractDiscountCalculateService + 4 个子类也是同理,新加一种优惠(满 200 减 50、买二送一)只是再写一个 @Service("M2J1") 类。

这是「面向扩展开放、面向修改关闭」的最朴素实践,但非常有效。

6.2 DCC(Dynamic Config Center):不停机调参

这个是我最喜欢的。DCCService 里三个字段:

@DCCValue("downgradeSwitch:0") private String downgradeSwitch;  // 降级开关
@DCCValue("cutRange:100")      private String cutRange;         // 切流比例
@DCCValue("scBlacklist:s02c02")private String scBlacklist;      // 渠道黑名单

启动时从 Redis 读默认值,运行时通过 GET /api/v1/gbm/dcc/update_config?key=downgradeSwitch&value=1 推送新值,Redis Pub/Sub 通知到所有 JVM 实例,BeanPostProcessor 反射改字段。不需要重启服务就能调降级开关、切流比例——这套机制在生产环境做活动运营会救命。

6.3 异步通知 + 重试:和主链路解耦

notify_task 表 + GroupBuyNotifyJob 每 15 秒扫一次的组合,是经典的「事务性发件箱」(Transactional Outbox)模式:

  • 拼团成功的事务里只做一件事——插一行 notify_task,不依赖任何外部服务;
  • 定时任务异步消费,重试最多 5 次,最终失败入「死信」人工处理;
  • HTTP 和 MQ 两种回调都能支持,看 notify_type 字段。

这样做的好处是——拼团结算的事务永远不会因为通知服务挂了而回滚,两个系统的可用性彻底解耦。

6.4 DDD 分层 + 聚合根:领域逻辑独立可测

GroupBuyOrderAggregate / GroupBuyTeamSettlementAggregate 把「一次拼团操作」涉及的实体打成一个包,外部只通过聚合根改状态。这让领域层可以完全脱离 Spring、MyBatis、Redis 来写单元测试——TradeLockOrderService.lockMarkctPayOrder() 接收的是 POJO,返回的是 POJO,没有一行 SQL、没有一行 Redis。

后期想重构存储(比如把 MySQL 换成 TiDB),domain 层一行都不用动。这就是 DDD 的好处。

6.5 一个完整的 demo 可观测

我把这套部署到了 https://yanshen.online/group-buy/,线上服务是 systemd 托管的 group-buy-market.service,后端 Java jar 跑在端口 8091,前面 nginx ^~ /group-buy/ 静态 + ^~ /api/ 反代。你现在打开这个页面,点几下按钮就能走完整个流程。所有 SQL、Redis、DCC 的状态都能在 phpmyadmin (8899)、redis-commander (8081) 里实时看到。

这种「写完就能跑、能跑就能看」的可观测性,比任何 PPT 上的架构图都有说服力。

七、小结 & 下一步

阶段一做的事情可以一句话概括:用 DDD 分层 + 责任链 + DCC + 异步通知,建了一个能跑起来、能讲清楚、但不抗压的拼团最小系统。

下一步(阶段二)我打算补这几个:

  1. 真分布式锁:用 RedissonService.getLock(teamId) 把锁单入口收口;
  2. 本地缓存:Caffeine 缓存 group_buy_activity + sku + group_buy_discount,读多写少;
  3. MQ 替换轮询:notify_task 表的扫描改成 RabbitMQ 消费;
  4. 降级开关生效:DCCService.downgradeSwitch 接到具体业务(比如切流、白名单);
  5. 单元测试:给责任链每个过滤器写单测,确保加新规则不破坏老的;
  6. 集成测试:用 JMeter 模拟 1k 并发锁单同一个 teamId,验证 100% 不超售。

这套工程在 https://yanshen.online/group-buy/ 上一直跑着,欢迎随时打开试。如果有想交流的、想看源码的,或者发现什么 bug,评论区见 👋


本文基于已部署上线版本 https://yanshen.online/group-buy/ 写作,所有接口调用、字段含义均与线上实现一致。源码 fork 自小傅哥的 group-buy-market-yanshen 项目。

标签: none

添加新评论