☰
在线客服系统从MySQL迁移到PostgreSQL的完整实践与对比
2026/10/2 3:33:38 网站建设 项目流程

花了大概三周时间,我把自己的在线客服系统接上了 PostgreSQL,现在它可以同时跑 MySQL 和 PostgreSQL,通过一个环境变量切库。最初这只是用户的一句"能不能支持 PG",但真正动手以后,我发现这不仅是多写一套方言的问题,而是把整个系统的数据模型、事务边界、索引策略都重新审视了一遍。这篇文章就把整个思考过程、改造方案和踩坑记录完整写出来,给同样在独立开发、正在纠结数据库选型的朋友一个参考。

先简单交代一下我的在线客服系统:前端是访客聊天窗,后端有会话管理、消息收发、坐席工作台、历史记录、满意度评价、常用回复这几个核心模块。数据量方面,消息表是绝对的大头,一天可能写几十万条,会话表虽然量级小一些,但状态变更频繁,坐席分配、转接、结束会话都离不开它。最初我只支持 MySQL,表结构、索引、SQL 习惯全是 MySQL 思维。这次改造的核心目标是:不重写业务逻辑,让系统在两种数据库下都能跑,并且保证关键路径的性能不倒退。

1. 为什么在线客服系统要折腾 PostgreSQL

1.1 一次真实的需求触动

事情起因是一个开源社区的使用者提了个 issue,希望部署时能用 PostgreSQL。这类需求对独立开发者来说很现实:很多企业用户内部的运维标准就是 PostgreSQL,或者云厂商已经给他们开好了 PG 实例,再让他额外装一套 MySQL 数据库,业务部门往往不愿意。

但真正让我下决心的不是这一个需求,而是我在开发报表模块时连续遇到几个"MySQL 写起来很别扭"的场景:按小时统计消息量、查询某个访客的完整会话轨迹、在 JSON 字段里按属性过滤坐席面板配置。这些需求用 PostgreSQL 的窗口函数、JSONB、GIN 索引会顺手很多。可以说,支持 PostgreSQL 既是在服务用户,也是在满足我自己对新能力的渴望。

1.2 客服系统对数据库的真实诉求

在线客服系统的数据特征其实挺明显的,我把它拆成三类:

  • 会话表(conversations):量级中等,但更新非常频繁。每次状态流转、坐席变更、最后消息时间都要 UPDATE,并发冲突主要发生在这里。
  • 消息表(messages):纯追加为主,偶尔更新(消息撤回),增长极快,需要按会话翻页查询。
  • 业务配置和统计表:聊天气泡样式、常用回复、SLA 规则等,读多写少,形态比较自由,经常要扩展字段。

这个画像决定了数据库选型的关注点:并发更新下的锁行为、历史数据量增长后的检索能力、字段扩展的灵活性。MySQL 的 InnoDB 在常规 OLTP 下表现稳定,但在复杂查询、JSON 检索、全文搜索这几个维度的能力确实偏弱,而 PostgreSQL 恰好在这些方面更有优势。

1.3 我想达成的目标状态

我给自己定的目标不是"从 MySQL 迁到 PostgreSQL",而是"同时支持两个数据库"。上线之后用户想用 MySQL 也行,想用 PG 也行,我在 README 里提供两种部署方案。这样既不用强迫存量用户迁移,也能让新用户按自己的运维习惯选。这个目标决定了后续工作量的上限:所有涉及数据库行为的代码都要经过一层抽象,每个 SQL 都要过一遍兼容性检查。

说实话,这比"一次性迁移"更费工夫,但从长期维护和社区口碑来看是值得的。很多项目为什么一直绑死在一个数据库上?就是因为早期"能跑就行"的写法太多,后期想抽离发现无从下手。我这次算是提前还债。

2. 整体方案选型:ORM 方言切换还是手工适配层

2.1 先盘点代码里有多少"MySQL 味道"

