把单体应用拆成微服务之后,最先撞上的问题往往不是服务间通信,而是数据模型怎么分。订单服务需要商品名称和价格,购物车需要库存数量,营销服务需要用户等级——这些字段散落在不同服务的数据库里,如果各服务都去远程查询,链路一长性能就崩;如果抽一个公共的entity包给所有人引用,又等于把耦合从数据库层搬到了代码层。共享数据模型的管理,本质上是在服务自治和数据复用之间找平衡点,处理不好,微服务就退化成了“分布式单体”。下面展开聊聊几种主流的管理策略和它们的取舍。

为什么公共entity包是个坑
很多团队的第一反应是把实体类抽成common模块,比如一个Product类被商品、订单、库存、营销四个服务同时依赖。短期看确实省事,字段定义一处修改处处生效,但代价很快就会显现:任何一个服务因为业务变化想给Product加字段或改类型,其他三个服务都必须重新打包发布。数据库表结构本该是各服务的私有实现,现在却通过一个共享类被间接绑死,等于每个服务都获得了修改别人数据的“合法通道”。
更隐蔽的问题在于语义漂移。订单上下文里的“商品”关注的是下单时刻的价格快照和规格,商品上下文里的“商品”关注的是当前售价和上下架状态,库存上下文里的“商品”只是一个SKU编码和数量。强行用一个类表达三种视角,最终会得到一个字段臃肿、一半字段对使用者无意义的“上帝实体”,而且谁都不敢删旧字段,只能越堆越多。
所以业界的基本共识是:服务间的数据模型共享,要共享的是契约而不是实现。契约可以共享,实现必须各自私有,这是后面所有方案的大前提。
按限界上下文拆分模型:同一实体的多个投影
DDD里的限界上下文(Bounded Context)给了很实用的思路:同一个业务概念在不同上下文中允许有不同的模型。商品服务里的Product是完整聚合根,订单服务里则只需要一个轻量的ProductSnapshot,字段按需裁剪。
// 商品服务内部:完整的商品聚合
public class Product {
private Long id;
private String name;
private String detail; // 图文详情,订单服务不关心
private BigDecimal currentPrice;
private Integer status; // 上下架状态
}
// 订单服务内部:下单时刻的快照,字段按需保留
public class ProductSnapshot {
private Long productId;
private String name; // 只冗余名称用于展示
private BigDecimal price; // 锁定下单时价格,防止商品改价影响历史订单
private String spec; // 规格快照
}这种做法的核心价值是解除了时间上的耦合:快照记录的是下单那一刻的数据,商品后续改价、改名、下架都不会波及历史订单。字段冗余在微服务里不是坏事,而是刻意的设计决策——用存储空间换取链路独立性和查询性能。判断该冗余哪些字段,标准很简单:这个字段在当前业务流程中是否需要参与计算或展示?需要就拷贝一份,不需要就只留一个ID引用。
跨服务数据同步:接口拉取还是事件驱动
模型拆开之后,剩下的局部副本如何保持更新?主流方案有两类。一类是查询时拉取(同步),即订单服务在需要时调用商品服务的接口;另一类是订阅变更事件(异步),商品服务在数据变化后发消息,订阅方更新自己的本地副本表。
同步拉取实现简单、数据总是最新,适合实时性要求高但调用频率不高的场景,比如后台审核页面展示商品详情。缺点是可用性耦合:商品服务挂了,订单的展示链路跟着挂。事件驱动的本地副本则反过来,读性能极好且不受上游故障影响,代价是要接受短暂的数据不一致,还要处理消息丢失、乱序、重复消费等一堆工程问题。
// 商品服务:数据变更后发布事件
productRepository.save(product);
eventPublisher.publish(new ProductChangedEvent(
product.getId(), product.getName(), product.getCurrentPrice()));
// 订单服务:消费事件,更新本地商品副本表
@KafkaListener(topics = "product-changed")
public void onProductChanged(ProductChangedEvent event) {
localProductRepo.upsert(event.getProductId(),
event.getName(), event.getPrice());
}实践中通常是混合使用:交易主链路(下单)用快照落库保证 immutable,列表页和查询场景走本地副本,极少数实时校验场景才同步调用。选型的判断依据是业务能容忍多久的数据陈旧,而不是技术上的洁癖。
防腐层与共享内核:两类特殊场景的处理
当你的服务必须消费外部系统或遗留系统的模型时,防腐层(ACL)几乎是必选项。它在外部模型和内部模型之间做一次翻译,外部接口字段变化时只需要改防腐层的转换逻辑,内部代码纹丝不动。用一个简单的转换器举例:
// 防腐层:把外部商品DTO翻译成本上下文的模型
public class ProductTranslator {
public static ProductSnapshot toSnapshot(ExternalProductDTO dto) {
ProductSnapshot s = new ProductSnapshot();
s.setProductId(dto.getSkuId()); // 外部叫skuId,内部叫productId
s.setName(StringUtils.isBlank(dto.getTitle()) ? "未知商品" : dto.getTitle());
s.setPrice(dto.getSalePrice() == null ? BigDecimal.ZERO : dto.getSalePrice());
return s;
}
}另一个容易走极端的方案是共享内核(Shared Kernel):明确划出一小块真正稳定的模型(比如基础的用户ID、租户ID类型定义)由多个服务共同拥有,修改需要各方协商。它适合团队沟通顺畅、内核极小且极少变化的场景。经验法则是共享内核里的东西应该少到“一个下午就能全部重写”的程度,一旦膨胀就立刻退化成前面说的公共entity包陷阱。
落地时的几条实践建议
总结成可执行的规则:第一,每个服务只暴露契约(DTO、事件schema),绝不暴露JPA实体或数据库表结构;第二,契约版本化管理,字段只加不删,新增字段给默认值,避免消费方被动升级;第三,跨服务的数据一致性目标定为最终一致,明确各业务场景能接受的延迟窗口,并配上对账任务兜底;第四,跨服务查询优先考虑CQRS式的读模型冗余,而不是拼装多次RPC。
最后提醒一点:不要为了“模型纯净”过度拆分。如果两个服务的数据模型有八成以上重叠、变更节奏完全一致、且由同一个团队维护,那它们大概率本该是一个服务。共享数据模型管理的种种手段,前提是服务边界本身划得合理,边界划错了,再精巧的同步机制都是在给错误的设计打补丁。