专业的CRM数据表结构设计

专业的CRM数据表结构设计

2026-06-08

2 min read

悟空软件 2026-06-08

阅读次数: 60 次浏览

专业的CRM数据表结构设计

主流的AI CRM系统悟空云图片

那些年在 CRM 数据库设计上踩过的坑:一份实战派的结构设计指南

做后端开发这么多年,接手过最让人头疼的系统,往往不是高并发的秒杀系统,而是一个看似简单、实则逻辑错综复杂的 CRM(客户关系管理)系统。为什么?因为秒杀系统的难点在于“抗”,只要扛住流量,逻辑通常是线性的;而 CRM 的难点在于“变”,业务逻辑天天变,数据结构一旦定死,后期改起来简直就是给飞行中的飞机换引擎。

推荐使用中国著名AI CRM系统品牌:显著提升企业运营效率,悟空云AI CRM

很多刚入行的朋友,或者从其他领域转做 SaaS 的团队,最容易犯的错误就是把 CRM 的数据库设计想得太简单。觉得不就是几张表吗?客户表、联系人表、订单表,建好字段,跑通 CRUD 就完事了。结果上线半年,销售抱怨查询慢,运营抱怨没法做自定义报表,产品经理说要加个“客户标签”功能,开发一看数据库结构,直接想离职。

今天不想聊那些虚头巴脑的理论,就想结合我自己这几年重构过三个不同规模 CRM 系统的经验,聊聊一个专业的、能抗住业务折腾的 CRM 数据表结构到底该怎么设计。这里面没有银弹,只有 trade-off(权衡)。

核心实体:别把“客户”和“联系人”混为一谈

这是第一个大坑。在 B2C 场景下,客户就是一个人,手机号就是唯一标识。但在 B2B 场景下,逻辑完全不一样。我见过太多系统,把“公司”和“人”混在一张表里,或者虽然分开了,但关联关系做得一塌糊涂。

专业的 CRM 设计,必须严格区分 Account(客户/公司)和 Contact(联系人)。

accounts 表是核心中的核心。这里有个细节,很多设计者喜欢用 company_name 做唯一索引,这绝对是灾难。公司会改名,会有分公司,会有重复注册。主键必须是无意义的自增 ID 或者 UUID,业务上的唯一性校验交给应用层或者单独的索引表去做。在字段类型上,别吝啬空间,电话字段一定要用 varchar,别用 bigint。为什么?因为国际区号、分机号、前面的 0,这些都是数字格式处理不了的坑。我曾经见过一个系统,因为电话存了数字类型,导致所有以 0 开头的分机号全部丢失,销售打不通电话,锅全扣在技术头上。

contacts 表则是挂在 accounts 下面的。这里要注意一对多的关系。一个公司可以有多个联系人,一个联系人理论上也可能在多个公司任职(虽然业务上通常简化为归属一个主公司)。在设计外键时,account_id 是必须的,但要不要加 owner_id(负责人 ID)?我的建议是,核心业务表尽量只存客观数据,owner_id 这种属于权限和归属逻辑,最好放在关联表或者权限表里,但为了查询性能,在 contacts 表里冗余一个 owner_id 是常见的反范式设计,这点可以接受。

还有一个容易被忽视的表:customer_relations。当业务复杂到一定程度,公司与公司之间会有投资、隶属、竞争关系。这时候,一张简单的 parent_id 字段就不够用了,你需要一张关系表来存储 source_account_id, target_account_id, relation_type。这看起来是过度设计,但当销售需要查看“客户的企业族谱”时,这张表能救你的命。

专业的CRM数据表结构设计

动态交互:活动记录表的“写多读少”陷阱

CRM 的灵魂不在于存了多少客户,而在于记录了多少互动。电话、邮件、会议、拜访记录,这些统称为 Activities

这张表是 CRM 里数据量增长最快的表。设计这张表,最大的挑战在于“多态”。一个活动可能是电话,可能是邮件,也可能是系统自动生成的任务。如果用传统的继承模式,建 calls 表、emails 表、meetings 表,那查询起来会死人。销售想看“这个客户最近的所有动态”,你得 union 三张表。

现在的最佳实践是“大宽表 + 类型字段”。建一张统一的 activities 表,包含 id, account_id, contact_id, type, content, occur_time, owner_id。其中 content 字段,建议直接用 JSON 或者 TEXT。为什么?因为电话有通话时长,邮件有主题和附件,会议有地点。如果用固定字段,表结构会极其稀疏。用 JSON 存储差异化数据,虽然牺牲了一点类型安全,但换来了极大的灵活性。