动手之前我做了两件事:全局搜了一遍 native query 和复杂 SQL,又检查了 ORM 对象的使用方式。我的后端是 Node.js + TypeScript,ORM 用的是 TypeORM,迁移工具用 TypeORM migration。原生 SQL 主要集中在报表统计、历史消息分页、批量更新这几个地方,占比大概三成。

盘点完发现真正的风险点在于:LIMIT ? , ?的分页写法、REPLACE INTO、ON DUPLICATE KEY UPDATE、GROUP BY依赖的宽松模式、TINYINT(1)当布尔值、反引号包裹字段名。这些在 MySQL 里写习惯了根本没感觉,一到 PG 全是问题。

2.2 架构设计:数据库方言适配层

我的做法是在仓储层(Repository)上加一层"方言适配器"。业务代码不直接拼接 SQL 关键字,而是通过一个 adapter 对象拿当前数据库的方言信息。比如分页语法、布尔值写法、主键生成方式、保留字处理,都封装在 adapter 里。

export type DatabaseDialect = 'mysql' | 'postgres'; export class DialectAdapter { constructor(private dialect: DatabaseDialect) {} getLimitClause(offset: number, limit: number): string { if (this.dialect === 'mysql') { return `LIMIT ${offset}, ${limit}`; } return `LIMIT ${limit} OFFSET ${offset}`; } getUpsertClause(table: string, insertSql: string): string { if (this.dialect === 'mysql') { return `${insertSql} ON DUPLICATE KEY UPDATE updated_at = NOW()`; } return `${insertSql} ON CONFLICT (id) DO UPDATE SET updated_at = NOW()`; } getAutoIncrementDefinition(): string { return this.dialect === 'mysql' ? 'BIGINT AUTO_INCREMENT' : 'BIGINT GENERATED ALWAYS AS IDENTITY'; } }

这层抽象的价值不仅是语法差异,它把"方言相关知识"集中在一个文件里。以后要支持其他数据库,或者调整某个语法的兼容逻辑,只需要改这一个文件,而不是去几十个文件里替换 SQL 字符串。

2.3 为什么不用现成的数据库迁移中间件

市面上有一些 MySQL 转 PostgreSQL 的迁移工具,比如 AWS DMS 或者 pgloader。我用它们做过一次评估,结论是:工具擅长一次性数据搬家,但解决不了"长期双写、双读、双跑测试"的问题。我要的不是搬一次家,而是保证未来每次发布的新功能都能在两个数据库上跑通。

TypeORM 自带的多数据库支持也解决不了全部问题。它的抽象层能处理LIMIT/OFFSET这类基础语法差异,但对ON DUPLICATE KEY UPDATE、全文搜索、JSONB 索引这类深水区,还是要自己写方言分支。与其被框架的抽象带着走,不如自己掌握适配逻辑更可控。我的 migration 文件也做了双份处理:同一张表结构,在 MySQL 下用TINYINT(1)表示布尔,在 PG 下用BOOLEAN。两套 migration 存放在不同目录,运行时按配置选择执行哪套。

3. 接入 PostgreSQL 的实操改造全记录

3.1 数据类型映射:每张表都要过一遍

这是我建议你做的第一件事:把现有表结构全部列出来,逐字段做映射。不是所有类型都能无脑对应,尤其要注意这几种:

MySQLPostgreSQL注意事项
TINYINT(1)BOOLEAN驱动返回值不同,MySQL 返回 1/0,PG 返回 true/false
BIGINT AUTO_INCREMENTBIGINT GENERATED ALWAYS AS IDENTITY对已有数据迁移,要同时处理 sequence 起点
DATETIMETIMESTAMPTZ时区语义不同,建议统一存 UTC
TEXTTEXT基本兼容,但要注意排序和比较规则
JSONJSONBPG 的 JSONB 是二进制格式,支持索引,强烈推荐
VARCHAR(255) utf8mb4VARCHAR(255)字符集概念不同,排序规则要重新指定
DECIMAL(10,2)NUMERIC(10,2)基本兼容

