聊天与引用回复的 SQL 怎么设计

做聊天功能时,最容易先想到一张表:id、发送人、接收人、内容、时间。它足以演示两个人互发消息,却很快会遇到几个问题:群聊怎么表示?一条消息回复的是哪条?消息重复发送怎么办?翻历史消息为什么越翻越慢?已读和未读又存在哪里?

这篇文章从一个可落地的模型开始,使用 PostgreSQL 16 语法,逐步写出建表、发消息、引用回复、查历史、列会话和更新已读的 SQL。示例以文字消息为主,图片、撤回、推送和大规模分库放在最后讨论。MySQL 或 SQLite 可以沿用模型,但 IDENTITY、LATERAL、部分索引和冲突处理语法需要改写。

一、先分清"聊天"和"回复"

在聊天系统里,用户按发送键,无论文字是不是在回答别人,数据库里都是一条新消息。只有产品要求"点击某条消息进行引用回复"时,才需要记录它指向的原消息。

text 复制代码
会话 100
  seq=1  小明:周五开会吗?
  seq=2  小红:开。                 reply_to_seq=1
  seq=3  小明:几点?               reply_to_seq=NULL

这里 seq=2 是一条正常消息,同时通过 reply_to_seq=1 关联了被回复的消息。没有必要再建一张结构几乎相同的 replies 表。若产品做的是帖子下的多级评论,那属于另一种树形讨论模型,查询和展示规则也不同。

本文先实现三个核心实体:

text 复制代码
users ──< conversation_members >── conversations ──< messages
                                             messages.reply_to_seq ──> 同会话的旧消息

二、先说清楚几个设计约定

为了让后面的 SQL 不靠猜,先固定规则:

  1. 一个 conversation 是一个私聊或群聊;成员关系放在 conversation_members。
  2. messages 以 (conversation_id, seq) 作为主键,seq 是会话内递增序号,不是全站递增 ID。
  3. 引用回复只允许指向同一会话中更早的消息。
  4. 同一发送人用 client_message_id 给一次发送请求生成 UUID;重试复用它,服务端用唯一约束去重。
  5. last_read_seq 是成员的已读游标;数字只向前移动。
  6. 示例默认成员能看会话完整历史。若新群成员只能看入群后的内容,要增加可见起点。

为什么不用 created_at 当消息排序键?时间戳会相同,客户端时钟不可信,事务并发提交顺序也不一定等于时间戳顺序。单个会话里由数据库事务分配的 seq,更适合分页、同步和已读游标。

三、建表:用户、会话、成员与消息

下面是一套可以从空库依次执行的核心 DDL。实际业务可能已有用户表,此时把 app_users 换成现有主键即可。

sql 复制代码
CREATE TABLE app_users (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nickname    TEXT NOT NULL
);

CREATE TABLE conversations (
    id                 BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kind               TEXT NOT NULL CHECK (kind IN ('direct', 'group')),
    title              TEXT,
    direct_user_low    BIGINT REFERENCES app_users(id),
    direct_user_high   BIGINT REFERENCES app_users(id),
    next_seq           BIGINT NOT NULL DEFAULT 1 CHECK (next_seq > 0),
    created_at         TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
    CONSTRAINT direct_pair_shape CHECK (
        (kind = 'direct'
            AND direct_user_low IS NOT NULL
            AND direct_user_high IS NOT NULL
            AND direct_user_low < direct_user_high)
        OR
        (kind = 'group'
            AND direct_user_low IS NULL
            AND direct_user_high IS NULL)
    ),
    CONSTRAINT unique_direct_pair UNIQUE (direct_user_low, direct_user_high)
);

CREATE TABLE conversation_members (
    conversation_id   BIGINT NOT NULL REFERENCES conversations(id),
    user_id           BIGINT NOT NULL REFERENCES app_users(id),
    joined_at         TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
    left_at           TIMESTAMPTZ,
    last_read_seq     BIGINT NOT NULL DEFAULT 0 CHECK (last_read_seq >= 0),
    PRIMARY KEY (conversation_id, user_id)
);

CREATE TABLE messages (
    conversation_id     BIGINT NOT NULL REFERENCES conversations(id),
    seq                 BIGINT NOT NULL CHECK (seq > 0),
    sender_id           BIGINT NOT NULL,
    client_message_id   UUID NOT NULL,
    reply_to_seq        BIGINT,
    body                TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 4000),
    created_at          TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
    deleted_at          TIMESTAMPTZ,
    PRIMARY KEY (conversation_id, seq),
    CONSTRAINT unique_client_message
        UNIQUE (conversation_id, sender_id, client_message_id),
    CONSTRAINT sender_is_member
        FOREIGN KEY (conversation_id, sender_id)
        REFERENCES conversation_members(conversation_id, user_id),
    CONSTRAINT reply_in_same_conversation
        FOREIGN KEY (conversation_id, reply_to_seq)
        REFERENCES messages(conversation_id, seq),
    CONSTRAINT reply_points_backward
        CHECK (reply_to_seq IS NULL OR reply_to_seq < seq)
);

