自研CRM系统实战:从数据建模到权限设计与性能优化

📅 发布时间:2026/10/19 17:47:24
自研CRM系统实战:从数据建模到权限设计与性能优化
1. 立项背景销售数据失控后我决定放弃现成CRM1.1 周末例会上三个版本的数据谁都不敢信事情要从一次周末销售复盘会说起。当时我们团队四十多人客户资料分散在销售个人Excel、企业微信聊天记录、以及一部分写在纸质笔记本上的信息里。每周五要出业绩预测负责人收集各区域表格手动合并后发给管理层但每次会上必吵一轮——大客户部的汇总数比运营后台的订单数多出一截A区报的跟进客户和B区报的撞了车更别说商机金额口径有人填含税、有人填不含税还有人把“在谈但基本没戏”的项目也写了上去。这些数据就算勉强对齐周一又会变。客户联系人的手机号改了跟进记录没人同步销售离职交接表里只有客户名和一句“正在跟进中”。我当时的判断是这不是换一个人来管就能解决的问题也不是简单买一套客户管理软件就能解决的。团队缺的不只是存放客户信息的数据库而是一套能逼着销售把过程留痕、让管理者能实时看懂全局的机制。这个需求最直接的落地方式就是一套贴合我们自己业务流程的CRM系统。我们内部把项目定名为DeskcommCRM。“Desk”对应坐席工作台销售每天打开它就是工作入口“Comm”代表沟通、跟进与协作“CRM”不用解释是客户关系管理的底座。1.2 选型绕了一圈最后还是决定自己做在决定自研之前我们其实正经评估过几套现成的CRM产品有国际大厂的全功能套件也有国内做得很成熟的SaaS平台。试用下来有几个共性问题没办法绕开。第一权限模型不匹配。我们的业务是“大区-区域-个人”三级管理销售总监既能看全公司又要求不同区域之间数据天然隔离代理商、合作伙伴还要单独分类。现成产品要么权限太粗要么精细化配置需要额外加钱开通。第二字段和业务流程绑定太深。销售管理最怕软件里写的“标准流程”和团队实际跑的不一致。比如我们的商机阶段有六个状态其中“方案报价”之后还要区分“报备未评审”和“评审已通过”现成产品很难用简单配置模拟。强行适配的结果往往是销售因为录入成本高而弃用。第三开放能力不足。我们要的不只是录入和查看还需要把客户数据推送到企业微信、在合同到期前自动生成提醒任务、把每日跟进摘要发送到管理群。现成产品的API要么限制多要么按调用次数计费算下来一年成本不低。综合考虑人力成本后我决定用一个四人小组、约三个月时间把一套轻量但完整可用的CRM从零搭起来。技术选型没有追求新颖存储用MySQL 8.0后端用Spring Boot前端用Vue3缓存走Redis。这套组合的好处是团队熟悉、社区资料多、出问题好招人解决。2. 客户主数据建模客户、联系人、商机怎么拆才不会后悔2.1 四张核心表和一个关键的“不后悔”原则DeskcommCRM的数据模型我从一开始就定了调子宁可多拆几张表也不把字段堆在一张大表里。最终核心围绕四张表展开客户表、联系人表、商机表、跟进记录表。一条核心原则是客户是客户联系人是联系人商机是商机记录是记录绝不混在一起。为什么客户和联系人必须拆开一个集团公司客户下可能有多个部门分别采购财务部关注价格、技术部关注性能、行政部关注资质每个部门对接人不同。如果把联系人作为客户表的重复字段后续做合并、权限控制、跟进提醒都会非常痛苦。同样商机必须独立于客户存在因为一个客户可能同时有两条产品线在谈一个赢单一个输单只有拆开才能单独统计。客户主表的设计如下CREATE TABLE customer ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_no VARCHAR(32) NOT NULL COMMENT 客户编号, name VARCHAR(128) NOT NULL COMMENT 客户名称, level TINYINT NOT NULL DEFAULT 3 COMMENT 客户等级 1-5, owner_user_id BIGINT NOT NULL COMMENT 当前负责人, region_code VARCHAR(16) NOT NULL COMMENT 区域编码, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 1-正常 0-停用, source VARCHAR(32) DEFAULT NULL COMMENT 来源渠道, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_customer_no (customer_no), KEY idx_owner_region (owner_user_id, region_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户主表;这版设计里有两个容易被忽略但很重要的细节。第一个是customer_no客户编号它不参与业务查询但用于对外展示和纸质合同对照避免直接把自增ID暴露给销售。第二个是owner_user_id和region_code两个字段同时加索引因为权限查询最频繁的场景就是“某个销售看自己名下的客户”以及“某个区域经理看整个区域”两个条件同时加进去才能走联合索引。2.2 跟进记录采用 append-only历史比修改更重要跟进的实现方式我踩过不少坑最终坚定地使用append-only 追加模式。销售每一次联系客户系统只新增一条跟进记录不修改、不删除界面上的“编辑”按钮直接砍掉。当时有销售负责人提出异议如果记录写错了怎么办我的回答是写错了可以新追加一条更正说明但不能覆盖原记录。原因有三个。第一客户跟进过程中的每一个判断都影响后续策略比如客户说过“预算在Q4才能到位”如果这条记录能被修改两周后没人记得当时真实情况是什么。第二销售可能因为业绩压力美化记录允许修改等于给事后造假留了后门。第三从审计角度讲如果客户投诉某个承诺我们需要能回溯到当时销售到底说了什么。跟进记录表设计得比较简单但查询索引要特别小心CREATE TABLE follow_up ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT NOT NULL COMMENT 客户ID, contact_id BIGINT DEFAULT NULL COMMENT 联系人ID, opp_id BIGINT DEFAULT NULL COMMENT 商机ID, content TEXT NOT NULL COMMENT 跟进内容, next_follow_at DATETIME DEFAULT NULL COMMENT 下次跟进时间, creator_id BIGINT NOT NULL COMMENT 创建人, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_customer_time (customer_id, created_at), KEY idx_creator_time (creator_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT跟进记录表;表结构看着简单但idx_customer_time这个联合索引是查询性能的命脉。页面最常见操作是“打开一个客户按时间倒序拉出他最近20条跟进记录”如果不加联合索引而单独在 customer_id 上面建索引MySQL 虽然能定位到该客户的所有记录但仍要在回表后做一次文件排序。数据量到了几十万条以后这个排序的开销会让接口响应从几十毫秒涨到一两秒。2.3 主键选择自增ID够用业务编号另起炉灶现在很多团队喜欢一上来就用 UUID 或者雪花 ID但我在 DeskcommCRM 里坚持用 MySQL bigint 自增主键。综合考虑有几点一是InnoDB的聚簇索引按照主键顺序组织数据自增ID写入一直是追加操作页分裂概率极低UUID字符串无序大规模插入时索引页频繁分裂会产生大量碎片写入性能明显下降。二是在这套系统里没有任何对外暴露ID的需求不存在“不能被猜出数量”的担忧客户编号字段已经承担了这种业务校验职能。三是后续做分库分表时bigint 比 varchar(32) 的UUID占用空间小索引也更快。日常开发中如果遇到“未来可能要同步数据、多端生成ID”的需求我的建议是业务唯一标识单独抽象一个编号字段但关系型数据库里的主键仍然用自增。主键是用来保证物理存储和关联查询效率的业务编号是用来处理业务语义的两者不能互相替代。3. 销售漏斗与自动化让商机阶段可量化而不是拍脑袋3.1 六个商机阶段每个阶段都有停留预警销售漏斗是整个系统的灵魂也是销售总监最关注的模块。我们把商机生命周期划分为六个阶段初步接触、需求确认、方案报价、评审通过、商务谈判、赢单/输单。每个阶段都有一个关键问题要在录入时回答这样管理者看到的不是干巴巴的阶段名称而是真实的推进状态。比如“初步接触”阶段必须填写客户预算规模“方案报价”必须关联报价单“评审通过”必须上传内部评审记录。这些约束刚开始会招致销售抱怨“太麻烦”但两个星期后就能明显感受到好处阶段之间的转化不再靠销售在周会上口头解释而是系统里已经沉淀了证据链。阶段停驻时间超过阈值时系统自动触发提醒。阈值设定参考了我们自己的业务周期初步接触超过3天没动静提醒销售补充说明方案报价超过7天没有新动作提醒销售同时抄送区域经理评审通过后超过15天没进入谈判阶段由系统标记为“高风险商机”。这个机制上线后商机的平均推进周期缩短了约18%主要原因是过去很多项目卡在某一步因为没人盯着一拖就是好几周。3.2 自动提醒任务Redis延迟队列比定时扫表靠谱自动提醒实现上我们放弃了最初设计的“每天凌晨扫全表”方案。原因很直接商机数量到了几万条之后每天扫一次全表虽然能撑住但对停驻时间超过48小时甚至更长的商机来说提醒太慢销售早就丧失了最佳跟进时机。最终方案是 Redis 延迟队列商机进入新阶段时立即计算预警时间点把任务 ID 写入 ZSET。专门有一个消费者进程每秒拉取过期任务到期后触发企业内部消息推送。这个方式的好处是单条商机的预警都能精确到分钟级别而且每次触发只处理真正到期的任务数据库不再被无谓地全表扫描。还要注意一个细节商机阶段发生变更时旧提醒任务必须删除或标记为失效否则会出现“商机已经赢单了系统还在给销售发预警”的尴尬情况。我们的做法是任务ID由“商机ID阶段序号”拼接生成阶段变化时用新ID替换旧ID天然覆盖。3.3 转化率口径先统一算法再谈数据好看销售漏斗里的转化率指标上线前一定要统一口径。我们内部定义了两套指标一套按商机数量计算一套按金额加权计算。按数量计算是转化率 赢单商机数 / 全部结束商机数。按金额加权则是转化率 赢单商机总金额 / (赢单商机总金额 输单商机总金额)。两套指标都有存在价值但要明确它们在管理上的用途。数量转化率反映销售整体推进能力金额加权转化率反映大客户策略是否合理。我见过不少团队只用一个转化率结果大客户销售和中小客户销售被放进同一个标准里比较大客户销售因为单子少、周期长转化率永远垫底团队士气很受影响。在DeskcommCRM里我们允许管理者按客户等级或区域维度筛选但任何比较都基于同一套口径计算不允许手工填数字。还容易踩的坑是“把尚在推进中的商机也算进分母”这样转化率会虚低。我们定义计算指标时只统计状态为“赢单”或“输单”的商机在途商机单独看不参与转化率计算。这样每个月的转化率数据才是可对比的不会因为月底有一批在途商机就导致指标突然波动。4. 权限设计与数据隔离从全员可见到区域数据边界4.1 角色权限矩阵六种角色三种操作级别DeskcommCRM上线之初数据权限几乎是全放的。销售可以看到全公司所有客户列表那时候团队小大家觉得没什么问题。但到了第四个月陆续出现抢单纠纷——A销售在系统里看到B销售的客户就私下联系事后辩称“我以为是公共客户”。这类问题一旦出现系统本身就会失去公信力。权限设计我们最终落到六种角色系统管理员、销售总监、区域经理、销售、商务专员、只读访客。操作权限从三个维度去定义查看、编辑、删除/转移。关键角色的权限矩阵如下角色查看数据范围编辑客户删除客户转移客户查看全部销售额系统管理员全部是是是是销售总监全部是否是是区域经理本区域是否本区域内本区域销售本人负责是否提交申请仅本人商务专员合同关联客户否否否仅合同金额只读访客授权范围内否否否否“删除客户”这个权限只有系统管理员有。一旦客户有了跟进记录和商机删除会造成历史数据断层所以日常操作里我们更推荐用“停用”而不是“删除”。销售要学会是在“离职交接”时把客户转移给新负责人而不是直接删掉。4.2 数据范围控制一条SQL条件走天下角色权限解决“能干什么”的问题数据范围解决“能看哪些数据”的问题。我们在权限框架里抽象了一个DataScope概念在每次查询客户、商机列表时动态拼入数据范围条件。核心逻辑简述如下系统管理员不做额外限制直接查全表。销售总监拥有所有区域的数据访问权但字段级权限有限制比如不能直接看到销售填写的“成本估算”字段。区域经理只能查看region_code属于本区域的数据。销售只能查看owner_user_id等于自己的数据以及被明确共享给他的客户。实际SQL实现时我们是在查询前先解析当前用户权限组装出可用的区域列表和用户ID列表然后拼在WHERE条件里// 伪代码数据权限过滤条件 if (user.hasScope(ALL)) { // 不加数据过滤 } else if (user.hasScope(REGION)) { where AND region_code IN (:regionCodes); } else if (user.hasScope(OWN)) { where AND (owner_user_id :userId OR id IN (SELECT customer_id FROM share_rule WHERE user_id :userId)); }这个方案的核心价值是规则集中在同一个模块里任何接口要拉客户列表都必须经过这套过滤逻辑不会出现某个接口“漏加权限导致全表泄露”的低级问题。4.3 权限缓存一致性问题离职员工还能看到客户权限这块我们上线后遇到过一个非常棘手的问题至今印象深刻。当时有销售离职管理员在后台把他的账号禁用同时将他名下的客户批量转移给另一位销售。表面看一切正常但第二天新接手的销售反馈客户列表里有些客户仍然挂在离职员工名下。排查下来发现前端页面加载时读取的是Redis里缓存的用户权限信息而离职操作只更新了MySQL中的账号状态没有主动清除Redis缓存。用户的会话还存着旧的“数据范围用户ID”所以查询时依然能按老的owner_user_id过滤并带上离职员工名下的数据。修复方案做了三层加固。第一账号禁用时主动删除该用户所有缓存Key。第二权限变更表增加版本号字段perm_version每次变更1前端请求时带上版本号不一致则重新拉取权限。第三所有权限缓存设置2小时过期兜底即使主动失效逻辑出现遗漏最多延迟2小时不会永久有效。这套组合拳打完之后再没出现过权限变更不生效的问题。我的经验是权限系统的设计重点不只是“如何授权”还要认真考虑“如何快速收回授权”。缓存是为了性能但权限的正确性优先级永远高于性能。5. 性能问题与三次真实故障复盘5.1 日报表查询超时一个缺失索引引发的连锁反应系统上线三个月后销售总监每天早上要看日报表前一天新增客户数、新增商机量、跟进次数、各区域排名。最初报表接口只要200毫秒后来有一天突然变成8秒页面直接超时。排查过程让我印象很深。首先看慢查询日志发现大量COUNT(*)和GROUP BY语句都卡在follow_up表上。EXPLAIN 一看idx_customer_time索引没有被使用MySQL选择了全表扫描。原因是日报表查询条件是按creator_id和created_at聚合统计的而这条SQL用的条件是created_at BETWEEN ? AND ?不包含联合索引的最左前缀customer_id索引自然失效。这个问题的教训是索引设计必须从“所有常见查询模式”出发不能只盯着某一个页面。日报表要按“时间范围员工”聚合就得单独有(created_at, creator_id)索引。修复之后报表响应降到400毫秒以内。后来我们在上线前加了一个强制步骤——新功能开发完必须用慢查询日志模拟一次最坏情况查询检查执行计划。5.2 并发修改客户资料导致锁等待FOR UPDATE惹的祸另一次事故发生在销售集中回访的下午。客户资料编辑页里我们为了让销售看到最新数据并防止覆盖在查询客户详情时开启了SELECT ... FOR UPDATE行锁直到事务提交才释放。单个客户没问题但有些大客户被多个销售同时打开有的销售打开页面后去接了个电话事务一直挂起行锁迟迟不释放后面所有想改这个客户的人全部进入锁等待。数据库监控很快打满最终触发了连接池耗尽。解决方案有两步。第一步取消查询时的FOR UPDATE只在提交更新时才使用乐观锁控制更新SQL带上updated_at 旧值条件如果影响行数为0则提示“该客户已被他人修改请刷新重试”。第二步显式控制事务范围——绝对不允许在用户思考时间中长时间持有事务所有事务必须在拿到用户明确的保存请求后才开启。这个改动上线后再没出现过因为编辑客户资料导致数据库锁死的情况。我在复盘时总结了一条原则Web应用里能不使用悲观锁就不用乐观锁加好冲突提示对用户体验和数据库都更友好。5.3 日历同步任务把数据库拖垮定时任务的隐性风险系统里有一个功能是把销售在外部日历中的拜访安排同步到CRM日历方便管理者了解销售外出情况。最初实现是每5分钟全量同步一次未来30天的日历事件。一开始没人在意直到销售人数增长到六十多人每次同步都会产生大量查询和插入数据库负载突然飙升业务高峰期接口全线卡顿。排查后发现同步任务完全没有增量概念每次全量拉取之后对比去重再插入。不仅浪费还会产生大量无效查询。修复方案是引入增量游标记录每个销售上次同步成功的位置只同步该时间点之后变更的事件同时把同步周期调整为15分钟一次批量写入时单次最多500条其余排队处理。这类问题的本质是定时任务对数据库的“抢夺式访问”没有做容量评估。我的习惯是每个定时任务上线前都要写清楚它的执行频率、单次耗时、写入行数、最多并发数至少要控制在数据库峰值QPS的10%以下否则业务高峰一来就会互相拖累。6. 上线后的反思哪些做对了哪些属于过度设计6.1 三个“做对了”的决策让我少返工第一个果断做对的决定是坚持追加式跟进记录它让销售的过程管理真正落地也让后面所有业绩纠纷、客户归属争议都有了解释依据。第二个是做数据权限的中控层而不是在每个接口里面自己判断角色哪怕是开发一个小功能也必须走统一的数据范围过滤这避免了权限漏洞反复出现。第三个是报表模块直接从第一天就独立设计没有塞进普通列表的查询逻辑里因为报表的查询模式和业务列表完全不同混在一起后期必然要拆。6.2 过度设计清单有些东西听起来很美实际没人用复盘时也要敢于承认走偏的地方。最典型的是我们花了大量时间做的“复杂审批流”商机报价、折扣、客户合并、字段修改都要走多级审批。设计时设想这是公司管理精细化的体现但实际运行两个月后发现真正需要审批的场景只有两类——大额折扣和客户归属变更。其他审批流完全成了流程负担销售不愿意等管理员审批点到手软最后不得不删掉大部分配置保留最小核心。另一个轻量但实用的功能是客户手动共享销售可以把自己名下的客户共享给同事一起协作但必须设置截止时间。这个功能起初不在计划里是销售团队提出来、我们花了两周快速上线的使用频率反而很高。这给我一个很深的触动做内部系统最忌讳替用户假设需求很多功能上线没人用往往是需求源头就偏了。现在回头复盘 DeskcommCRM 这个项目我最大的体会是一套让销售愿意每天打开的系统胜过一堆漂亮但没人填的数据表格。技术上的索引、缓存、锁其实都有现成方案可循难的是理解业务里那些没说出口的潜规则——哪些数据必须留痕、谁可以看谁的数据、阶段推进的真实阻力在哪里。把这些事情理清楚系统就已经成功了一大半。