我的消息表主键原来是BIGINT AUTO_INCREMENT,切到 PG 时必须考虑 sequence 与已有数据的衔接。最简单的办法:建完表后执行SELECT setval('messages_id_seq', (SELECT MAX(id) FROM messages)),保证新插入的消息主键不会撞上历史数据。

其他表相对好处理,就是机械活。但有一个坑必须提:如果大家原来用DATETIME存"本地时间",那在 PG 里用TIMESTAMPTZ后,展示层逻辑和报表 SQL 的时区转换全部要检查一遍。我建议不管哪个数据库,应用层统一用 UTC 时间交互,只在展示层转换。

3.2 SQL 语法清单:最常见的十个不兼容点

接下来是我实际遇到的不兼容点清单,每一条都是踩过坑的:

  1. 分页写法:MySQLLIMIT offset, count,PG 是LIMIT count OFFSET offset。参数顺序反了会导致分页结果完全错乱。
  2. 布尔值处理:MySQL 可以直接WHERE online = 1,PG 要WHERE online = true,驱动层面还需要注意传参类型。
  3. 字符串拼接:MySQL 用CONCAT('a', 'b'),PG 用'a' || 'b'。PG 里||语义被滥用,但CONCAT也支持,看驱动版本。
  4. 反引号和双引号:MySQL 反引号包标识符,PG 用双引号。PG 的保留字更多,比如user、group,字段命名要提前回避。
  5. REPLACE INTO:MySQL 的REPLACE INTO是先删后插,PG 没有等价语法,要改用ON CONFLICT。
  6. INSERT IGNORE:MySQL 忽略冲突插入,PG 对应ON CONFLICT DO NOTHING。
  7. GROUP BY宽松模式:MySQL 默认允许 select 非聚合列(不严谨但方便),PG 不允许,必须把所有非聚合列加入 GROUP BY,或者改用DISTINCT ON/窗口函数。
  8. ORDER BY排序规则:MySQL 的utf8mb4_unicode_ci不区分大小写,PG 默认按C/UTF-8locale 区分大小写。做唯一约束和模糊搜索时,结果会有差异。
  9. DATE_FORMAT:MySQL 格式化日期用DATE_FORMAT,PG 用TO_CHAR,语法完全不一样。
  10. 修改表结构:MySQLALTER TABLE t MODIFY column ...,PG 用ALTER TABLE t ALTER COLUMN ... SET ...。自动化脚本里非常常见。

如果是通过 ORM 的 query builder 写的大部分查询,前五种问题 ORM 会帮你处理。但 native SQL、原生的查询缓存,一个都逃不掉。我的做法是写了一段正则扫描代码,把项目里所有 native query 文件扫一遍,标记可疑语法,逐个手工审查。

3.3 UPSERT 与并发分配:客服抢单场景的硬骨头

客服系统的核心高频操作是"坐席点击接入会话"。这一步逻辑其实很微妙:需要更新会话状态为assigned,同时把agent_id写进去,还要防止两个坐席同时抢到同一个会话。

MySQL 下我原来的实现是用UPDATE ... WHERE status = 'waiting'配合affectedRows判断是否抢到。这个在 InnoDB 的默认 RR 隔离级别下基本没问题,但有个隐藏风险:两个事务同时读到waiting状态,都尝试更新,其中一个会锁等待;如果动作顺序不统一,极端情况下会有死锁。

切到 PostgreSQL 后,我干脆把这块逻辑重写成SELECT ... FOR UPDATE SKIP LOCKED,直接跳过已经被其他事务锁定的会话,搭配 UPSERT 记录分配日志:

-- PostgreSQL 坐席抢单 WITH candidate AS ( SELECT id FROM conversations WHERE status = 'waiting' AND support_team_id = $1 ORDER BY priority DESC, created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1 ) UPDATE conversations c SET agent_id = $2, status = 'assigned', updated_at = NOW() FROM candidate WHERE c.id = candidate.id RETURNING c.id;