这里有几个值得专门解释的约束:

  • unique_direct_pair 把两人 ID 按小到大存,避免 A→B 和 B→A 创建两条私聊。PostgreSQL 的唯一约束允许多个含 NULL 的群聊记录;群聊不受这组私聊字段限制。
  • sender_is_member 保证发送人至少在成员表里;"当前是否仍在群里"还需在发送事务里检查 left_at IS NULL。
  • 复合外键 (conversation_id, reply_to_seq) 保证回复目标在同一个会话;reply_to_seq < seq 阻止指向未来消息,也让回复链不可能形成环。
  • deleted_at 用来保留消息位置和引用关系;删除时可把正文替换为占位文本。是否需要彻底清除原内容,应按产品和数据保留规则另行设计。

索引先建最必要的

sql 复制代码
CREATE INDEX idx_members_active_user
    ON conversation_members (user_id, conversation_id)
    WHERE left_at IS NULL;

CREATE INDEX idx_messages_replies
    ON messages (conversation_id, reply_to_seq, seq)
    WHERE reply_to_seq IS NOT NULL;

messages 的主键 (conversation_id, seq) 已经提供 B-tree 索引,可用于按会话查历史、按 seq 倒序扫描;unique_client_message 也自带唯一索引。不要为了"可能更快"再建一份相同的 (conversation_id, seq DESC) 索引,先看真实查询计划。

四、创建私聊:先去重,再加入双方成员

下面的 $1、$2 是数据库驱动传入的用户 ID。应用要先验证两个用户都存在且不相同。

sql 复制代码
BEGIN;

INSERT INTO conversations (kind, direct_user_low, direct_user_high)
VALUES ('direct', LEAST($1, $2), GREATEST($1, $2))
ON CONFLICT (direct_user_low, direct_user_high) DO NOTHING;

SELECT id
FROM conversations
WHERE direct_user_low = LEAST($1, $2)
  AND direct_user_high = GREATEST($1, $2);

-- 将上一步返回的 id 作为下面的 $3。
INSERT INTO conversation_members (conversation_id, user_id)
VALUES ($3, $1), ($3, $2)
ON CONFLICT (conversation_id, user_id)
DO UPDATE SET left_at = NULL;

COMMIT;

唯一约束是并发去重的最终保障。两个请求同时创建同一私聊时,一个插入,另一个在冲突处理后查询已存在的会话。若你的产品允许"退出私聊",上面的重进会清除 left_at;历史是否重新可见,需要另加产品规则。

群聊的创建方式类似,但要写入 kind='group' 和标题,再批量插入成员。邀请、踢人和重新加入时,除了成员状态,还要考虑"新成员能看多少历史"和旧消息是否继续可见。

五、发送消息:序号、权限和幂等必须在同一事务里

客户端生成 client_message_id,网络超时后重试原 UUID,不要每次重试都生成新值。服务端收到请求后按以下步骤执行;步骤间由应用代码读取返回值并决定继续还是回滚。

sql 复制代码
BEGIN;

-- $1=conversation_id,$2=sender_id。锁住成员行,防止检查后被并发移出。
SELECT 1
FROM conversation_members
WHERE conversation_id = $1
  AND user_id = $2
  AND left_at IS NULL
FOR SHARE;
-- 无结果:ROLLBACK,返回"无发送权限"。

-- $3=client_message_id。已有结果:比较请求内容,返回旧消息并 COMMIT。
SELECT seq, body, reply_to_seq
FROM messages
WHERE conversation_id = $1
  AND sender_id = $2
  AND client_message_id = $3;

-- 会话行锁串行化该会话的序号分配;把返回值保存为 $4。
UPDATE conversations
SET next_seq = next_seq + 1
WHERE id = $1
RETURNING next_seq - 1 AS allocated_seq;

-- $5=body,$6=reply_to_seq(普通消息传 NULL)。
INSERT INTO messages (
    conversation_id, seq, sender_id,
    client_message_id, body, reply_to_seq
)
VALUES ($1, $4, $2, $3, $5, $6)
ON CONFLICT (conversation_id, sender_id, client_message_id) DO NOTHING
RETURNING seq;

