本文目录导读:

- 方案一:极简版(小项目/单机应用)
- 方案二:标准版(推荐,适合大多数业务)
- 方案三:进阶版(高并发 / 多人群聊 / 企业微信风格)
- 关键索引设计(防死库)
- 软删除(回收站)逻辑
- 未读消息计数(性能优化)
- 给你的PHP代码建议(PDO示例)
- 总结建议
PHP站内信(私信/消息系统)的表设计通常需要考虑性能、扩展性和业务场景。
最核心的诀窍是:尽量避免“消息表”和“用户表”直接多对多关联的复杂查询,用“线程/会话”和“消息”分离的设计。
以下是三种从简单到复杂的表设计方案,你可以根据项目规模选择:
极简版(小项目/单机应用)
适合:用户量少,或仅需要类似“通知公告”或“系统通知”的场景,无需会话管理。
表结构:messages(消息表)
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
BIGINT (PK) | 主键 |
from_user_id |
INT | 发送者ID(0代表系统) |
to_user_id |
INT | 接收者ID |
content |
TEXT | |
is_read |
TINYINT(1) | 是否已读(0/1) |
created_at |
DATETIME | 发送时间 |
缺点:如果A发消息给B,B回复,会新建两条记录,查询“我和某人的聊天记录”需要查两次(from=A,to=B 或 from=B,to=A),索引效率低,且无法区分“会话”。
标准版(推荐,适合大多数业务)
这是最常用且扩展性良好的设计,将“会话”和“消息”分开。
会话表 conversations(线程)
存储两个用户之间的对话关系。
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
BIGINT (PK) | 会话ID |
user_one_id |
INT | 参与者1(统一存ID小的或先创建者) |
user_two_id |
INT | 参与者2 |
last_message_id |
INT | 最后一条消息ID(用于快速展示列表) |
updated_at |
DATETIME | 最后更新时间(用于排序) |
is_deleted_1 / is_deleted_2 |
TINYINT | 双方各自的“删除会话”状态(软删除) |
消息表 messages
字段名
类型
说明
id
BIGINT (PK)
主键
conversation_id
INT
外键索引(指向会话表)
sender_id
INT
发送者ID
receiver_id
INT
接收者ID(冗余存储,便于查询未读)
message_type
TINYINT
类型:1文字,2图片,3文件,4系统
content
TEXT
(或JSON存储附件URL)
is_read
TINYINT(1)
是否已读(0/1)
read_at
DATETIME
已读时间
created_at
DATETIME
发送时间
核心SQL设计要点:
- 在
conversations 表建立联合唯一索引 (user_one_id, user_two_id) 防止重复会话。
- 查询会话列表时:
SELECT * FROM conversations WHERE user_one_id = 我 OR user_two_id = 我 ORDER BY updated_at DESC。
- 查询聊天记录:
SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC。
进阶版(高并发 / 多人群聊 / 企业微信风格)
如果站内信包含群聊、会话成员管理或云端同步,需要拆分成“会话成员表”。
会话表 conversations
字段名
类型
说明
id
BIGINT (PK)
会话ID
type
TINYINT
1=单聊,2=群聊
name
VARCHAR
群名称(单聊为空)
owner_id
INT
创建者ID
created_at
DATETIME
创建时间
会话成员表 conversation_members
这是关键,用于解决“我删掉了会话,但对方没删”的问题。
字段名
类型
说明
id
BIGINT (PK)
主键
conversation_id
INT
会话ID
user_id
INT
用户ID
last_read_id
INT
用户在此会话中读到的最后一条消息ID(实现“未读消息数”)
is_muted
TINYINT
是否免打扰
is_deleted
TINYINT
该用户是否删除了此会话
消息表 messages
与方案二相同,但没有 receiver_id(因为可能是群发),只保留 sender_id。
关键索引设计(防死库)
无论选择哪种方案,以下索引必须建立,否则数据量大时必卡:
- 方案二:
INDEX (conversation_id, created_at)。
- 方案二:
INDEX (to_user_id, is_read) —— 这是查询未读消息数的关键。
- 方案三:
INDEX (user_id, conversation_id) 在成员表中。
软删除(回收站)逻辑
绝对不建议直接物理删除 messages 行,因为这会破坏会话的历史连续性。
推荐方案:在“会话表”或“成员表”中增加 is_deleted 字段。
- 当A删除与B的会话时,只更新
conversation_members 表中 A记录 的 is_deleted = 1。
- 当A再次发消息给B时,只需将A的
is_deleted 改回 0,旧消息仍然存在。
未读消息计数(性能优化)
避免每次都 COUNT(*) 整个消息表。
常用优化方案:
- 在
conversation_members 表存 last_read_id(最后读取的消息ID)。
- 计算未读数 =
SELECT COUNT(*) FROM messages WHERE conversation_id = ? AND id > last_read_id AND sender_id != 当前用户。
- 如果计数很大,可以定时任务(Cron)异步聚合,或将计数缓存到 Redis。
给你的PHP代码建议(PDO示例)
查询“与我相关的会话列表”时(方案二),SQL 可以这样写:
SELECT c.*,
-- 联查最后一条消息用于预览
m.content AS last_message,
m.sender_id AS last_sender,
m.created_at AS last_time,
-- 计算未读数(假设当前用户ID是::user_id)
(SELECT COUNT(*) FROM messages mc
WHERE mc.conversation_id = c.id
AND mc.receiver_id = :user_id
AND mc.is_read = 0) AS unread_count
FROM conversations c
LEFT JOIN messages m ON c.last_message_id = m.id
WHERE c.user_one_id = :user_id OR c.user_two_id = :user_id
ORDER BY c.updated_at DESC
LIMIT 20;
总结建议
- 如果只是系统发通知(单向):用方案一(一张表)就行。
- 如果是用户互聊:直接用方案二,最简单实用。
- 如果有群聊功能:用方案三。
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
BIGINT (PK) | 主键 |
conversation_id |
INT | 外键索引(指向会话表) |
sender_id |
INT | 发送者ID |
receiver_id |
INT | 接收者ID(冗余存储,便于查询未读) |
message_type |
TINYINT | 类型:1文字,2图片,3文件,4系统 |
content |
TEXT | (或JSON存储附件URL) |
is_read |
TINYINT(1) | 是否已读(0/1) |
read_at |
DATETIME | 已读时间 |
created_at |
DATETIME | 发送时间 |
核心SQL设计要点:
- 在
conversations表建立联合唯一索引(user_one_id, user_two_id)防止重复会话。 - 查询会话列表时:
SELECT * FROM conversations WHERE user_one_id = 我 OR user_two_id = 我 ORDER BY updated_at DESC。 - 查询聊天记录:
SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC。
进阶版(高并发 / 多人群聊 / 企业微信风格)
如果站内信包含群聊、会话成员管理或云端同步,需要拆分成“会话成员表”。
会话表 conversations
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
BIGINT (PK) | 会话ID |
type |
TINYINT | 1=单聊,2=群聊 |
name |
VARCHAR | 群名称(单聊为空) |
owner_id |
INT | 创建者ID |
created_at |
DATETIME | 创建时间 |
会话成员表 conversation_members
这是关键,用于解决“我删掉了会话,但对方没删”的问题。
| 字段名 | 类型 | 说明 |
|---|---|---|
id |
BIGINT (PK) | 主键 |
conversation_id |
INT | 会话ID |
user_id |
INT | 用户ID |
last_read_id |
INT | 用户在此会话中读到的最后一条消息ID(实现“未读消息数”) |
is_muted |
TINYINT | 是否免打扰 |
is_deleted |
TINYINT | 该用户是否删除了此会话 |
消息表 messages
与方案二相同,但没有 receiver_id(因为可能是群发),只保留 sender_id。
关键索引设计(防死库)
无论选择哪种方案,以下索引必须建立,否则数据量大时必卡:
- 方案二:
INDEX (conversation_id, created_at)。 - 方案二:
INDEX (to_user_id, is_read)—— 这是查询未读消息数的关键。 - 方案三:
INDEX (user_id, conversation_id)在成员表中。
软删除(回收站)逻辑
绝对不建议直接物理删除 messages 行,因为这会破坏会话的历史连续性。
推荐方案:在“会话表”或“成员表”中增加 is_deleted 字段。
- 当A删除与B的会话时,只更新
conversation_members表中 A记录 的is_deleted = 1。 - 当A再次发消息给B时,只需将A的
is_deleted改回0,旧消息仍然存在。
未读消息计数(性能优化)
避免每次都 COUNT(*) 整个消息表。
常用优化方案:
- 在
conversation_members表存last_read_id(最后读取的消息ID)。 - 计算未读数 =
SELECT COUNT(*) FROM messages WHERE conversation_id = ? AND id > last_read_id AND sender_id != 当前用户。 - 如果计数很大,可以定时任务(Cron)异步聚合,或将计数缓存到 Redis。
给你的PHP代码建议(PDO示例)
查询“与我相关的会话列表”时(方案二),SQL 可以这样写:
SELECT c.*,
-- 联查最后一条消息用于预览
m.content AS last_message,
m.sender_id AS last_sender,
m.created_at AS last_time,
-- 计算未读数(假设当前用户ID是::user_id)
(SELECT COUNT(*) FROM messages mc
WHERE mc.conversation_id = c.id
AND mc.receiver_id = :user_id
AND mc.is_read = 0) AS unread_count
FROM conversations c
LEFT JOIN messages m ON c.last_message_id = m.id
WHERE c.user_one_id = :user_id OR c.user_two_id = :user_id
ORDER BY c.updated_at DESC
LIMIT 20;
总结建议
- 如果只是系统发通知(单向):用方案一(一张表)就行。
- 如果是用户互聊:直接用方案二,最简单实用。
- 如果有群聊功能:用方案三。