在 MySQL 里FOR UPDATE SKIP LOCKED到 8.0 才支持,早期版本根本没有。这套逻辑写完以后,我反而觉得 PG 的并发处理能力更清晰:锁等待的语义、跳过锁、返回数据这些功能控制粒度更细。这也算是一个意外的收获。

3.4 全文搜索:给历史消息检索换引擎

历史消息搜索是客服系统的高频功能,坐席经常要找"客户之前有没有提过发票、退款"之类的内容。原来 MySQL 只做了LIKE '%关键词%',数据量过了千万级之后基本是灾难。

PostgreSQL 的全文搜索方案是内建的tsvector + GIN 索引。我把消息表的content字段处理成 tsvector,配合触发器或应用层写入全文向量列。中文场景下 PG 内置分词对中文支持较弱,我实测之后用了pg_trgm扩展,对中文查询提升明显:

CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX messages_content_trgm_idx ON messages USING gin (content gin_trgm_ops) WHERE status <> 'deleted';

这个部分索引(partial index)也是 PG 的独家优势,MySQL 没有。只给未删除的消息建索引,索引体积小了很多,查询时还能自动匹配。这类"功能密度"的差异,在后面对比里还会反复出现。

MySQL 想要做全文搜索,要么用 ngram 全文索引(中文分词效果一般),要么上 Elasticsearch。对独立开发的小系统来说,PG 内置方案成本极低,这是我能坚持做完这个改造的最大动力。

4. PostgreSQL 与 MySQL 的正面交锋:按客服场景逐项对比

4.1 基准测试设计:模拟真实会话轨迹

为了不拍脑袋下结论,我用同一台测试机(4C8G 云主机,数据盘普通 SSD),相同的数据量做了对比测试。数据构造规则:模拟 200 个访客、50 个坐席、约 200 万条消息、10 万条会话,每个会话平均 20 条消息。

测试脚本模拟真实操作序列:访客发消息、坐席拉取会话列表、坐席搜索历史消息、生成日报图表。两个数据库都开启默认配置,未做极致的参数调优(毕竟这是大多数独立开发者的真实状态)。

4.2 写入与并发:消息堆积场景

我先测了纯写入。消息表是典型的"高并发插入、低频更新",两个数据库在 20 并发下的写入吞吐差距不大,MySQL 略好于 PG,但没有出现数量级差异。这个结果在我的意料之中:作为纯 MySQL 优化版的 InnoDB,在简单插入场景下确实优势还在。

转到"抢单"场景(高并发更新同一张表的不同行)时,情况反过来。PostgreSQL 的 MVCC 机制让FOR UPDATE SKIP LOCKED的并发吞吐表现明显更稳定,我压到 100 并发时,PG 的平均等待时间大概是 MySQL 的 60%。这里要声明一下:MySQL 也不是不行,只是需要额外优化事务隔离级别和索引设计。

场景MySQL 8.0PostgreSQL 16
20 并发纯插入(消息写入)稳定,吞吐略高略低,但差距在 5% 以内
100 并发抢单(同一状态更新)锁等待较多,偶尔死锁稳定,配合 SKIP LOCKED 表现好
消息列表翻页(深分页)深度分页变慢配合索引,深度分页更稳定

4.3 复杂查询与历史检索

这里就是 PostgreSQL 的主场了。我在同样的 200 万条消息表里跑一个实际查询:按访客维度和时间范围统计消息量:

-- 统计每个访客近 30 天的消息量 SELECT visitor_id, COUNT(*) AS msg_count, MAX(created_at) AS last_msg_time FROM messages WHERE created_at >= NOW() - INTERVAL '30 days' GROUP BY visitor_id ORDER BY msg_count DESC LIMIT 20;