-- 若 INSERT 没返回行,说明并发重试先写入了同一个 UUID:
-- 再 SELECT 原消息、比较内容并返回;不要把它当作一次新发送。
COMMIT;

这里的 $4 是上一条 UPDATE 返回的值,在应用中绑定到下一条语句;它不是 SQL 客户端自动跨语句传递的变量。事务建议使用 PostgreSQL 默认的 READ COMMITTED 隔离级别,并由服务端统一执行。发生数据库错误时要 ROLLBACK。

同一会话的发送都要更新同一行 conversations,因此顺序稳定:前一个发送事务提交或回滚后,后一个才能拿到下一个序号。回滚或并发幂等冲突可能留下序号空洞;序号只要求递增,不要求连续。极高吞吐的单个群聊可能在这行锁上形成热点,届时需要更复杂的分片或排序方案。

请求内容若与已存在的同一 client_message_id 不一致,应返回冲突,而不是悄悄把新内容当成旧请求。reply_to_seq 不存在、属于其他会话或指向未来时,外键与检查约束会拒绝写入。若群聊有限制历史可见的规则,还要在业务层确认发送人有权看到被引用的那条消息。

六、查聊天历史:用游标,不用深度 OFFSET

第一页把 $2 设为 NULL;向上翻页时,把当前列表中最小的 seq 作为下一页的 $2。服务端校验 $3 在合理范围内,例如 1~100。

sql 复制代码
SELECT
    m.seq,
    m.sender_id,
    CASE WHEN m.deleted_at IS NULL THEN m.body END AS body,
    m.deleted_at,
    m.created_at,
    m.reply_to_seq,
    p.sender_id AS reply_sender_id,
    CASE WHEN p.deleted_at IS NULL THEN p.body END AS reply_body
FROM messages AS m
LEFT JOIN messages AS p
       ON p.conversation_id = m.conversation_id
      AND p.seq = m.reply_to_seq
WHERE m.conversation_id = $1
  AND ($2::BIGINT IS NULL OR m.seq < $2)
ORDER BY m.seq DESC
LIMIT $3;

查询返回"最新到最旧",客户端展示聊天记录时可把这一页反转为正序。服务端必须先验证当前用户对 $1 会话有读取权限;不要把 conversation_id 当成可自行访问的公开资源。即使消息被删除,仍保留占位行,引用它的消息才能显示"原消息已删除"。

OFFSET 100000 LIMIT 50 在历史很深时会跳过大量行,也容易因新消息插入产生重复或遗漏。seq < 游标 的键集分页在聊天场景更稳定。

若要查"某条消息下面有哪些直接回复",可以利用上面的回复索引:

sql 复制代码
SELECT seq, sender_id, body, created_at
FROM messages
WHERE conversation_id = $1
  AND reply_to_seq = $2
ORDER BY seq ASC
LIMIT $3;

这只查直接回复;若要展示完整多级讨论树,应先决定层级上限和交互形式,再选择递归查询或单独的讨论模型。

七、列出我的会话与未读状态

会话列表要显示最近一条消息,并给出是否有未读。以下查询用 LATERAL 为每个会话取一条最新消息,用 EXISTS 检查未读,适合成员会话数不太大的常规场景:

sql 复制代码
SELECT
    c.id,
    c.kind,
    c.title,
    last_msg.seq AS last_seq,
    CASE WHEN last_msg.deleted_at IS NULL THEN last_msg.body END AS last_body,
    last_msg.created_at AS last_at,
    EXISTS (
        SELECT 1
        FROM messages AS unread
        WHERE unread.conversation_id = c.id
          AND unread.seq > cm.last_read_seq
          AND unread.sender_id <> $1
          AND unread.deleted_at IS NULL
    ) AS has_unread
FROM conversation_members AS cm
JOIN conversations AS c ON c.id = cm.conversation_id
LEFT JOIN LATERAL (
    SELECT seq, body, deleted_at, created_at
    FROM messages
    WHERE conversation_id = c.id
    ORDER BY seq DESC
    LIMIT 1
) AS last_msg ON true
WHERE cm.user_id = $1
  AND cm.left_at IS NULL
ORDER BY last_msg.created_at DESC NULLS LAST, c.id DESC
LIMIT $2;

要显示精确未读数,可以把 EXISTS 换成同条件的 COUNT(*),但大群聊和大量会话会增加查询成本。先量真实数据;达到瓶颈后再维护会话摘要或未读计数,不要一开始就引入难保证一致性的缓存。私聊标题通常从另一位成员资料获得,群聊则可使用 conversations.title。