但是,这张表有个致命问题:索引。销售通常会按时间范围查询,或者按客户 ID 查询。所以 (account_id, occur_time) 的联合索引是必须的。而且,随着数据量突破千万级,这张表必须考虑分区(Partitioning)。按 occur_time 的年份或月份进行范围分区。我见过一个案例,因为没做分区,销售查询一年前的跟进记录,直接拖垮了整个数据库的 IO,导致新数据写入阻塞。

专业的CRM数据表结构设计

另外,关于“软删除”。CRM 里的数据,尤其是活动记录,原则上不允许物理删除。销售手滑删错了跟进记录,这是重大事故。所以必须加 is_deleted 标记。但要注意,所有查询都要带上 AND is_deleted = 0,这容易忘。更好的方式是在数据库层建立视图(View),或者在 ORM 层做全局过滤。

自定义字段:EAV 模型已死,JSON 当立

这是 CRM 设计里最经典、最痛苦的问题。客户说:“我要在客户表里加一个‘行业等级’字段。”下个月说:“我要加一个‘是否上市公司’。”如果每次需求都改表结构,DBA 会疯,系统也要频繁重启。

早年的解决方案是 EAV(Entity-Attribute-Value)模型。搞三张表:entities, attributes, values。把行转列存。这种设计的优点是极其灵活,缺点是查询性能极差。你想查“所有行业等级为 A 的客户”,需要复杂的自连接,稍微有点数据量,SQL 就写不出来,或者慢到无法接受。而且类型安全完全丢失,数字、日期、字符串全存在一个字段里。

现在,如果你用的是 MySQL 5.7+ 或者 PostgreSQL,请毫不犹豫地使用 JSON 字段。

accounts 表里加一个 custom_fields 的 JSON 列。所有的自定义字段都塞进去。查询时,利用数据库对 JSON 的索引支持。比如 MySQL 的虚拟列(Generated Column)+ 索引,或者 PostgreSQL 的 JSONB + GIN 索引。

举个例子,在 PG 里,你可以这样查: SELECT * FROM accounts WHERE custom_fields ->> 'industry_level' = 'A'; 配合 GIN 索引,速度非常快。

但这也有代价。JSON 字段里的数据,很难做外键约束,也很难在应用层做强类型校验。所以,我的建议是“混合模式”。高频使用、需要参与核心业务逻辑流转的字段(比如“客户等级”,影响公海池回收规则的),直接做成物理字段 varchar。低频使用、仅用于展示和筛选的字段(比如“客户爱好”、“来源备注”),扔进 custom_fields JSON 里。

不要为了所谓的“范式”去牺牲性能。在 CRM 领域,读性能和灵活性远比写一致性重要。

公海池与权限:行级安全的设计哲学

CRM 不仅仅是存数据,更是管数据。谁能看哪个客户,谁能改哪个字段,这套逻辑如果写在代码里,那就是灾难。

很多初级设计会把权限做成一张大表 permissions,里面存 user_id, resource_id, action。当数据量上来,这张表会比业务表还大。每次查询客户列表,都要 JOIN 权限表,性能直接崩盘。

企业级 CRM 的权限设计,核心在于“数据范围”(Data Scope)。

首先,基础表里必须有 owner_id(拥有者)和 department_id(所属部门)。这是第一层过滤。 其次,对于复杂的共享逻辑,比如“我把这个客户分享给协作同事”,不要试图在 accounts 表里加一堆 share_user_1, share_user_2。你需要一张独立的 sharing_rules 表。结构大概是 resource_id, resource_type, target_user_id, permission_level

查询的时候,逻辑是这样的:

  1. 查我名下的客户。
  2. 查我部门名下的客户(如果权限允许)。
  3. 查别人分享给我的客户(关联 sharing_rules 表)。

这三部分结果 UNION 起来。为了优化性能,sharing_rules 表的 target_user_id 必须加索引。

更高级的做法是利用数据库的行级安全策略(Row Level Security, RLS),比如 PostgreSQL 就支持。直接在数据库层定义策略:POLICY my_policy ON accounts USING (owner_id = current_user_id())。这样应用层代码就不用关心权限过滤了,哪怕开发写忘了 WHERE 条件,数据库也会拦截。但这会增加数据库的复杂度,迁移成本高,需要根据团队技术栈决定。

