用DDD设计聊天系统:限界上下文划分与聚合边界实战
服务端一听到“聊天系统”绝大部分人的第一反应是建一张 message 表sender_id、receiver_id、content、create_time完事。我最早也这么干直到产品经理把已读回执、多端同步、消息撤回、群聊、会话置顶、输入中状态排进迭代之后那张表开始四面漏风。后来我用 DDD领域驱动设计把整个聊天模块重做了一版才真正理解聊天系统的复杂度从来不在“表结构”而在业务规则和状态流转。这篇文章不聊消息队列选型、不聊长连接怎么保活只讲领域模型怎么切、聚合边界怎么定、消息状态机怎么设计以及我在落地过程中踩过的坑和最终的取舍。写这些东西的时候我尽量把代码和结论都留给你看完能直接照着画上下文、建聚合、定事件而不是停留在“DDD 很美好”的层面。1. 聊天系统的复杂度不在增删改查而在规则归属1.1 一张 message 表能撑多久很多团队做聊天功能的前期极其顺利列表查询一条 SQL 搞定历史消息翻页也简单连索引都不用多建几个。但需求是会长的加了已读回执表里开始出现 read_flag、read_time加了撤回出现 recalled_flag加了多端已读同步出现 device_read_position加了引用回复又得加 reply_to_id。每加一个字段Service 里就多一段判断逻辑多一个“根据状态不同走不同分支”的 if。等到 status 字段有了五六个取值、每个取值在三个接口里都有不同行为时团队里已经没人能说清“一条消息从发出到已读到底经历了什么状态、哪些规则在约束它”。这时候最可怕的事情发生了同样的判断逻辑被复制到三四个地方有人用了 status 1有人用 status ! 0还有人忽略 status 直接查时间字段。线上出了 bug排查的人靠肉眼对比各处的 if 分支找差异。这是聊天系统最典型的复杂度来源——它不是一个存储问题而是一个业务规则持续演化的问题。消息会不会被发出、能不能撤回、已读怎么推进、成员退了群消息还在不在这里面每一句都是规则而且规则和规则之间还会互相影响。1.2 表驱动设计为什么先甜后苦表驱动设计的本质是让数据结构决定业务行为。接口天然跟着表走加功能等于加字段加字段等于动表结构动表结构又牵连所有查询。一个最典型的例子在设计“撤回”功能时如果团队一直按“改 status 字段”的思路走就会忽略撤回背后的业务约束——操作人必须是消息发送者、撤回必须在某个时间窗内、消息如果已经被对方读到就不该再撤回。这些约束放在哪最常见的归宿是 ApplicationService 里堆 if。一个 Service 两三千行每个方法开头都是各种状态判断和权限校验。新人接手想改一个召回逻辑得先把整个 Service 读一遍才敢动手。DDD 的价值恰恰在这里把规则放回模型里Service 只负责编排和协调。领域对象是有行为的不是只有 getter/setter 的数据类。你让当前很火的 AI 代码工具去生成聊天模块它大概率给你一个“标准三层架构”Controller、Service、MapperService 里全是 if。原因很简单语料里大部分代码就是这么写的。AI 擅长的是模式匹配但“撤回窗口内不许撤回已读消息”这种业务规则它不知道怎么替你建模因为你没告诉它规则长什么样。1.3 我判断建模好坏的一个土办法这些年我判断一个模型好不好有个特别土的标准改一个功能看要动几个地方。如果加一个“引用回复”要改五张表、三个 Service、一个推送模板说明规则散落得太厉害。如果加一个“群主解散群聊”你能明确说出它落在哪个上下文、动哪个聚合、发什么事件不需要翻遍所有代码去找哪里漏了模型就算立住了。DDD 并不神秘它做的最核心的一件事就是逼迫你把业务规则钉在它该在的位置而不是让规则随着需求摇摆散落得到处都是。2. 限界上下文划分聊天域不是一张 message 表2.1 先划上下文再谈模型第一次做 DDD 的人最容易犯的错是上来就画类图、建实体。正确顺序恰恰相反先划限界上下文。上下文是模型和团队的边界每个上下文内部有一套自己的“通用语言”上下文之间通过事件或防腐层通信不直接共享一张表、不互相调用对方的仓储。为什么先划上下文因为聊天这个领域里“用户”“消息”“会话”这些词在不同场景下含义完全不同。“用户”在联系人列表里是一个可被拉黑的好友在会话里是一个可以被禁言的成员在实时连接里只是一个 session 标识。你如果把这三个角色塞进一个 User 实体里模型会立刻变得臃肿不堪。2.2 我把聊天系统划成了这几个上下文上下文核心概念职责用户与联系人上下文User、ContactRelationship、Block账号资料、好友关系、黑名单、拉黑/解除拉黑会话上下文Conversation、Member、LastReadPosition会话创建、成员管理、成员角色、每人的已读位置消息内容上下文Message、MessageBody、MessageSeq消息创建、正文渲染、撤回规则、消息不可变约束投递与回执上下文DeliveryRecord、ReadReceipt设备推送结果、送达确认、已读明细可按团队规模并入消息上下文实时连接上下文Connection、Session长连接生命周期、心跳、断线重连、路由严格说是支撑子域用户与联系人上下文不关心你发了什么消息它只负责“谁和谁能建立关系”。会话上下文负责“哪些人组成了一个群、谁能发言、谁读到了哪”。消息内容上下文负责“这条消息说了什么、它处于什么状态、能不能撤回”。投递回执上下文负责把“设备确认收到了”翻译成领域事件再反哺给会话上下文更新已读位置。实时连接上下文我刻意单独拆了出来。因为它关心的维度和业务模型完全不在一个平面上——连接 ID、心跳超时、断线重连、消息路由到哪个网关节点。如果用户实体里写满了 lastOnlineSocketId、lastHeartbeatTime 这些字段模型立刻被技术细节污染。这个上下文通常不需要引入 DDD它就是个技术支撑子域用现成的网关框架维护即可。2.3 上下文之间怎么协作上下文划分完紧接着的问题是发一条消息到底要经过几个上下文我先说结论会话上下文和消息内容上下文会在同一个用例里协同——应用服务同时加载 Conversation 聚合和 Message 聚合在同一个事务里完成“校验成员身份 创建消息 更新会话最后一条消息”。而投递与回执上下文是通过订阅消息/会话上下文发布的事件异步协作的。事件是上下文之间最干净的桥它让上游不需要知道下游有多少个消费者。一个动作跨上下文时我的判断标准是这件事值得让另一个上下文立刻知道吗如果值得就发领域事件如果不值得就不需要同步。比如设备收到推送这件事下游只是更新送达记录完全可以用事件异步做。如果哪天要接第三方 IM 的数据再在边界上加防腐层把外部模型翻译成内部模型绝不让外部表结构渗透进来。3. 聚合边界让 Conversation 当老大Message 不硬塞进去3.1 两种建模范式之争聊到具体建模问得最多的问题是Message 到底算不算 Conversation 的一部分第一种做法Message 是 Conversation 聚合内部的实体。好处是强一致发消息、改成员、更新已读位置全都在一个聚合里完成随便你怎么查都能保证数据一致。坏处也很明显一个群聊的会话可能沉淀几十万条历史消息如果 Message 是聚合内部实体加载会话就等于把消息全捞进内存既不现实也没必要。第二种做法Message 独立成聚合内部持有 conversationId。好处是性能可控消息表和会话表可以分开存储、分开扩展历史消息查询直接走消息表索引。坏处是要自己维护“消息一定属于某个有效会话”这个跨聚合不变量。3.2 我的选择Message 不可变独立聚合Conversation 只保留位置与成员我最终选了第二种核心理由有三个。第一消息的高频操作是顺序追加。聊天场景里几乎不存在并发修改同一条消息的需求一条消息发出去之后就没人再改它的正文。并发写量低聚合的版本冲突风险就很低独立成聚合完全可行。第二消息一旦发送正文不可变。撤回不是把 content 改成空字符串而是追加一个 RECALLED 状态和撤回时间引用回复、编辑如果要做也是新增记录不是原地修改。不可变对象天然好缓存、好并发、好做事件溯源独立出来没有任何负担。第三会话聚合不需要消息全文。它只需要知道“最后一条消息是什么、每个成员读到了哪条”也就是指针和位置而不是消息内容本身。把这两个概念拆开各自聚合适配各自的扩展方向会话表不会因为消息量增长而膨胀。这个决策落到代码上大概是这样的public class Conversation { private final ConversationId id; private final MapMemberId, MemberRole members; private MessagePointer lastMessage; private final MapMemberId, MessageSeq lastReadPosition; public Message sendMessage(UserId sender, MessageBody body, MessageId messageId, MessageSeq seq) { MemberId memberId new MemberId(sender.id()); if (!members.containsKey(memberId)) { throw new MemberNotInConversationException(id, memberId); } Message message new Message(messageId, this.id, sender, body, seq, MessageState.SENT); this.lastMessage new MessagePointer(messageId, seq); return message; } public void markRead(MemberId memberId, MessageSeq upToSeq) { // 已读位置只前进、不后退天然防住旧消息重复触发事件 MessageSeq current lastReadPosition.getOrDefault(memberId, MessageSeq.ZERO); if (upToSeq.gt(current)) { lastReadPosition.put(memberId, upToSeq); registerEvent(new ReadPositionAdvancedEvent(id, memberId, upToSeq)); } } }3.3 聚合 ID 必须由领域生成不依赖数据库这里有个 DDD 里容易忽略的细节聚合 ID 应该在领域层生成用 UUID 或雪花算法而不是让数据库自增主键决定业务身份。原因很实际聊天场景里客户端需要提前生成消息 ID 来做幂等和乐观展示如果服务端等数据库返回自增 ID整个交互时序就乱了套。聚合 ID 是业务身份不是存储技术。我在这个项目里所有聚合 ID 都用雪花算法生成数据库主键只是兜底存储。3.4 跨聚合一致性靠应用用例编排不强上分布式事务严格意义上的 DDD 会建议一个事务只更新一个聚合。我发消息时同时更新了 Conversation 和 Message 两个聚合严格来说是破了戒。但我的实际考量是这两个聚合因为同一条命令触发属于强相关的业务行为放在同一个本地事务里保存损失极小、收益是数据一致性有保障。但跨上下文、跨微服务的强一致我坚决不做聊天系统本质能接受最终一致强行跨库搞事务是给自己埋雷。4. 消息状态机与领域事件把“已读”建模成真正的领域规则4.1 消息状态到底有哪几个很多设计会把“发送中”“发送失败”也做成服务端消息状态我试过之后发现这其实是把客户端状态和服务端状态混为一谈了。客户端那个转圈的 loading、断网重发是客户端自己的状态机服务端根本不应该为它建状态。服务端只要把消息持久化成功了这条消息就是 SENT推送没推到设备那是投递层的事不是消息内容的属性。领域内真正需要的状态只有四个状态触发动作后续影响SENT服务端持久化成功触发消息入库、向在线接收方推送DELIVERED接收方设备确认收到推送投递回执上下文更新送达记录READ接收方打开会话或滑动到该位置已读位置推进、未读数减少RECALLED发送方在时间窗内发起撤回客户端替换显示、会话列表文案变化这里有个特别值得说的点READ 状态我几乎从不逐条存储。实际系统里已读是一个“位置”概念——你在会话里读到第 150 条那么前 150 条都算已读。所以我把已读建模成 Conversation 上的 lastReadPosition 游标用消息序号MessageSeq表示每次 markRead 只更新一个位置不需要逐条去改消息表。这也是为什么已读回执的本质归属在会话聚合而不是消息聚合。DELIVERED 和 READ 的区分依据是设备行为DELIVERED 由推送通道成功回调触发说明消息到达了用户设备READ 由用户浏览行为触发说明用户真正看到了。两者都发生在消息上下文外部所以通过领域事件回灌进来分别调用 Message.markDelivered() 和 Conversation.markRead() 更新状态。4.2 状态转移必须由领域方法触发来看撤回这个场景。撤回不是简单地把状态改成 RECALLED它至少要回答三个问题操作人是不是发送者、时间窗口是否过期、这条消息是不是已经被读。这些规则我会全部收进 Message 聚合的领域方法里public class Message { private final MessageId id; private final ConversationId conversationId; private final UserId senderId; private final MessageBody body; private final MessageSeq seq; private MessageState state; private Instant recalledAt; public void recall(UserId operator, Instant now) { if (!senderId.equals(operator)) { throw new NotMessageOwnerException(id); } if (state MessageState.READ) { throw new MessageAlreadyReadException(id); } if (now.isAfter(this.sentAt.plus(2, ChronoUnit.MINUTES))) { throw new RecallWindowExceededException(id); } this.state MessageState.RECALLED; this.recalledAt now; registerEvent(new MessageRecalledEvent(id, conversationId, seq, operator, now)); } public void markDelivered() { if (this.state MessageState.SENT) { this.state MessageState.DELIVERED; } } }为什么一定要把状态转移封装在领域方法里而不是让 Service 里直接 setState因为 Service 可以调用但没人能保证每个调用方都记得先做“是不是本人”的判断。规则放哪就决定了它会不会被绕过。领域方法把规则和一个行为绑定单元测试直接 new 一个 Message 调 recall各种边界条件都能测。我做完这段重构之后这类状态流转的回归 bug 少了一大半。4.3 领域事件别只停在内存里用 Outbox 保证可靠发布消息发出后下游要做的事情不止一件会话列表要更新 lastMessage、接收方设备要收到推送、未读数要减、投递回执要落库。这些动作不可能都塞在发消息的事务里领域事件就是拿来干这个的。我在这个系统里一开始只定义了最核心的几个事件MessageSentEvent、ReadPositionAdvancedEvent、MessageRecalledEvent、MemberAddedEvent。但事件发布有个坑直接在事务里调用 mq.send 是错的。如果事务回滚了消息队列里的事件已经发出去了下游处理了一个本不该存在的事件就产生脏数据。我在生产里用的方案是 Transactional Outbox事件和数据一起写在同一个本地事务里落一张发件箱表后台任务扫描这张表把事件投递到消息队列消费端再做幂等。方案一致性和可靠性额外成本适用场景事务内直接发 MQ可能丢事件或发脏事件最低允许少量丢失的活动通知本地消息表 定时任务可靠但有延迟一张表 任务调度对实时性不高的同步Transactional Outbox可靠且延迟可控需要后台捞取与投递聊天这种核心链路聊天是核心链路这块不能省。消费端幂等也同样重要事件可以被重放重放不能导致未读数被重复减。4.4 事件风暴的产出物只是草稿不是圣旨很多团队做事件风暴特别热闹一群人贴满了一墙便利贴定义了几十个事件。我自己的经验是第一次定义的那些事件最后真正用得上的往往只有一半。事件是在建模过程中慢慢长出来的不是靠头脑风暴一次性定死的。我的建议是先定义两个最核心的事件比如 MessageSentEvent 和 ReadPositionAdvancedEvent把整条链路跑通等真正理解业务之后回头查漏补缺。事件命名、字段设计、事件粒度都会随着你对业务的理解加深而改变一开始追求完美是浪费时间。5. 应用服务编排与基础设施解耦SendMessage 用例怎么落地5.1 一个完整用例的代码走查理论讲了这么多落到一个最核心的用例上发送消息。这是所有聊天系统的主干道把它理清楚其他用例都顺着这个模式做。public record SendMessageCommand( ConversationId conversationId, UserId senderId, String content, MessageId clientMessageId ) {} Service public class ChatApplicationService { private final ConversationRepository conversationRepository; private final MessageRepository messageRepository; private final OutboxPublisher outboxPublisher; Transactional public SendMessageResult sendMessage(SendMessageCommand cmd) { // 1. 幂等检查客户端重试直接返回旧结果 if (messageRepository.existsByClientMessageId(cmd.clientMessageId())) { return SendMessageResult.duplicated(cmd.clientMessageId()); } // 2. 加载会话聚合完成成员校验和序号分配 Conversation conversation conversationRepository.load(cmd.conversationId()); MessageSeq seq conversation.nextSeq(); Message message conversation.sendMessage( cmd.senderId(), MessageBody.of(cmd.content()), cmd.clientMessageId(), seq ); // 3. 两个聚合同事务保存 messageRepository.save(message); conversationRepository.save(conversation); // 4. 事件通过 Outbox 可靠发布 outboxPublisher.publish(new MessageSentEvent( message.getId(), conversation.getId(), cmd.senderId(), seq, Instant.now() )); return SendMessageResult.ok(message.getId(), seq); } }注意看这段代码里没有一行 SQL、没有一个 MQ 的 API。应用服务只负责编排流程、管理事务边界真正的业务规则——你是不是成员、你能不能发、序号怎么分配、消息状态初始值是什么——全在 Conversation 和 Message 里。这才是 DDD 想达到的效果业务复杂度被关在领域层应用层薄成一张纸。5.2 仓储接口属于领域层实现属于基础设施层这里要澄清一个非常常见的错误有人会在应用服务里直接 new 一个 MongoTemplate 或者 JdbcTemplate。领域层的正确姿势是定义仓储接口接口里只出现聚合相关的领域概念比如 load、save、existsByClientMessageId。至于底层是 MySQL、MongoDB 还是分库分表后的中间件都是基础设施层的实现细节。聚合完全不知道底层存储长什么样这才能保证未来从 MySQL 迁到分布式存储时领域模型一行不用改。仓储接口的粒度按聚合定义不搞“通用大仓库”。每个聚合一个接口方法就两三个按 ID 加载、保存、按业务唯一键查重。够用别过度设计。5.3 幂等不能漏聊天客户端在弱网下重试是常态用户也可能手快连点两下发送。所以客户端生成的 clientMessageId 必须作为幂等键。服务端在消息表上给 client_message_id 建唯一索引重复请求直接返回已存在的结果。有个细节容易被忽略幂等检查要放在业务校验之前。如果先校验成员身份再查幂等重试请求会因为成员校验通过才走到幂等逻辑看起来还行但如果在成员校验前就命中唯一索引异常就会走进异常分支给客户端返回一个“消息发不出去”的错误体验很奇怪。正确顺序是先查幂等命中就返回旧结果没命中再往下走。5.4 事务边界要克制别动不动跨库事务发消息这个用例里我同时更新了 Conversation 和 Message 两个聚合前文说过这是有意的因为同源命令且强相关。但再往外的边界我坚决不碰推送、未读数更新、会话列表刷新全部是异步事件驱动的最终一致。你要真敢把推送也塞进同一个事务里等所有下游返回线上延迟会让你怀疑人生。DDD 和分布式事务是两回事事务只保真正需要强一致的那一小块其余交给事件和幂等。6. 实战避坑清单这些坑我替你们踩过了6.1 别把 DDD 做成“数据库设计前置版”现在大家喜欢让 AI 写代码AI 生成 CRUD 确实一把好手但你要是让它照 DDD 写聚合它大概率给你一代农一堆看似规范实则空转的类。判断一个类是不是真聚合标准特别简单它里面有没有业务方法有没有要维护的不变量有没有注册领域事件。如果一个类只是把表字段包成 getter/setter那它不叫聚合叫 ORM 映射。换个包名不叫落地 DDD。6.2 消息历史查询永远别走聚合刚重构那会儿我犯过错想复用聚合做历史消息分页结果一条 SELECT 就能解决的问题被 ORM 加载了几千条实体对象再翻页内存和 GC 都遭罪。后来我彻底切了 CQRS写入走聚合命令模型查询走专门的读模型。历史消息表、会话列表缓存、未读数快照全部是读模型由领域事件异步更新。聚合是为了保证业务一致性存在的不是为了查询快。这条经验我后来用在不同项目里每次都物超所值。6.3 已读回执改成位置游标式事件量降了一个数量级第一版设计我把“每条消息已读”都做成一个事件群里某个人滑一下聊天记录产生了上百条 ReadEvent消息队列和回执存储一起爆炸。后来改成位置游标一个人在会话里读了多少只产生一个 ReadPositionAdvancedEvent下游根据位置差推导未读范围。这个优化让事件量直接降了一个数量级DBA 看完改动跟我说早该这么干。6.4 上下文数量要克制别硬拆我见过一个聊天项目划了九个上下文连“表情包上下文”“通知上下文”都算上明显是硬凑的。DDD 的上下文划分应该跟着业务能力和团队结构走业务上没有明确边界的模块别硬拆。一个三五人的团队维护三四个上下文已经足够上下文不是越多越优雅而是越贴合业务和组织的边界越合理。表情包这种功能塞在消息内容上下文里做一个值对象比单开一个上下文靠谱得多。6.5 聚合内的规则要敢删别堆“防御式设计”写聚合时容易犯的另一个毛病是堆防御式校验所有字段做非空、所有方法开头做权限判断不管这个场景下是否需要。聊天领域里消息正文就是不能为空的但“发送者不允许给自己发消息”这种奇葩规则如果你不确定业务是否真的需要先别写进聚合。聚合里的每一条规则都应该是业务明确要求而且是当前需要的不是“以防万一”的。防御式设计写多了聚合会变得很重后面每次需求变更都要跟这一堆校验搏斗。6.6 测试是 DDD 的隐藏福利聚合把规则收进来之后单元测试变得异常好写。不再需要 mock 数据库、不需要启动 Spring 容器直接 new 一个 Conversation调用 sendMessage断言消息状态、断言注册的事件、断言异常分支。我对状态流转、撤回规则、已读位置推进都写了 given-when-then 风格的测试这一层覆盖率上来了Service 层的回归压力小很多。如果你做完一版模型发现没法写单元测试大概率是规则没归位还在 Service 里飘着。收尾前再分享一点我的体会D DD 这套东西最难的地方从来不是战术模式——实体、值对象、聚合、领域事件这些概念几天就能学会。难的是你能不能沉住气先想清楚业务里到底有哪些规则、哪些状态、哪些不变量然后让模型长出正确的形状。我做这个聊天系统的过程中最大的收获不是代码写得更好看了而是以后再接到“加个功能”的需求时我能很快说出这个改动落在哪个上下文、动哪个聚合、发什么事件。如果你也在做聊天系统我建议从限界上下文清单开始先画一张上下文关系图然后在最核心的会话和消息两个上下文里动手做聚合。别贪多先跑通一条发消息的链路再把撤回、已读、多端同步的规则一点点收进模型。模型是会生长的前提是它一开始就有正确的位置可以生长。