八、更新已读游标

用户真正看到某条消息后,客户端上报该 seq。以下 SQL 只接受数据库中存在的消息,并保证已读游标不会倒退:

sql 复制代码
UPDATE conversation_members AS cm
SET last_read_seq = GREATEST(cm.last_read_seq, $3)
WHERE cm.conversation_id = $1
  AND cm.user_id = $2
  AND cm.left_at IS NULL
  AND EXISTS (
      SELECT 1
      FROM messages AS m
      WHERE m.conversation_id = $1
        AND m.seq = $3
  )
RETURNING last_read_seq;

$1 是会话,$2 是当前用户,$3 是已看到的序号。服务端还应检查用户对该消息有读取权限。新群成员如果不允许看入群前历史,可以在加入时把 last_read_seq 初始化为加入前的最新序号,并增加 visible_from_seq 作为查询下界;仅靠 joined_at 做分页边界不够稳。

九、删除、实时推送和附件怎么扩展

删除消息:保留位置,清理展示内容

如果产品允许发送人撤回或删除自己的消息,可以保留行和 seq,把正文替换成占位文本:

sql 复制代码
UPDATE messages
SET body = '[消息已删除]',
    deleted_at = clock_timestamp()
WHERE conversation_id = $1
  AND seq = $2
  AND sender_id = $3
  AND deleted_at IS NULL;

这样后续消息的游标和引用关系不会断。管理员删除、双方删除、彻底删除与法定保留要求是不同功能,不能只用一个 deleted_at 混在一起。

实时推送:数据库提交后再通知

WebSocket、推送通知不是 SQL 本身提供的。最简单的做法是事务提交后推送,但服务进程可能在"提交成功、通知尚未发出"之间崩溃。需要可靠投递时,在同一个发送事务 里插入 outbox 事件,由后台任务读取并投递;消费者仍按 (conversation_id, seq) 去重。数据库保存事实,推送只是把事实及时告诉在线客户端。

图片和文件:另建附件表

大文件放对象存储,数据库记录 message_attachments(message_id/会话序号、对象键、类型、大小、状态)。本例的 body NOT NULL 适用于纯文本;增加图片消息后,应引入 message_type 并用约束规定"文本有正文、图片有附件",不要把二进制内容直接塞进 body。

十、上线前检查这几个边界

问题 需要确认
权限 查历史、发消息、引用原消息、更新已读都要验证会话成员与可见范围
幂等 客户端重试是否复用 UUID,同一 UUID 的内容变化是否被拒绝
顺序 同会话消息是否按数据库分配的 seq 排序,客户端是否只用它做游标
索引 用 EXPLAIN (ANALYZE, BUFFERS) 查看历史、回复和会话列表的真实计划
并发 两人同时创建私聊、同时发送、退出群聊与发送同时发生时结果是否符合预期
扩展 热门群的序号分配锁、精确未读数、附件与实时推送何时成为瓶颈
数据治理 删除、备份、保留期、隐私导出与数据迁移如何执行

聊天 SQL 的核心不是某条复杂查询,而是让会话归属、消息顺序、引用关系和读取进度都有明确的事实来源。先用关系约束和事务把正确性做稳,再按真实流量优化列表和推送,通常比先堆缓存更容易维护。

参考资料

相关推荐
APItesterCris44 分钟前
告别反复手工查价:用 Open‑Claw 搭建商品监控与数据分析小工具
大数据·数据库·数据仓库·自动化
来福猿1 小时前
分布式锁 Redis 实现源码分析:SETNX、过期时间与可重入设计
数据库·redis·分布式
xiangyun611 小时前
C++ list 模拟实现(一):手撕双向链表,从节点到迭代器
数据库·c++·算法·链表·stl·list
wespaul1 小时前
一条自然语言命令的 49 个节点:可视化编排与每段强校验
前端·数据库
KIDULT°10 小时前
MySQL系列 (1):数据库原理
运维·数据库·mysql·云计算
我不是程序员三三10 小时前
透明加密软件偶发解密异常排错实录:文件时能打开时乱码
数据库·域智盾
snhna10 小时前
向量数据库深度实战:从 HNSW 到 pgvector、Milvus 选型与 RAG 落地
数据库·人工智能·python·postgresql·milvus
天行健,君子而铎10 小时前
数据分类分级的范式转换:从规则匹配到场景化高准确率一键部署
网络·数据库·安全
牛战士coding11 小时前
MySQL 读写分离完整链路:主从复制、多数据源与 @DS 路由
数据库·mysql