PG 执行这个查询的耗时大约是 MySQL 的一半。更夸张的是OLAP风格的窗口函数场景:计算"每个坐席每小时的接待消息量",PG 的FILTER子句和窗口函数组合写起来干净,执行计划也更优。

全文搜索更不用比了。MySQL 的ngram全文索引在中文场景下,我的体验是"能用但不出彩",翻页速度、相关度排序都不如 PG 的 tsvector + pg_trgm。如果历史消息到了千万级,PG 的 GIN 索引优势会被进一步放大。

4.4 功能密度与开发效率

从开发者的视角,我把 36 个工时内的差异项列了一下表:

功能MySQLPostgreSQL我的评价
JSON 字段检索JSON 类型,索引能力弱JSONB 支持 GIN 索引PG 完胜
全文搜索ngram 索引,中文一般tsvector + pg_trgmPG 完胜
窗口函数8.0 之后可用非常成熟PG 更顺手
部分索引不支持原生支持节省不少空间
UPSERTON DUPLICATE KEY UPDATEON CONFLICT DO UPDATE语义 PG 更清晰
复制与高可用主从成熟,工具多逻辑复制、物理复制都强各有优劣
生态管理工具极多不少但略少MySQL 小胜
云托管成本普遍便宜略贵MySQL 有优势

"功能密度"这个词是我自己的体会:同样是写一条查询,PG 提供了更多原生的解决手段,业务代码可以少绕一些弯。但功能多也带来认知负担,这是后话。

4.5 运维成本:独立开发者的个人视角

独立开发者没有专职 DBA,数据库出问题只能靠自己。MySQL 的好处是资料极多,任何报错都能搜到现成答案;PG 的资料也在增长,但问题定位确实需要更扎实的原理理解。

部署层面,现在两者都支持 Docker 一键起,没有本质差别。备份恢复上,MySQL 的mysqldump和 PG 的pg_dump都够用,但 PG 的pg_restore更灵活,支持并行恢复、按表恢复,这个在动手恢复大库时能明显感受到差距。

如果业务体量不大,单机跑得很轻松,两者运维成本几乎一样。区别出现在做大之后:PG 的高级功能让你很多场景不需要额外引入中间件(搜索、数仓分析、地理信息),从系统复杂度上反而省了运维。但如果团队只会 MySQL,强行上 PG 就是给自己找麻烦。

5. 上线前必须排掉的坑

5.1 锁等待与死锁:现象和排查方法

我在压测抢单场景时,PG 报过一次55P03: lock_not_available,报错信息比 MySQL 直观得多。初步判断是事务里有慢查询,持锁时间过长。

排查路径是这样的:先SELECT * FROM pg_stat_activity WHERE state = 'active'看有没有长时间运行的查询,再用pg_locks视图看锁的持有关系。最终定位到问题出在一个报表查询上——它需要扫全表统计数据,每次跑 2 秒,期间恰好和抢单更新冲突。

解决方案是把报表查询改成读从库副本(我刚好配了物理复制),或者把报表改成异步生成。MySQL 的死锁排查相对绕,需要SHOW ENGINE INNODB STATUS看 LATEST DETECTED DEADLOCK,信息密度低。这一点上我得出一个结论:PG 的锁管理机制更像手术刀,但用好了之后能省很多排查时间。

5.2 大小写与排序规则的暗雷

这是迁移中非常隐蔽的一个问题。我的用户表有一个email字段,MySQL 下默认排序规则utf8mb4_unicode_ci是不区分大小写的,Test@example.com和test@example.com会被当作重复。但是 PG 默认按 locale 区分,导致唯一约束失效,产生了重复注册。

解决有两个路径:一是全表检索时统一转小写再比对,二是给 PG 的 email 字段建表达式索引:

CREATE UNIQUE INDEX idx_users_email_lower ON users (lower(email));