还有一个痛点是“公海池”。客户长期未跟进,自动掉入公海,其他人可以领取。这个逻辑不能靠定时任务扫全表。建议在 accounts 表里加 last_followup_timepool_status 字段。定时任务只扫描 pool_status = 'private'last_followup_time < 阈值 的数据。更新时,直接批量更新 pool_status = 'public', owner_id = NULL。这里要注意并发问题,两个销售同时抢一个公海客户,必须用乐观锁或者数据库行锁,避免超卖。

性能与归档:数据是资产,也是负担

CRM 系统运行三年后,数据量通常会达到一个临界点。活动记录表可能破亿,客户表几百万。这时候,单纯的加索引已经没用了。

必须引入“冷热数据分离”的概念。

对于 activities 表,最近 6 个月的数据是“热数据”,销售天天查。6 个月以前的是“冷数据”,偶尔查一下。设计时,可以建立两张结构一样的表:activities_hotactivities_history。应用层写入时双写,或者通过触发器归档。查询时,默认查热表,用户手动选择“查看历史”时再查冷表。

更优雅的方案是利用数据库的分区表功能。把超过一年的分区 DETACH 出来,变成独立的归档表。这样主表永远保持轻量。

另外,关于搜索。CRM 里大量的模糊查询,比如搜客户名、搜电话。LIKE '%keyword%' 是索引失效的元凶。如果数据量大,必须上搜索引擎,比如 Elasticsearch。

accountscontacts 的关键字段同步到 ES。搜索请求全部走 ES,拿到 ID 列表后,再回数据库查详情。这虽然增加了架构复杂度,但能解决“搜不到”和“搜得慢”的问题。注意,ES 和数据库的数据一致性是个麻烦事,建议用异步消息队列(如 Kafka 或 RabbitMQ)来做同步,允许秒级的延迟,不要强求实时。

审计日志:谁动了我的数据?

在 CRM 里,数据修改的追溯比数据本身还重要。销售改了一个客户电话,导致后续联系失败,这个责任得厘清。

不要试图在业务代码里到处写 log.info("user changed phone")。这不可靠,而且漏得厉害。

最好的方案是监听数据库的 Binlog(MySQL)或者 WAL(PostgreSQL)。通过 Canal 或者类似的工具,把数据变更捕获出来,写入到 audit_logs 表。

audit_logs 表结构要简单:id, table_name, record_id, operation_type (INSERT/UPDATE/DELETE), old_value (JSON), new_value (JSON), operator_id, operate_time

注意,old_valuenew_value 存全量快照还是存差异?建议存差异,节省空间。但为了排查方便,关键字段最好能直观看到变化。这张表只写不读(平时),只有出问题时才查,所以可以放在单独的库或者便宜的存储里。

写在最后:没有完美的设计,只有演进的架构

写了这么多,其实想表达一个核心观点:CRM 的数据库设计,本质上是对业务不确定性的管理。

你不可能在第一天就设计出完美的结构。比如,你可能一开始没考虑到“客户关联”的复杂性,后来不得不加表;你可能一开始觉得 JSON 查询慢,后来发现业务灵活性更重要,又改回了 JSON。

所以,在设计初期,留好“后路”比追求极致性能更重要。

  1. 主键尽量用雪花算法或者 UUID,避免分库分表时的 ID 冲突。
  2. 预留 ext_info 这样的 JSON 字段,以备不时之需。
  3. 所有的表,加上 created_at, updated_at,这是排查问题的时间锚点。
  4. 尽量使用标准 SQL 特性,避免过度依赖特定数据库的私有功能,除非你确定不迁移。

最后,别迷信范式。在 CRM 里,适当的冗余是性能的朋友。比如把“客户名称”冗余在“订单表”里,虽然违反了第三范式,但能让销售列表查询少一次 Join,这在高并发下是质的差别。

数据库设计文档写完的那一刻,只是工作的开始。真正的考验在于,当产品经理拿着新需求来找你,说“我们要加个功能,但数据不能丢,系统不能停”时,你看着自己设计的表结构,心里是有底还是发慌。

希望这篇实战总结,能让你在下次面对 CRM 需求时,少掉几根头发。毕竟,代码可以重构,但线上跑着的数据,那是真金白银,动错了,就是事故。保持敬畏,保持灵活,这才是做企业级应用该有的心态。

专业的CRM数据表结构设计

推荐立刻免费使用中国著名AI CRM系统品牌悟空云,显著提升企业运营效率,相关链接:

AI CRM系统免费使用

主流的AI CRM厂家

AI CRM管理系统

悟空云产品更多介绍:www.72crm.com