一次资损:限领 1 张的券,用户领了 2 张
1 月初的一个上午,运营在群里 @ 我:一张"新客专享 50 元券",配置的是每人限领 1 张,有用户领到了 2 张,已经核销了一张。
我查了数据库:
mysql> SELECT user_id, coupon_id, count(*) c FROM coupon_grant
-> WHERE coupon_id = 10237 GROUP BY user_id HAVING c > 1 LIMIT 5;
+---------+-----------+---+
| user_id | coupon_id | c |
+---------+-----------+---+
| 882341 | 10237 | 2 |
| 901552 | 10237 | 2 |
| 774109 | 10237 | 2 |
+---------+-----------+---+
3 张超发,金额 150 元。钱不多,但性质很糟——这是规则被绕过,不是并发没控制住。
我们的失血模型长什么样
当时的优惠券代码是标准的三层架构 + 失血模型。Coupon 这个类是这么写的:
@Data
@TableName("coupon")
public class Coupon {
private Long id;
private String name;
private Integer type;
private BigDecimal amount;
private BigDecimal threshold;
private Integer totalCount;
private Integer grantedCount;
private Integer limitPerUser;
private Integer status;
private LocalDateTime startTime;
private LocalDateTime endTime;
}
没错,一个纯 Lombok @Data,除了字段什么都没有。所有逻辑都在 CouponService 里,这个类的实际行数是 2147 行。
领券的入口有四个:H5 领券中心、商品详情页的领券组件、新人礼包自动发放、客服后台手动补发。前三个各自调了 CouponService 的不同方法:
// 领券中心
public void grantFromCenter(Long couponId, Long userId) {
Coupon coupon = couponMapper.selectById(couponId);
checkTime(coupon); // 校验了时间
checkLimitPerUser(coupon, userId); // 校验了限领
doGrant(coupon, userId);
}
// 商品详情页,另一个同事写的
public void grantFromDetail(Long couponId, Long userId) {
Coupon coupon = couponMapper.selectById(couponId);
checkStatus(coupon); // 校验了状态
doGrant(coupon, userId); // 漏了 checkLimitPerUser
}
// 新人礼包,三个月前写的,那时还没有 limitPerUser 字段
public void grantFromGift(Long couponId, Long userId) {
Coupon coupon = couponMapper.selectById(couponId);
doGrant(coupon, userId);
}
出事的就是商品详情页那个入口。它上线于 2020 年 10 月,比 limitPerUser 字段晚一个月,写的时候没人提醒他要加限领校验。
失血模型的三个真实危害
这次事故之后我整理了代码,发现问题比"漏了一行校验"严重得多:
- 规则可以被绕过。只要有一条路径不经过校验方法,规则就不成立。四个入口就是四份规则副本,改一次要同步四处。
- 无法单元测试。
CouponService依赖 Mapper、Redis、Dubbo,测一个限领规则要 mock 一堆东西。我们这个服务 2147 行的 Service 只有 12 个测试,覆盖率 8%。 - 并发安全靠运气。
grantedCount的更新是UPDATE coupon SET granted_count = granted_count + 1,靠数据库保证原子性,这没问题。但"查限领数量 + 插入发放记录"这两步之间没有锁,只有一处入口加了 Redis 分布式锁,另外三处没有。之所以只超发 3 张,纯粹是因为并发量低。
我把这段贴给团队看的时候,说了一句:我们的 Coupon 不是对象,是一张会走路的数据库表。
第一步:找出聚合根
我们没搞事件风暴那种大阵仗,就四个人在会议室,把优惠券相关的表画在白板上,问了两个问题:
- 哪些东西必须"同生共死",改一个必须同时改另一个?
- 外部要操作这批数据时,从谁开始?
结论是 Coupon(券的模板/批次)是聚合根,CouponGrantRecord(发放记录)在它的边界内。判断依据:校验"用户领了几张"必须查发放记录,而发放记录离开了券模板没有意义(没人会单独查"所有用户的领券记录"这个业务动作)。
而 CouponTemplate(券的展示模板)、UserAccount(用户账户)不在这个聚合里,它们是别的聚合根。
外部只能通过聚合根的方法操作聚合内的一切。改造后:
public class Coupon extends AggregateRoot<Long> {
private Long id;
private CouponStatus status;
private TimeRange validRange;
private GrantRule grantRule; // 值对象:限领数量、总量、库存
private int grantedCount;
/**
* 唯一的领券入口。所有发放路径都必须走这里。
*/
public CouponGrantRecord grantTo(long userId, Clock clock) {
// 不变量 1:券必须处于可领取状态
if (!this.status.canGrant()) {
throw new CouponNotGrantableException(this.id, this.status);
}
// 不变量 2:必须在有效期内
if (!this.validRange.contains(clock.now())) {
throw new CouponExpiredException(this.id);
}
// 不变量 3:总量不能超发
if (this.grantedCount >= this.grantRule.totalCount()) {
throw new CouponSoldOutException(this.id);
}
this.grantedCount++;
CouponGrantRecord record = CouponGrantRecord.of(
this.id, userId, this.grantRule, clock.now());
registerEvent(new CouponGrantedEvent(this.id, userId, record.getCode()));
return record;
}
/**
* 限领校验放在发放记录这一侧,由 repository 提供计数。
* grantedCount 是聚合内的,用户维度的领取数要从仓储查。
*/
public void checkUserLimit(long userId, int alreadyGranted) {
this.grantRule.checkUserLimit(userId, alreadyGranted);
}
}
关键变化:没有任何人能从外部 setGrantedCount()。字段没有 setter,状态只能通过 grantTo() 改变,而 grantTo() 里三个不变量一个都跑不掉。
仓储:只存取聚合根
仓储接口定义在领域层,实现扔到基础设施层。这是依赖倒置的关键——领域层不认识 MyBatis。
// domain 层
public interface CouponRepository {
Coupon findById(Long id);
int countGrantedByUser(Long couponId, Long userId);
void save(Coupon coupon);
void saveGrantRecord(CouponGrantRecord record);
}
// infrastructure 层
@Repository
public class CouponRepositoryImpl implements CouponRepository {
@Autowired private CouponMapper couponMapper;
@Autowired private CouponGrantMapper grantMapper;
@Override
public Coupon findById(Long id) {
CouponPO po = couponMapper.selectById(id);
return CouponConverter.toDomain(po); // PO -> 领域对象
}
@Override
public void save(Coupon coupon) {
couponMapper.updateById(CouponConverter.toPO(coupon));
}
}
新增了 CouponConverter 做 PO 和领域对象的转换。这层转换挺烦的,但它把数据库表结构和领域模型解耦了——后来我们把 status、type 这些整数换成枚举,数据库一行没动。
仓储有一条铁律:仓储方法返回的是聚合根,不能返回 CouponGrantRecord 让上层自己去改。一旦允许,不变量就又散出去了。
领域服务:只处理跨聚合的事
领券这个动作涉及两个聚合:Coupon 和 UserAccount(要判断用户是不是新客)。单个聚合根干不了,这时候上领域服务:
@Service
public class CouponGrantService {
@Autowired private CouponRepository couponRepository;
@Autowired private UserAccountRepository accountRepository;
@Autowired private DistributedLock lock;
@Transactional
public String grant(Long couponId, Long userId) {
String lockKey = "coupon:grant:" + couponId + ":" + userId;
return lock.execute(lockKey, 3, TimeUnit.SECONDS, () -> {
Coupon coupon = couponRepository.findById(couponId);
int already = couponRepository.countGrantedByUser(couponId, userId);
coupon.checkUserLimit(userId, already);
UserAccount account = accountRepository.findById(userId);
coupon.checkUserTag(account.getTags()); // 新客校验
CouponGrantRecord record = coupon.grantTo(userId, Clock.systemUTC());
couponRepository.saveGrantRecord(record);
couponRepository.save(coupon);
return record.getCode();
});
}
}
领域服务本身很薄,它负责编排(加锁、加载、调领域对象、保存),不负责业务规则。规则全在聚合根和值对象里。
现在四个入口全部收敛成一行:
couponGrantService.grant(couponId, userId);
漏校验这件事,从"code review 要盯着"变成了结构上不可能。
值对象:把一组规则打包
GrantRule 是个值对象,它把"限领多少、总量多少、什么时间能领"这几条规则打包成一个不可变对象:
public final class GrantRule {
private final int totalCount;
private final int limitPerUser;
private final TimeRange activeRange;
public GrantRule(int totalCount, int limitPerUser, TimeRange activeRange) {
if (totalCount <= 0) {
throw new IllegalArgumentException("totalCount 必须大于 0");
}
if (limitPerUser <= 0) {
throw new IllegalArgumentException("limitPerUser 必须大于 0");
}
this.totalCount = totalCount;
this.limitPerUser = limitPerUser;
this.activeRange = activeRange;
}
public void checkUserLimit(long userId, int alreadyGranted) {
if (alreadyGranted >= limitPerUser) {
throw new CouponLimitExceededException(userId, limitPerUser, alreadyGranted);
}
}
// 值对象没有 setter,改动就创建新对象
public GrantRule extendTotal(int additional) {
return new GrantRule(this.totalCount + additional,
this.limitPerUser, this.activeRange);
}
@Override
public boolean equals(Object o) { /* 按值比较,不是按引用 */ }
@Override
public int hashCode() { ... }
}
值对象的三个特征:构造时校验、没有 setter、按值比较相等。它不可变,所以可以随便传递,不用担心被谁改了。
把校验放进构造函数这一步很关键。以前 limitPerUser 是个 Integer 字段,运营在后台填了 0 也能存进去,运行时才暴露问题。现在构造的时候就直接抛异常,脏数据从源头进不来。
落地中真正麻烦的部分
原理讲清楚只要一小时,做下去全是具体困难:
- MyBatis 映射富对象很别扭。
GrantRule是个值对象,在表里是三个平铺字段。我们没上 JPA,用了 MyBatis 的<resultMap>+ 自定义TypeHandler,写了大概 200 行转换代码。 - 团队接受度。有同事问"就加个校验至于这么麻烦吗"。我把那 3 张超发的券截图给他看了,之后没再问。但确实有人不认同,我们最后达成的共识是:核心交易链路(券、库存、订单)用领域模型,后台管理类的 CRUD 继续用失血模型。
- 不要一上来就搞 CQRS、事件溯源。我们 2021 年只做了聚合根、值对象、仓储这三层,事件机制只是发了个 Spring Event 用于发券后清缓存,没有做成事件驱动架构。
- 单元测试的收益最快。改造后
Coupon这个聚合根不依赖任何 Spring,测一个"超发时抛异常"只要 6 行代码。三个月后这个包的测试覆盖率从 8% 涨到 61%,新增的规则 bug 是 0 个。
@Test
public void should_throw_when_sold_out() {
Coupon coupon = CouponFixture.couponWithTotal(100);
IntStream.range(0, 100).forEach(i -> coupon.grantTo(i, FIXED_CLOCK));
assertThrows(CouponSoldOutException.class,
() -> coupon.grantTo(999L, FIXED_CLOCK));
}
最后的目录结构
com.xxx.coupon
├── interfaces/ 对外接口层
│ ├── CouponController.java
│ └── GrantRequest.java
├── application/ 应用服务层(编排、事务、加锁)
│ ├── CouponGrantService.java
│ └── CouponQueryService.java
├── domain/ 领域层(不依赖 Spring、不依赖 MyBatis)
│ ├── model/
│ │ ├── coupon/
│ │ │ ├── Coupon.java 聚合根
│ │ │ ├── CouponGrantRecord.java 实体
│ │ │ ├── GrantRule.java 值对象
│ │ │ └── CouponStatus.java 枚举
│ │ └── shared/
│ │ ├── AggregateRoot.java
│ │ └── TimeRange.java
│ ├── repository/ 仓储接口(只有接口)
│ │ ├── CouponRepository.java
│ │ └── UserAccountRepository.java
│ ├── service/ 领域服务(跨聚合)
│ │ └── CouponGrantDomainService.java
│ └── event/
│ └── CouponGrantedEvent.java
└── infrastructure/ 基础设施层
├── repository/
│ ├── CouponRepositoryImpl.java
│ └── converter/CouponConverter.java
└── persistence/
├── CouponMapper.java
└── CouponPO.java
domain 包我特意加了一条检查规则:用 ArchUnit 写了个单测,禁止 domain 包依赖 Spring 和 MyBatis。
@Test
public void domain_should_not_depend_on_framework() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "com.baomidou..", "org.apache.ibatis..")
.check(classes);
}
这个测试拦下过三次违规,都是有人图省事在领域对象里注入了 Mapper。
下篇预告
这篇先把《DDD 落地第一步:贫血模型到领域模型的转变》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。