我最后用了表达式索引,因为不需要改应用代码,索引命中也非常准确。排序规则差异还会影响列表分页:如果ORDER BY用的字段排序逻辑在不同数据库下不一致,分页结果可能重复或遗漏。我的建议是,尽量用主键或者稳定顺序字段做排序兜底,不要依赖数据库的默认排序规则。

5.3 连接池参数不能照搬

PG 的连接成本比 MySQL 高,每个后端是一个独立进程,内存开销明显。原来 MySQL 连接池我配了max: 200,照搬到 PG 几乎每 1 个连接多耗好几 MB 内存,8G 内存的机器很快报警。

我后来参考 pgtune 的建议,把应用层连接池压到max: 20,再给 PG 的max_connections设成 150(留一点给管理操作),效果立刻稳定。同时 PG 的shared_buffers建议配置到物理内存的 25% 左右,work_mem不能给太大,否则多个排序操作叠加起来会让内存爆掉。

这里有个技巧:用pg_stat_activity可以观察真实的当前连接数和状态,比 MySQL 的SHOW PROCESSLIST更直观。压测时要同时看应用连接池和 PG 侧的状态,两边对不上号就知道肯定有一个配置有问题。

5.4 备份恢复演练:pg_dump 的一天

上线前务必做一次完整的备份恢复演练,别等出事故才学。PG 的pg_dump默认输出 SQL 文件,但我推荐加-Fc用自定义格式,然后用pg_restore恢复:

pg_dump -h localhost -U app_user -Fc app_db > app_db.dump # 恢复 createdb -h localhost -U app_user new_db pg_restore -h localhost -U app_user -d new_db -j 4 app_db.dump

-j 4并行恢复,大库速度提升明显;--list可以先查看备份内容,按需恢复特定表。MySQL 的mysqldump也能做类似事情,但大库恢复往往更慢,且参数灵活度差一些。

我还跑了一次"垃圾模拟":故意误删一张表,然后用备份恢复。整个过程没遇到大问题,但要提醒一句:PG 逻辑备份默认不包含角色、权限,如果应用有独立账号要记得把角色也备份进来。

6. 我的最终结论与后续打算

6.1 什么场景我推荐 PostgreSQL

如果业务形态和在线客服系统类似,具备这三个特征,我推荐认真考虑 PG:数据模型经常变化(需要灵活扩展字段)、历史数据需要深度检索(全文搜索、多维统计)、单机越来越吃力而不是靠简单分片撑住。PG 在功能密度上的优势,会让后续开发省下很多"中间件接入成本"。

6.2 什么场景还是 MySQL 省心

如果团队对 MySQL 运维极其熟练、业务极其标准、数据量可控、没有全文搜索和复杂报表需求,那么强行切换到 PG 是反效果的。MySQL 的生态与云托管成本优势依旧存在,小项目用它省事省钱,完全没有问题。另外,如果你的整套监控体系、备份脚本、容器编排都是围绕 MySQL 建的,切换成本会覆盖掉 PG 的新功能收益。

6.3 如果重新开局,我的选择

回到最初的问题:假如我现在从零开始建设一个在线客服系统,会先选哪个数据库?以我目前的经验和判断,会直接选 PostgreSQL 作为默认主库。理由很简单:客服系统最棘手的是"历史消息检索"和"复杂实时统计",这两块 PG 的原生能力可以让我晚一步再引入搜索中间件和数仓方案,对独立开发者来说是巨大的复杂度节省。MySQL 保留在兼容层,方便有存量运维习惯的用户部署。

这次改造虽然花的时间比预期多,但换来的是对两个数据库底层行为的真理解。以后如果再有人问"PG 和 MySQL 怎么选",我不太会说哪个更好,而是会说:看你的业务像不像在线客服系统。如果你的核心痛点是数据在增长、查询在变复杂、字段在不停地动——那 PostgreSQL 值得认真尝试。如果你的重点是极致的低成本托管和最简单直接的部署,MySQL 依然是那个稳如磐石的选择。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询