
在线客服源码中的会话分配与消息存储怎么实现
在线客服系统的核心不只是“把访客发来的内容保存下来”,还包括把会话交给合适的坐席、避免并发分配造成超载、保证消息在重试时不重复,以及在网络中断后能够按顺序恢复历史记录。处理不好这些边界,系统在本地演示时可能正常,一旦多个访客同时进入、坐席状态频繁变化或客户端重复提交,就容易出现一条会话分给多人、消息重复、序号错乱等问题。
本文用 PostgreSQL 和 Node.js 的 pg 驱动说明一套可落地的最小实现。示例重点是数据模型、数据库事务和并发控制,不依赖特定在线客服源码框架。代码适合用作原型或现有系统的实现参考;上线时还需要接入真实的用户身份认证、授权、连接管理、限流、日志和监控。
一、先明确适用场景和分配规则
本文讨论的是以下常见流程:
- 访客创建一个待接入会话。
- 系统从在线坐席中选择一位没有超过承载上限的坐席。
- 坐席接入后,访客与坐席持续发送消息。
- 消息写入数据库,并可按会话和序号分页读取。
- 会话结束后,坐席的活动会话数不再计入容量。
“在线”“容量”“合适”都需要产品先给出明确定义。在线状态可能来自坐席主动心跳,也可能来自 WebSocket 连接;容量可能是每位坐席最多处理的活动会话数,也可能按技能组、租户或优先级分别计算。本文采用一个简单规则:只给 online = true 的坐席分配,按当前活动会话数从少到多尝试,且不超过坐席设置的 max_conversations。
这种规则适用于单个客服工作组、容量相对简单的系统。它不是复杂调度器的替代品。若需要技能匹配、VIP 优先、区域路由、排班和超时回收,应先把这些条件建模为可查询的字段或独立调度规则,再在事务内执行分配,而不是只在应用内取一个“当前最空闲”的坐席就直接写入。
二、数据模型:会话、坐席与消息
以下 DDL 面向 PostgreSQL。每个会话有一个当前坐席(未分配时为空),每条消息有会话内递增的序号。client_message_id 用于识别客户端重试的同一条消息;数据库唯一约束是幂等能力的最终保障,不能只靠应用内缓存。
CREATE TABLE agents (
id BIGSERIAL PRIMARY KEY,
display_name TEXT NOT NULL,
online BOOLEAN NOT NULL DEFAULT FALSE,
max_conversations INTEGER NOT NULL DEFAULT 5
CHECK (max_conversations >= 0)
);
CREATE TABLE conversations (
id BIGSERIAL PRIMARY KEY,
visitor_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'waiting'
CHECK (status IN ('waiting', 'assigned', 'closed')),
assigned_agent_id BIGINT REFERENCES agents(id),
last_message_seq BIGINT NOT NULL DEFAULT 0
CHECK (last_message_seq >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX conversations_waiting_idx
ON conversations (created_at, id)
WHERE status = 'waiting';
CREATE INDEX conversations_agent_active_idx
ON conversations (assigned_agent_id)
WHERE status = 'assigned';
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES conversations(id),
seq BIGINT NOT NULL,
sender_type TEXT NOT NULL
CHECK (sender_type IN ('visitor', 'agent', 'system')),
sender_id TEXT NOT NULL,
client_message_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (conversation_id, seq),
UNIQUE (conversation_id, client_message_id)
);
CREATE INDEX messages_history_idx
ON messages (conversation_id, seq);
sender_id 在这个最小例子中使用文本,便于表示访客 ID 或坐席 ID。正式系统通常会将访客、坐席和租户建成独立表,并使用外键或统一的用户表。消息正文可以使用 TEXT 保存纯文本;若要支持附件,通常另建附件表保存对象存储的 key、文件类型、大小和校验信息,不建议把大文件二进制直接塞进消息行。
索引要服务于实际查询。消息历史以会话和序号范围读取,因此使用 (conversation_id, seq);待分配会话通常按创建时间取最早的一条,因此为等待状态建部分索引。不要在没有数据规模和查询计划依据时堆叠大量索引:每增加一个索引,写入和维护成本也会增加。
三、并发分配:用数据库事务保护容量
常见错误是先在应用里查询坐席负载,再单独插入分配记录。两个请求可能同时查到“这位坐席还剩一个名额”,然后都分给他。解决思路是将“锁定坐席、统计其活动会话、写入分配结果”放在同一个数据库事务中,并且所有会影响容量的分配和关闭操作都遵循同一套锁规则。
下面的示例用 FOR UPDATE SKIP LOCKED 锁定候选坐席。一个事务正在检查某位坐席时,另一个事务会跳过被锁定的坐席,尝试其他人。锁定后再统计活动会话数,因此只要所有分配入口都先锁坐席行,检查和写入之间就不会被另一条遵守同一规则的分配操作插队。
安装依赖并设置连接字符串:
npm install express pg
DATABASE_URL='postgres://user:password@localhost:5432/support' node app.js
以下代码使用 Express 和 pg。为便于说明,创建会话和分配写在一个接口中。生产系统可以把“访客创建会话”和“后台调度器分配”拆成不同任务,但必须保留事务边界和并发约束。
const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json({ limit: '32kb' }));
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
async function createAndAssign(visitorId) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const conversationResult = await client.query(
`INSERT INTO conversations (visitor_id, status)
VALUES ($1, 'waiting')
RETURNING id, visitor_id, status`,
[visitorId]
);
const conversation = conversationResult.rows[0];
// 锁住候选坐席行。并发事务会跳过已被锁定的坐席。
const candidates = await client.query(
`SELECT id, max_conversations
FROM agents
WHERE online = TRUE
ORDER BY id
FOR UPDATE SKIP LOCKED`
);
let assignedAgentId = null;
for (const agent of candidates.rows) {
const countResult = await client.query(
`SELECT count(*)::int AS active_count
FROM conversations
WHERE assigned_agent_id = $1
AND status = 'assigned'`,
[agent.id]
);
const activeCount = countResult.rows[0].active_count;
if (activeCount < agent.max_conversations) {
assignedAgentId = agent.id;
break;
}
}
if (assignedAgentId !== null) {
await client.query(
`UPDATE conversations
SET status = 'assigned',
assigned_agent_id = $2,
updated_at = now()
WHERE id = $1`,
[conversation.id, assignedAgentId]
);
conversation.status = 'assigned';
conversation.assigned_agent_id = assignedAgentId;
}
await client.query('COMMIT');
return conversation;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
app.post('/conversations', async (req, res, next) => {
try {
// 示例假定 visitorId 已由认证中间件验证后放在请求上下文中。
// 不要直接信任客户端提交的 visitorId。
const visitorId = req.auth && req.auth.visitorId;
if (!visitorId) {
return res.status(401).json({ error: 'unauthenticated' });
}
const conversation = await createAndAssign(visitorId);
res.status(201).json(conversation);
} catch (err) {
next(err);
}
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'internal_error' });
});
app.listen(3000, () => {
console.log('Listening on port 3000');
});
代码中的 req.auth 是身份认证中间件写入的上下文,不是 Express 自动提供的属性。单独运行示例前,需要接入认证中间件,或者在本地测试中明确提供可信的开发身份;不要为了让接口“能用”就把请求体里的任意 visitorId 当作可信身份。示例里的错误处理也只是最小形式,生产环境应区分可预期的业务错误和服务端故障。
这里选择候选坐席时按 ID 排序,是为了让例子保持简单,并不代表负载均衡。可将查询改为按技能、队列优先级或其他确定性规则排序;但不能把并发安全寄托在排序上,关键仍是锁、事务以及所有写入路径一致遵守锁规则。
容量统计为什么不会超卖
假设坐席 A 还有一个名额。第一个事务锁住 A 的行并统计出活动数为 4;另一个事务选择候选时会跳过 A。如果第一个事务分配成功并提交,后续事务再获得 A 的行锁时,重新统计就会看到新的会话数。要使这一点成立,关闭会话、重新分配会话等改变活动数的操作,也必须先锁定相关坐席行,或者使用更强的数据库约束和专门的容量计数方案。
当等待会话数量很大、坐席数量很多时,上面的逐个统计会增加查询开销。可以维护坐席的活动计数,但要在同一事务中增减,并处理重复事件、会话转移和异常恢复。无论选实时计数还是计数器,都要明确一个不变量:同一条活动会话只能贡献一次负载,结束或转移后必须准确扣减。不要同时把两个方案当作互不相关的“近似值”。
若没有任何符合条件的在线坐席,接口返回的会话会保持 waiting,这是明确的业务状态,不应伪装成“已分配”。系统可以用后台任务稍后重试,也可以在坐席上线时触发分配。调度任务同样要通过事务领取等待会话,并防止多个调度实例同时处理同一条会话。
四、消息存储:幂等、顺序和权限
消息写入需要处理三类问题:同一请求被重试时不能重复保存;同一会话中的消息需要有可恢复的顺序;发送者必须确实有权限在该会话发言。HTTP 超时、WebSocket 重连和移动端离线重发都可能造成请求重复,因此客户端应为每次逻辑发送生成一个稳定的 clientMessageId,重试时沿用它。不能在每次重试时重新生成 ID,否则数据库无法识别同一条操作。
以下函数将“验证会话、检查幂等键、更新序号、插入消息”放入一个事务。会话行锁用于串行化同一会话的序号分配。不同会话之间仍可以并行写入。重复请求会读取并返回已存在的消息,不会再增加序号。
async function storeMessage({
conversationId,
senderType,
senderId,
clientMessageId,
body
}) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const conversationResult = await client.query(
`SELECT id, status, assigned_agent_id, last_message_seq
FROM conversations
WHERE id = $1
FOR UPDATE`,
[conversationId]
);
if (conversationResult.rowCount === 0) {
const err = new Error('conversation_not_found');
err.statusCode = 404;
throw err;
}
const conversation = conversationResult.rows[0];
// 权限判断必须使用认证后的身份,而不是信任请求体中的 senderId。
// 这里仅展示坐席与访客的基本归属检查;实际项目应接入完整鉴权逻辑。
if (senderType === 'agent' &&
String(conversation.assigned_agent_id) !== String(senderId)) {
const err = new Error('agent_not_assigned');
err.statusCode = 403;
throw err;
}
if (senderType === 'visitor' &&
conversation.status === 'closed') {
const err = new Error('conversation_closed');
err.statusCode = 409;
throw err;
}
const existing = await client.query(
`SELECT id, conversation_id, seq, sender_type, sender_id,
client_message_id, body, created_at
FROM messages
WHERE conversation_id = $1
AND client_message_id = $2`,
[conversationId, clientMessageId]
);
if (existing.rowCount > 0) {
await client.query('COMMIT');
return existing.rows[0];
}
const nextSeq = Number(conversation.last_message_seq) + 1;
const inserted = await client.query(
`INSERT INTO messages (
conversation_id, seq, sender_type, sender_id,
client_message_id, body
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, conversation_id, seq, sender_type, sender_id,
client_message_id, body, created_at`,
[
conversationId,
nextSeq,
senderType,
senderId,
clientMessageId,
body
]
);
await client.query(
`UPDATE conversations
SET last_message_seq = $2,
updated_at = now()
WHERE id = $1`,
[conversationId, nextSeq]
);
await client.query('COMMIT');
return inserted.rows[0];
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
序号使用会话行上的 last_message_seq,而不是对消息表执行 MAX(seq) + 1。后者在并发写入时可能由多个请求算出相同的下一序号;唯一约束虽然能阻止脏数据,但会让其中一个请求失败。锁定会话行后更新序号,则为同一会话提供了明确的串行化点。消息表的唯一约束仍然要保留,作为最后一道数据完整性保护。
消息 API 的调用方式可以是:
app.post('/conversations/:id/messages', async (req, res, next) => {
try {
const conversationId = Number(req.params.id);
const auth = req.auth;
if (!auth) {
return res.status(401).json({ error: 'unauthenticated' });
}
const { clientMessageId, body } = req.body || {};
if (!Number.isSafeInteger(conversationId) || conversationId <= 0) {
return res.status(400).json({ error: 'invalid_conversation_id' });
}
if (typeof clientMessageId !== 'string' ||
clientMessageId.length < 1 ||
clientMessageId.length > 128) {
return res.status(400).json({ error: 'invalid_client_message_id' });
}
if (typeof body !== 'string' || body.trim().length === 0) {
return res.status(400).json({ error: 'invalid_body' });
}
// 根据认证主体确定身份,不允许客户端自行指定 senderType 或 senderId。
const message = await storeMessage({
conversationId,
senderType: auth.type,
senderId: String(auth.id),
clientMessageId,
body
});
res.status(201).json(message);
} catch (err) {
next(err);
}
});
示例对会话 ID 和幂等键设置了接口层格式检查,正文只检查非空,没有设定业务字数上限。express.json({ limit: '32kb' }) 限制的是 JSON 请求体大小,不等同于正文字符数限制;一个请求的 JSON 编码、字段名称和其他字段也会占用字节。正式系统应根据产品需求明确正文字符数或字节数上限,并在服务端和数据库存储策略中保持一致。传给数据库的值使用参数化查询,避免将用户输入拼进 SQL 字符串。
示例的坐席权限检查只能说明基本思路:发言坐席必须是该会话当前分配的坐席。访客校验也不能仅检查 senderType,必须核对认证身份是否属于此会话。多租户系统还应校验会话和身份属于同一租户。权限判断失败应拒绝请求,而不能因前端隐藏了按钮就认为安全。
五、历史读取与断线恢复
历史记录不宜一次性返回整段会话。可以使用会话内序号做游标,获取某个序号之后的消息,既便于断线恢复,也避免使用不断变化的偏移量。示例查询如下:
SELECT id, seq, sender_type, sender_id, client_message_id, body, created_at
FROM messages
WHERE conversation_id = $1
AND seq > $2
ORDER BY seq ASC
LIMIT $3;
LIMIT 应由服务端设置合理上限,不能把未经校验的任意数值直接传给数据库。连接恢复时,客户端可提交最后确认的序号,服务端返回其后的消息;如果需要让客户端确认服务端已接收消息,也应区分“已接收”“已送达设备”和“已读”等状态,它们不是同一件事。可以建立单独的送达回执或已读位置表,避免在消息正文行上不断覆盖与消息内容不同的状态。
序号是数据库中的会话内顺序,不代表消息一定按网络到达时间排列,也不代表多个设备上展示顺序可以只靠客户端时间戳保证。客户端本地时间可能不准;服务端写入事务形成的序号更适合作为历史顺序依据。若业务需要表达编辑、撤回或删除,通常应使用额外事件或状态字段,并保留审计要求,而不是让客户端直接改写历史记录。
六、关闭会话和重新分配也要维护不变量
前面的分配示例通过统计 status = 'assigned' 的会话数计算负载。因此关闭操作至少要在同一事务中更新会话状态。若实现了“关闭会话必须锁定坐席”的规则,关闭时也应先锁坐席行,再锁会话行;所有代码路径尽量采用一致的锁定顺序,避免不同流程分别先锁坐席、先锁会话而产生死锁。
一种简单的应用约定是:分配、关闭和转移都先锁坐席,再锁会话;对于尚未分配的会话,先锁会话并在获得坐席时按统一顺序处理。复杂场景下更稳妥的做法是设计明确的锁顺序,发现 PostgreSQL 死锁错误时安全重试整个事务。不能只捕获错误后从失败的中间步骤继续运行。
会话从坐席 A 转给坐席 B 时,状态更新必须原子完成。若使用实时统计,分配结果提交前的状态变更会自然影响统计结果;若使用计数器,则必须在同一个事务中给 A 减一、给 B 加一,并确保重复转移不会重复加减。重复关闭也应具有幂等效果,不能让计数器被多扣一次。
七、消息数据库与实时推送的职责不同
WebSocket、SSE 或消息代理负责实时传输,关系数据库负责可靠保存和历史查询。不能把“已经向 WebSocket 发送成功”当作“消息已经可靠入库”,也不能因为数据库写入成功就假定客户端一定收到。一个常用顺序是先提交数据库,再尝试推送;客户端断线时通过历史接口补齐。
如果业务要求数据库提交后必须可靠地向多个消费者发布事件,可以在同一事务中写入消息和 outbox 事件,再由后台任务把 outbox 发布到消息代理并标记处理状态。这样可以避免“数据库提交成功,但进程在发布事件前崩溃”造成的通知丢失。即使采用 outbox,消费者仍应按事件 ID 做幂等处理,因为消息代理和后台任务可能重复投递。
附件上传通常先经过鉴权和文件校验,再写入对象存储,数据库保存附件元数据和存储标识。上传过程和消息事务很难成为一个跨服务的原子事务,应明确失败后的清理或补偿流程。不要把用户提供的文件名直接当作对象存储路径,也不要把可公开访问的长期凭据放进消息正文。
八、安全边界和参数说明
- 身份来源:访客 ID、坐席 ID 和租户 ID 必须来自服务端验证后的身份上下文。请求体中的同名字段不是授权凭证。
- 输入校验:校验 ID、正文类型、幂等键长度和请求体大小;还应按产品要求限制消息长度、频率和附件类型。具体上限应由需求和存储策略确定,不能把示例中的
32kb当成通用标准。 - SQL 安全:示例使用 PostgreSQL 参数占位符传递数据。参数化查询可以避免输入值被当作 SQL 语法解析,但不能替代业务权限校验。
- 内容展示:保存纯文本不等于输出时安全。将正文渲染到网页时应按 HTML 上下文进行转义;若产品允许富文本,应使用经过审查的白名单净化方案。不要把用户正文直接拼接进 HTML。
- 敏感信息:按业务要求减少不必要的个人信息,设置访问控制、备份保护和保留期限。数据库中有历史消息,并不意味着所有运维人员都应该能查看。
- 错误响应:对外避免回传 SQL、连接串和堆栈信息;内部记录可关联请求 ID 的错误日志,但日志中也应避免无必要地记录完整敏感正文。
- 滥用防护:针对访客和坐席接口设置适当的速率限制与连接限制。速率限制不是身份认证,也不能替代会话级授权。
九、常见错误及其原因
1. 先查负载,再单独写分配
这是典型的并发竞态。两个事务可能同时看到同一名额。应把锁定、统计和写入放在同一个事务内,并要求所有改变负载的操作遵守同一规则。
2. 只在应用缓存中保存幂等键
进程重启、多个服务实例和缓存过期都会让缓存失效。对需要持久幂等的消息,应在数据库设置唯一约束,并定义客户端重试时复用同一个键。
3. 用客户端时间戳排序消息
设备时钟可能偏差,网络也可能延迟或乱序。历史顺序应由服务端定义,例如本文的会话序号;客户端时间可作为显示或诊断信息,不能取代服务端顺序。
4. 把“在线”理解为绝对准确
断网后服务端通常要经过心跳超时才知道坐席离线,在线状态天然可能滞后。要明确心跳周期、过期判定和恢复策略;分配时可再检查数据库里的在线状态,但不能假定它与现实连接状态完全同步。
5. 消息写入成功就直接返回“已送达”
入库、推送到连接、客户端接收和用户阅读是不同阶段。接口应使用准确的状态语义,断线恢复应基于历史查询或事件游标。
6. 用“查不到会话”代替完整授权
即使会话 ID 很难猜,也不能据此授权。读取历史、发送消息、关闭会话和下载附件都应逐项检查调用者是否属于该会话及租户。
7. 在生产中无限制读取历史
一条会话可能积累大量消息。使用序号游标和服务端分页上限,避免把整段历史一次载入内存或通过单次响应传输。
十、实施前的检查清单
- 分配规则是否明确在线判定、容量口径、排队顺序和无可用坐席时的行为?
- 所有分配、关闭和转移入口是否使用一致的事务与锁定约定?
- 消息是否有数据库级幂等约束,以及会话内稳定顺序?
- 认证身份是否由服务端生成,所有消息和历史接口是否执行对象级授权?
- API 是否设置符合业务需要的请求体、正文和分页限制?
- 断线补取、重复投递、服务重启和数据库事务失败是否经过测试?
- 数据库迁移、备份恢复、索引维护和消息保留策略是否纳入运维流程?
会话分配与消息存储的可靠性,主要来自清晰的不变量:一条活动会话只归属一个当前坐席;容量判断和分配写入处于同一并发控制范围;同一逻辑消息有稳定幂等键;同一会话的消息顺序由服务端统一产生;所有读写都经过身份和会话权限校验。把这些规则落实到数据库约束和事务里,再用实时通道负责通知,才更容易应对重试、并发和断线等真实场景。









