1. 项目从哪来:不养眼不实用的CRM,不如自己造一个
先说说我为什么动手做 DeskcommCRM 这个东西。之前在好几家做企业服务的团队待过,销售团队每天的日常工作里,客户信息散落得令人抓狂——企业微信里聊过一轮的客户没有记录,Excel 表格里维护的商机跟进到一半就没人更新了,邮件往来和工单记录互相割裂,管理层想看一眼本周到底跟进了多少有效客户,得让销售助理加班到晚上十点去手工汇总。市面上现成的 CRM 系统我也带团队评估过一批,结论是两头难:轻量的工具胜在便宜,但字段固定、流程僵硬,销售用两周就放弃;重型的平台功能是齐全了,可光一个权限模型和审批流就能配置半个月,一线销售打开系统看到满屏用不上的按钮,立刻就没有了录入的欲望。
于是 2023 年初,我们决定在公司内部从零做一个真正贴合销售一线习惯的客户关系管理系统。项目代号就叫 DeskcommCRM,拆开来看是 Desk + Communication,核心设计理念是“把所有沟通沉淀到桌面上”:把客户档案、聊天记录、跟进任务、商机阶段、服务工单全部放进同一个界面,销售坐在工位上打开系统就能处理跟客户相关的所有事,不用再在多个应用之间来回切。开发周期一共压缩到 6 周,上线后销售团队连续用了两个月,客户资料完整率从原来的 37% 提升到了 91%,这条数据让我确定这个方向没做错。如果你也在考虑自建 CRM,或者单纯想了解一个企业内部系统从需求到落地的完整链路,这篇文章应该能给你一些可以直接抄作业的参考。
2. 核心需求拆解:销售不愿意录,往往是系统设计的问题
2.1 客户数据散落是表象,流程割裂才是病根
在立项之前,我花了整整一周去跟销售团队泡在一起观察他们的工作节奏,访谈了 8 位一线销售、2 位销售总监,还翻了他们近三个月的 Excel 台账和工作群聊天记录。最后总结出三个最痛的点。第一,客户信息录入太反人类,传统的“新建客户”表单有二十多个字段,很多还是必填,销售在拜访完客户的路上用手机根本填不完;第二,沟通记录无法追溯,客户在微信/企业微信上说了什么、承诺了什么、发了什么资料,全靠个人聊天记录,销售一旦休假或者离职,客户资产就跟着人走了;第三,跟进任务全靠自觉,没有系统性的提醒和优先级排序,经常是上海区域的客户被冷落了三周都没有人发现。
这些表象背后,真正的问题是流程割裂:线索获取、客户管理、商机推进、售后工单各自为政,没有一条贯穿客户全生命周期的数据主线。所以做 DeskcommCRM 时,我定下的第一条原则就是“以客户为主键,所有业务对象都挂接在客户之下”。后续所有表结构设计和功能开发,都是围绕这条主线展开的。
2.2 竞品调研给我的三条重要启示
我也带着团队花了大半个月把市面上的主流 CRM 产品都试用了一遍,包括国外老牌厂商的云产品、国内几家头部 SaaS 的客户管理系统。跨境网络相关的产品不做评价,我只说那些真正在国内能合规上线用的产品。调研完以后,我总结出三条经验,直接影响 DeskcommCRM 的设计取舍。
第一条:界面一定要拆得足够简单。竞品里销售接受度最高的,打开首页第一眼就是今天要跟进的客户清单和待办任务,而不是一堆仪表盘和报表图表。一线销售要的是“今天打给谁、说什么、记一下结果”,订单报表是管理者看的,不是销售日常用的。第二条:录入路径要极致缩短。某竞品支持在列表页直接快速新建跟进记录,整个过程不需要跳转页面,这个交互设计我非常认可,后来在我们的系统里把它进一步做到了一键唤起——在任何页面都能通过悬浮按钮快速记录跟客户讲的内容,默认带上当前客户 ID。第三条:强大的搜索是留存的关键。销售经常记不住全部客户名,印象里只有“上次那个做跨境电商的王总”,所以系统必须支持模糊搜手机号、搜关键词、搜最近沟通内容,搜索结果要快到毫秒级。这三条现在看很朴素,但在当时确实帮我们避免了好几个弯路。
2.3 明确不做的事:不让系统变成一个“监控工具”
在需求评审阶段,销售总监提过一个需求:希望系统能自动统计每个销售每天拨打电话的时长和次数,作为绩效考核的参考。我在评审会上直接把这条需求砍掉了。原因是,如果系统定位成监控工具,一线销售的第一反应一定是抗拒,他们会用各种方式规避录入,甚至伪造数据,最终所有数据都会失真。而 CRM 这类系统的核心价值,依赖的是数据的真实性和完整性。所以我给团队立了一条规矩:系统先做销售的服务者,再谈管理需求。个人绩效统计这类功能,等数据量积累充分之后,以“团队效能分析”的形式再做,而不是从第一天就摆在销售面前。
3. 技术选型与整体架构:不考虑规模的话都是耍流氓
3.1 技术栈选型对比:为什么是这套组合
我们很清楚自己是企业内部系统,不是要面向百万用户的互联网产品,所以在技术选型上追求的是成熟稳定、团队熟悉、社区资料多。后端选了 Spring Boot 3.2 + MyBatis-Plus,前端用 Vue 3 + Element Plus,数据库 MySQL 8.0,缓存 Redis 7,然后加一个轻量级的消息队列 RocketMQ(主要是为了处理异步通知和搜索索引同步)。整套技术栈都是 Java 生态里的“标准答案”,招聘容易,出了问题网上随便搜都能找到方案。
| 模块 | 选型 | 关键考虑 |
|---|---|---|
| 后端框架 | Spring Boot 3.2 | 生态成熟,微服务演进方便,团队上手快 |
| ORM 层 | MyBatis-Plus | 单表 CRUD 代码量最少,复杂 SQL 也方便手写 |
| 前端框架 | Vue 3 + Element Plus | 组件丰富,中后台界面开发效率高 |
| 数据库 | MySQL 8.0 | 稳定可靠,事务支持完善,运维成本低 |
| 缓存 | Redis 7 | 会话管理、列表缓存、热点客户数据 |
| 消息队列 | RocketMQ | 异步解耦,索引同步、通知推送不阻塞主流程 |
| 搜索 | Elasticsearch 8 | 支撑客户全局模糊搜索,走旁路同步 |
我要特别说明一下 Elasticsearch 这个选择。系统刚开发完第一版的时候,全局搜索直接用的 MySQL 的 LIKE 查询,数据量只有几万条的时候体验还行。上线一个月后客户数据涨到 20 万条,关联沟通记录、商机、工单之后,一次模糊搜索要扫描好几张表,响应时间掉到了 3 秒以上。销售根本等不了。后来加了 ES 做旁路索引,数据库变更通过 RocketMQ 异步同步到 ES,搜索响应时间压到了 200 毫秒以内。你如果做类似系统,也建议一开始就把搜索方案想清楚,不要走我这一步的弯路,从 MySQL 的 LIKE 迁到 ES 的索引重建工作还挺折腾的。
3.2 整体架构设计图:从浏览器到数据落地的完整链路
系统架构我按功能边界分了五层。最上层是浏览器端的 Vue 单页应用,所有交互都在一个工作台内完成;第二层是网关层,负责登录态校验和接口路由;第三层是核心业务服务群,我拆了三个模块:客户中心、商机中心、服务工单中心,三块之间通过领域事件进行通信;第四层是数据与中间件层,包括 MySQL 主从、Redis 缓存、ES 搜索集群和 RocketMQ 消息;最底层是对象存储服务,用来存客户头像、合同附件、产品资料这些非结构化数据。模块之间的调用全部走内部接口,不允许跨过服务层直接访问数据库。
第一次做这种中大型系统的人容易犯一个毛病:把所有功能塞进一个单体应用里,一开始开发确实快,但后续如果要拆微服务,扩展点很难找。我的建议是,早期单体没关系,但一定要按业务域划分清楚代码包结构,接口边界清晰,后面才有拆分的基础。DeskcommCRM 第一版就是单体应用,但代码里controller / service / mapper三层接口都画得很清楚,后面真要按域拆,只要把每个域下面的数据表和 Service 接口迁出去就行。
3.3 为什么不用现成开源 CRM:自研的四点真实对比
被问到最多的问题是:明明 GitHub 上有不少开源 CRM 可以直接拿来改,为什么要从零做一个?这个问题我当时也纠结了挺久,毕竟从零做的开发成本摆在那里。我做了个对比表格,结论至今还是站得住的。自研不是因为它比开源方案“高级”,而是因为业务融合度要求太高了——我们要跟企业内部的企业微信、工单系统、合同审批系统深度对接,开源的 CRM 大多是一个封闭产品,外部数据接进来要写一堆适配层,改造成本未必低于自研。
| 维度 | 使用开源 CRM 改造 | 自研 DeskcommCRM |
|---|---|---|
| 初始开发成本 | 较低,能快速跑起来 | 较高,6 周开发期 |
| 与内部系统对接 | 需要适配层,改造成本高 | 按内部协议直接对接 |
| 界面与交互控制力 | 受限于开源项目的 UI 框架 | 完全可控,按销售习惯定制 |
| 后续维护迭代 | 依赖上游社区更新 | 全团队可控,按业务节奏走 |
我这么说不是反对用开源,如果你的业务比较标准、流程改动不大,开源 CRM 是一个很省成本的起点。但如果像我们这样需要跟一堆内部 OA、IM、工单系统深度联动,那还是自研的路更顺,长期来看维护成本反而低。
4. 数据模型设计:客户、联系人、商机、工单的实体关系
4.1 客户主数据模型:一个客户一号通查
客户表是整套系统的地基,设计这个表我花了整整两天。核心思想是一个客户(customer)拥有多个联系人(contact)、多个商机(opportunity)、多个工单(ticket),所有业务动作都挂在这条主线上。建表 SQL 我简化了保留核心字段,你可以看到客户表并不追求把客户所有属性都塞进去,只放最通用的基础信息,行业、规模、来源渠道这些分析型字段放到了拓展表里,避免以后属性变多的时候要频繁 ALTER 大表。
CREATE TABLE `customer` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `customer_name` varchar(128) NOT NULL COMMENT '客户名称', `customer_level` tinyint NOT NULL DEFAULT '1' COMMENT '客户级别:1潜在/2普通/3重要/4战略', `source_channel` varchar(32) DEFAULT NULL COMMENT '来源渠道:展会/官网/转介绍/电销', `owner_id` bigint DEFAULT NULL COMMENT '归属销售ID', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1跟进中/2已成交/3已流失', `next_follow_time` datetime DEFAULT NULL COMMENT '下次跟进时间', `last_follow_time` datetime DEFAULT NULL COMMENT '最后跟进时间', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_owner_status` (`owner_id`, `status`), KEY `idx_next_follow` (`next_follow_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户主表';这张表里有两个字段我想展开讲一下。一个是source_channel,它是后续市场渠道 ROI 分析的基础,如果一开始不记录,后面想分析哪个渠道来的客户质量高,数据是补不回来的。另一个是next_follow_time,这是销售工作台“今日待跟进”列表的数据来源。当时有同事建议用last_follow_time来判断是否该跟进了,我坚持要加next_follow_time,因为不同的客户跟进频率完全不同,战略客户可能每天都要跟,潜在客户可能一周一次,必须让销售自己定下一次跟进的时间点,让系统做提醒而不是做猜测。
4.2 跟进记录表:沟通时间线的数据基石
跟进记录(follow_up)是整个系统里写入频率最高的表。销售每次跟客户打完电话、加完微信、见完面,都要在系统里留一条记录。它的表结构既简单又复杂,简单在于字段不算多,复杂在于内容格式比较灵活,我用了一个content_type来区分文字、语音转文字、图片还是文件,content_json存具体内容。你看建表语句里的content_json,这个字段可以承担很灵活的存储职责,比如语音记录可以存转写文本和音频地址,图片记录可以存图片 URL 列表的 JSON 数组。
CREATE TABLE `follow_up` ( `id` bigint NOT NULL AUTO_INCREMENT, `customer_id` bigint NOT NULL COMMENT '客户ID', `contact_id` bigint DEFAULT NULL COMMENT '联系人ID', `biz_type` tinyint NOT NULL DEFAULT '1' COMMENT '业务类型:1跟进/2报价/3签约/4回款', `content_type` tinyint NOT NULL DEFAULT '1' COMMENT '内容类型:1文字/2语音/3图片/4文件', `content_json` text NOT NULL COMMENT '内容,JSON格式', `creator_id` bigint NOT NULL COMMENT '创建人ID', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_customer_created` (`customer_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟进记录表';每次销售打开客户详情页,系统会一次性加载该客户最近 30 条跟进记录,并按时间倒序排成一条“客户沟通时间线”。这个设计参考了社交产品里信息流的思路——人最擅长阅读的是时间线,而不是表格。销售可以像刷朋友圈一样从头翻到尾,客户上个月说过什么、报价报了多少、当时是怎么回复的,一目了然。有同事提过要不要做成树状结构、把每次沟通单独变成一个独立会话,我否了,因为实际使用场景是销售要快速回顾客户全貌,不是看逐字稿,时间线足够满足需求。
4.3 商机与工单:客户生命周期的左右两条腿
商机表和工单表分别对应售前和售后两个阶段。商机表记录了一个潜在客户从“有意向”到“赢单”全过程的状态变化,我设计了几个状态:需求确认、方案报价、商务谈判、赢单或输单。每一个商机都要关联一个预计成交金额和预计成交日期,这样管理者可以随时看到未来三个月的销售预测。工单表则是客户签约之后的售后服务跟踪,记录客户提了什么问题、谁在处理、处理到哪一步了。为了实现“客户详情页一个页面看完所有历史”,商机和工单都强制要求关联customer_id,这是写入规范里的一条硬约束。
当时我们内部讨论过:一个客户能不能同时有多个商机?我把规则定为“同一客户同一时间只能有一个进行中的商机,但可以有多个历史商机”。原因是销售团队人不多,同时一个客户开两条商机线,多半是重复跟进,会产生内耗。这个限制在系统里是做了唯一索引并校验商机状态来实现的。这么设计在初期会有一些误伤——比如一个客户又买产品 A 又买产品 B,理论上是两个商机——但销售主管反馈,实际跑下来这样反而逼着销售把两个需求合并起来谈,更容易做大订单。
5. 核心功能模块实现:从线索池到销售工作台的完整闭环
5.1 线索池与客户认领:公平分线索规则背后的设计逻辑
系统里每天会有来自官网留资、市场活动、客服转交的各类线索(customer 表中 status=0 的就是线索状态),这些线索统一放在“线索池”里,任何销售都可以浏览,但要变成自己的客户,必须操作“认领”。这里有一个关键的场景:好几个人同时看中了一条线索,认领冲突怎么处理?我用的是 Redis 的分布式锁,锁的 key 是customer:claim:客户ID,拿到锁的人才能执行认领。后续把线索分配给内部销售,可以看我写的核心方法。
public Boolean claimCustomer(Long customerId, Long userId) { String lockKey = "customer:claim:" + customerId; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, userId.toString(), Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { return false; // 别人正在操作,本次认领失败 } try { // 二次校验:客户是否仍处于未认领状态 Customer customer = customerMapper.selectById(customerId); if (customer == null || customer.getOwnerId() != null && customer.getOwnerId() != 0L) { return false; } customer.setOwnerId(userId); customer.setStatus(CustomerStatusEnum.FOLLOWING.getCode()); customer.setNextFollowTime(LocalDateTime.now().plusDays(1)); return customerMapper.updateById(customer) > 0; } finally { redisTemplate.delete(lockKey); } }这段代码是面试中常被问到的“分布式锁”问题在真实业务中的落地案例,我把要点说一下。第一,setIfAbsent命令必须带过期时间,防止锁持有者宕机导致死锁;过期时间设 10 秒,正常认领操作几十毫秒就完成了,就算有人网络慢也不会误伤。第二,拿到锁之后必须再做一次数据库校验,因为从设置锁到走完查询之间可能有间隙,另外一个人可能已经把客户认领走了,这一步防的是双花。第三,认领后自动把next_follow_time设置为当前时间加一天,意思是“这条线索你得在 24 小时内开始跟进”,如果 24 小时没动作,系统会把线索自动退回池子,这是保证线索活跃度的关键机制。
5.2 销售工作台:一个页面管好一天的所有客户动作
销售工作台是 DeskcommCRM 使用频率最高的页面,底层逻辑是按“时间”和“优先级”双重排序拉开一天的待办清单。工作台页面首屏展示三块内容:今日待跟进客户列表、本周到期商机、未处理的服务工单。其中今日待跟进列表的查询 SQL 核心条件是next_follow_time between 今日0点和今日24点,再加上销售当前负责的客户,按customer_level降序排列,这样战略客户永远排在列表最前面。
前端用 Vue 3 实现,查询走的是后端分页接口。这里我有个小技巧:列表页的后端接口不做关联查询,只查 customer 表本身,联系人和最后一条跟进记录通过一个lastFollowUpMap批量查出来再在内存里组装。如果直接在 SQL 里 LEFT JOIN,数据量一上来,SQL 就会变成性能灾难。组装逻辑很简单:先查当前页 20 个客户 ID,然后用WHERE customer_id IN (20个ID)批量查跟进记录,再按客户 ID 分组后去取每组最大的created_at那条。这个小优化在 20 万客户数据下实测接口响应时间从 800 毫秒降到了 180 毫秒,效果非常明显。
5.3 客户详情页:沟通时间线、商机、工单一眼看全
客户详情页是整个系统信息密度最大的页面。顶部是客户基本信息和个人标签,中间是沟通时间线(默认加载最近30条跟进记录),下方分 Tab 展示商机列表、工单列表、联系人和附件。为了让打开速度足够快,我做了“分段加载”:客户的基本信息接口、时间线接口、商机列表接口三个并行异步请求,谁先回来谁先渲染,Vue 的异步组件让页面骨架先出,体验上几乎无感知。
我特别想提一下,这个页面我们做了一个很受欢迎的小功能:右侧悬浮“快捷记录”按钮,销售在任何地方看到跟这个客户相关的新信息,随时可以点击按钮弹出一个 Mini 对话框,输入文字、选择业务类型就能保存,无需跳转到新的表单页。这个交互在移动端同样保留了。很多销售反馈,就是这个小按钮让他们养成了随手记录的习惯。这给我们的启发是:一套 CRM 系统能留住用户,靠的不是多全的功能,而是把最常用的操作做到极致顺畅。
5.4 通信记录集成:把企业IM和邮件的往来收进系统
Deskcomm 里的 “comm” 就是通信的含义,这部分是让系统真正差异化的地方。我们做了一个邮件集成模块,通过标准 IMAP/SMTP 协议把销售绑定的企业邮箱收进来:销售每天发的邮件,系统会自动抓取副本挂到对应客户的时间线下;客户回复的邮件也会在时间线里自动冒出来。这样即使销售本人忘了录入邮件这条沟通记录,系统也不会漏。
企业 IM 的接入相对麻烦一些——各家产品有自己的开放接口,我们接的是企业自有的即时通信系统。基本思路是:销售在系统里给客户发消息时,可以选择“同时把消息同步到企业 IM”,系统通过开放接口把一条文本消息推送到客户的企业微信/自研IM会话里;客户在 IM 上的回复也会通过回调接口推给 DeskcommCRM,自动挂到对应客户的沟通时间线。这部分的回调处理有一点一定要防:回调消息是异步的,可能乱序到达,必须在处理逻辑里加一个“消息比对”,判断这条消息是否已经存在,避免重复插入时间线。我在消息表上建了biz_id的唯一索引,用通信平台的消息 ID 做去重。
5.5 自动化提醒:系统主动发现风险,而不是等销售想起
上线一段时间后我收到销售反馈说“有几个客户好久没跟进了,但系统也没提醒”,这其实是因为我第一版只做了被动查询——销售打开工作台才能看到待办。后来我加了一个自动化任务调度模块,在每天上午 10 点和下午 4 点各跑一轮任务:扫描所有next_follow_time已经过期或者今天过期的客户,按销售维度把“该跟进了”的消息推到企业 IM 和站内信。调度任务用 XXL-Job 实现,每天只跑两次,避免高频查询对数据库造成压力。
针对不同客户级别,我也设了不同的提醒阈值,这是我踩了一些坑之后总结出来的经验。潜在客户超过 3 天没跟进就提醒,普通客户超过 5 天提醒,重要客户超过 2 天提醒,战略客户超过 1 天就提醒。最初我把所有客户统一为 3 天,结果战略客户被冷落了一周都没人发现,普通客户又因为提醒太频繁让销售产生了免疫。分级之后,提醒才真正起到作用。
6. 权限模型设计:多角色、数据范围、字段级权限的三层控制
6.1 角色功能权限:RBAC模型的最小可用实现
DeskcommCRM 的权限模型分三层,第一层是“角色功能权限”,也就是经典的 RBAC(User-Role-Permission)模型。系统预置了四个角色:销售、销售主管、服务专员、系统管理员。不同角色看到的功能菜单不同,能操作的按钮也不同。比如销售只能新建和编辑自己名下的客户,销售主管可以查看所有下属的客户列表并可以做客户分配,服务专员只能操作工单模块,系统管理员拥有全部权限并且能维护字典数据。
具体实现上,我用了 Spring Security + 自定义注解@RequirePermission("customer:update")的方式。后端在每个 Controller 方法上标注需要的权限码,请求到达时通过拦截器校验当前用户是否拥有该权限码。前端菜单的显隐和按钮的禁用状态,则是登录成功后返回权限码列表,前端通过v-permission自定义指令来控制。这个方案比前端代码写死管理员的判断要优雅得多,新增角色时也很方便。
6.2 数据范围权限:销售只能看自己的,主管能看全组的
第二层“数据范围权限”是我认为 CRM 系统里最容易做漏的地方。很多系统只做了菜单权限,结果销售主管要看下属客户的跟进情况时全是空白,最后只能给主管开管理员权限,数据安全就完全失控了。DeskcommCRM 里我在客户查询接口的 SQL 上做了数据权限自动拼接,用一个DataScope注解在 Service 方法上声明“当前用户只能查询自己名下的数据”或者“当前用户及下属的数据”。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { String customerAlias() default "c"; // 数据范围类型:1仅本人 2本人及下属 3全部 int type() default 1; }拦截器读取注解后,根据当前用户角色自动在查询 SQL 上拼接条件。销售执行客户列表查询时,实际执行的 SQL 会被动态追加AND c.owner_id = 当前用户ID;销售主管则追加AND c.owner_id IN (当前用户ID, 下属1ID, 下属2ID...)。这样做的好处是业务代码里完全不用关心数据范围的逻辑,查询方法只需要写原始的查询条件,数据范围由框架统一控制,不会出现某个接口遗漏导致的越权漏洞。
6.3 字段级权限:客户手机号按角色脱敏显示
第三层是字段级权限,主要用于敏感信息的保护。客户手机号、微信、家庭住址等敏感字段,我们要求销售以外的角色(比如财务、市场部的人)查看时必须是脱敏状态,只有销售本人和销售主管能看到完整的联系方式。实现方式是后端在返回数据前,根据当前角色把敏感字段替换成脱敏串,比如138****1234,前端不做任何处理,防止有人直接调接口抓完整数据。
这里有一个踩过的坑要提醒大家:字段脱敏必须要做在后端,而不是前端。有一版我们是在前端 Vue 里做了个过滤器来脱敏,结果有同事直接在浏览器开发者工具里调后端接口,把完整手机号给拉了出来,测试阶段就发现了。从那以后,凡是涉及安全策略的,一律以后端为准,前端只能做展示优化,永远不能承担安全责任。
7. 部署上线与日常运维体验:从物理机到云容器的一路折腾
7.1 服务器资源规划:到底需要几台机器
很多人问自建一套内部系统到底要花多少钱在服务器上。我直接给出我们生产环境的真实配置,仅供参考。用户规模 200 人以内的团队,一台 8 核 16G 的云主机跑应用和 MySQL 就够用了;如果上了 ES,建议单独一台 8 核 16G 给 ES 和服务拆分,因为 ES 是内存大户,堆内存给少了 GC 频繁,搜索延迟会飙升。Redis 可以和应用共用一台机器,控制在 2G 内存以内就够了。我们是 3 台机器的架构:两台应用节点做 Nginx 负载均衡和主备容灾,一台专门跑 MySQL 和 Redis;ES 集群初期只有 2 个节点。
内网部署的话,机器的成本压力会更小。但如果碰到节假日大促、市场部群发活动导致访问量脉冲式增加的情况,云环境的弹性扩容优势就体现出来了。我们有一回做完市场活动,官网访问量瞬间上来,线索录入接口 QPS 翻了 20 倍,还好应用是无状态多节点部署,直接在云控制台点里加了两个节点就抗住了,数据库和 Redis 的负载也都在安全范围内。
7.2 容器化部署:Docker Compose 一脚本拉起整个环境
开发环境、测试环境、生产环境三套环境,我不想手工一台台去装 JDK、MySQL、Redis,所以在项目里提交了 Docker Compose 编排文件。生产环境用 Docker Compose 已经能满足大部分需求,除非你要上 Kubernetes 做自动扩缩容,那种复杂度对内部系统来说是过度设计。我用 docker-compose 定义了后端应用、MySQL、Redis、Elasticsearch、Nginx 共五个容器,其中 redis 里还启用了 AOF 持久化,防止重启把会话和缓存数据丢了。Nginx 配置了 gzip 压缩和前端静态文件缓存,首屏加载速度提升明显。
有个小细节需要注意:MySQL 的时区配置必须加上TZ=Asia/Shanghai,不然后端 Java 程序写入的LocalDateTime和 MySQL 的DATETIME之间会有 8 小时的偏差。这个问题我当年第一次做部署的时候排查了整整一天,现在写进部署清单里,社区里也有人频繁踩到。凡是做面向国内用户的系统,从一开始就把时区统一到Asia/Shanghai,可以省掉后面大量的时间转换 bug。
7.3 数据备份与迁移:救命用的每日全备+增量日志
数据备份是 CRM 这类核心业务系统不可省的一道工序。我配置了每天凌晨 2 点通过 mysqldump 做全量备份,保留最近 14 天的备份文件;同时开启了 MySQL 的 binlog,配合保留 7 天的 binlog 日志,这样任何一个时间点的数据都能通过全量备份+binlog 回放到指定时刻。备份文件通过脚本同步到另一台异地的对象存储,防止机器被删或者是机房故障的时候本地备份全毁。
我还写了一个每日校验备份的脚本,每天检查备份文件的大小是否大于某个阈值(比如 1GB),如果连续三天备份文件大小明显偏小,就发告警通知——正常情况下全量备份的体量应该在一个稳定区间内,明显偏小说明备份可能失败或业务数据异常。这不是什么高技术含量的事,但能让你在灾难发生时真的敢从备份恢复数据,而不是恢复的时候才发现备份是坏的。
8. 上线后的常见问题与排查手记:那些真实踩过的坑
8.1 并发认领客户时数据错乱:Redis锁没覆盖到的洞
系统上线第二周就遇到一个问题:两个销售同时点击认领同一个客户,居然都成功了。查日志看到两个请求都返回了“认领成功”。排查过程是这样的:第一轮怀疑 Redis 锁没生效,看了 Redis 日志发现锁的 key 在极短的时间内被创建和删除;第二轮仔细看代码,发现问题出在setIfAbsent的finally块——我在方法退出前无条件删除了锁,如果第一个请求还没走完 DB 操作,锁就被第二个请求抢到了?不对,仔细看我的实现,方法不是异步的,一个请求走完才会结束,正常情况下不会出现交叉。真正的原因是我把claimCustomer方法放在了一个没有开启事务的 Service 里,更新操作和锁的释放之间无法保证原子性。解决办法很简单,给方法加上@Transactional,并且把锁的释放放到事务提交之后再执行,用TransactionSynchronizationManager.registerSynchronization实现。
这个问题其实暴露了一个深层次的教训:分布式锁保护的是代码块的互斥,而不是事务的原子性。你要保证“检查-修改-释放锁”整个过程是一个完整的事务,锁的存在才有意义。后来我把所有涉及写操作的业务方法都加上了事务注解,并做了统一异常处理,这类问题就再也没出现过。
8.2 客户跟进时间线出现重复数据:回调幂等没做干净
还有一次,客户在 IM 里连续给销售发了两条消息,时间线里显示出了三条记录,第三条是重复的。查了一遍发现是通信平台的回调机制:消息发送成功后,平台会回调两次(一次是消息发送成功事件,一次是消息内容同步事件),两次回调携带了相同的消息 ID。我的处理逻辑里虽然做了biz_id唯一索引,但第一次去重却是在索引之前——先查是否存在,不存在则插入,中间有一个极小的时间缝隙,两条并发回调同时通过了“不存在”的判断,然后都在插入阶段失败?不对,因为唯一索引的存在,第二条插入会直接抛 DuplicateKeyException,但被全局异常处理器吞掉了,所以业务上表现为“看起来没插进去”,实际上时间线里已经出现了一部分把消息 JSON 存乱的数据。
修复方案是把唯一索引的约束做得更严格:除了biz_id唯一,还加了customer_id + source + biz_id的三合一唯一索引。但更根本的解决是:在插入前捕获DuplicateKeyException,把它当成“消息已存在”的正常情况处理,不记录异常日志,这样既不会产生脏数据,也不会误导排查。这件事提醒我,对接外部系统的回调时,幂等处理必须从“先查后插”改成“靠数据库约束兜底”的思路,靠应用层判断永远有并发漏洞。
8.3 搜索越来越慢:MySQL LIKE 方案迁移到 Elasticsearch
前面提过,全局搜索一开始用 MySQL LIKE,客户数据 5 万的时候还好,20 万之后性能急剧下降。尤其是搜索条件里包含客户名、手机号、备注关键词三个字段的 LIKE 匹配,相当于全表扫描三遍。后来我用 Elasticsearch 替代,数据同步用 RocketMQ:数据库变更时发送消息,消费端异步把数据 upsert 到 ES。这个方案落地后的效果:搜索客户名、联系人、手机号、备注关键词的统一走 ES 查询,由于 ES 本身是倒排索引,再配合 IK 中文分词器,既能精确匹配关键词,也支持模糊搜索,响应时间稳定在 150 毫秒到 200 毫秒之间。
ES 索引同步有一个必须注意的延迟问题:数据库刚更新的客户名,到 ES 里能搜到,中间可能有 1 到 2 秒的延迟。这是异步系统的通病,无法完全避免,但我们通过设置“更新后跳转到详情页”的交互路径来规避掉——搜索出现旧数据不影响点进详情页,详情页的数据永远是实时查数据库的。如果你做类似系统,我建议把搜索的时效性要求定得低一点,不要在搜索这里追求强一致性,绝大多数业务场景下,2 秒内的查询延迟完全可接受。
8.4 工单状态与客户时间线不同步:领域事件解耦怎么设计
服务专员在处理客户工单时,会在工单系统里把状态从“处理中”改成“已解决”,但这个状态变更没有同步到 CRM 客户的时间线里,销售查看客户详情时看不到最新的处理结果。出现这个问题的原因是:工单模块和客户模块是分开的两个服务,工单状态变了,客户模块并不知道。我在工单状态变更的地方发一个领域事件(RocketMQ 的普通消息),客户模块消费事件后在客户时间线下新增一条“系统自动记录:工单 #12345 状态变更为已解决”。这样就实现了解耦——工单服务不需要知道客户服务的存在,但客户服务订阅了所有关注的事件。
顺着这个模式往下走,我们还把“合同签署完成事件”、“回款到账事件”都做成了领域事件,凡是客户生命周期里的关键节点,消费端会自动往客户时间线里写一条系统记录。销售的反馈非常好,他们说现在客户详情页真的是“一条龙看到底”,之前需要去 OA 系统查的合同进程和回款进程,现在打开客户就能看到。领域事件这个设计模式,在中后台系统里真的值得推广。
9. 常见问题速查表:按图索骥快速定位问题
我把上线以来运维和客服反馈的问题整理成了一张速查表,团队内部排查问题靠这张表能省很多时间。以下几个问题是遇到频率最高的,直接给排查思路和解决方案。这张表不一定覆盖所有场景,但解决 80% 的常见问题足够了。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 登录后页面一直转圈 | 网关 session 校验失败 | 查看 Nginx 日志,确认是否跨域 | 检查 Cookie 的 SameSite 属性,统一登录态有效期 |
| 客户列表加载很慢 | 数据权限 SQL 拼接导致索引失效 | EXPLAIN 查看执行计划 | 在owner_id和status上建立联合索引 |
| 搜索不到刚录入的客户 | ES 索引同步延迟 | 查看 RocketMQ 消费进度 | 等待 2 秒后重试,或进入详情页确认 |
| 跟进记录保存失败 | 事务回滚或消息队列积压 | 检查服务日志中的异常堆栈 | 确认biz_id是否重复,调整唯一索引 |
| 客户认领提示“已被认领” | 并发请求同时命中 | 查看 Redis 锁是否生效 | 检查锁的过期时间和释放时机 |
| 时间线出现重复记录 | 回调重复推送未幂等 | 查消息表biz_id | 用唯一索引兜底,捕获 DuplicateKeyException |
| 工作台待办数量不对 | 时区不一致导致日期边界错乱 | 对比 MySQL 时区与 Java 时区 | 统一为Asia/Shanghai |
| 导出数据中文乱码 | 文件编码问题 | 查看导出 CSV 编码 | 导出时强制 UTF-8 with BOM |
10. 做这类系统最值得记住的三件事
写到这里,我想把这段时间做 DeskcommCRM 最深的感受分享出来。第一件事:永远不要低估“简单”的力量。销售团队真正高频使用的功能其实不超过 10 个——录入客户、写跟进、看时间线、待办提醒、全局搜索、商机看板、工单处理。把这几个核心流程做到极致顺畅,比堆砌 50 个低频功能有价值得多。我们的产品之所以能让销售坚持用下去,很大程度上是因为“快捷记录”这个按钮真的足够快,快到不打断他们原有的工作节奏。
第二件事:数据模型和权限模型是系统的地基,这两个设计错了,后面所有楼层都会歪。客户主键的设计、跟进记录的时间线结构、数据权限的边界划分,这些在设计阶段多花三天想清楚,后面能省下三个月的返工时间。权限模型如果做浅了,后期补远比前期做要痛苦。
第三件事:系统上线只是起点,不是终点。DeskcommCRM 上线后的第二个月,我们根据销售反馈迭代了 4 个版本,改的最多的是交互细节:把客户级别选择框改成更醒目的大按钮、在时间线里增加按业务类型过滤的 Tab、把待办列表按客户级别做颜色标记。这些细节没有一个是技术上复杂的,但每一个都直接影响用户坚持使用的意愿。自研系统的核心优势就是迭代速度,一定要把这个优势发挥到极致。
如果你也在规划类似的客户管理系统,我的建议是:先花足够时间把销售的真实工作流程摸透,再动手设计数据模型,最后才谈技术选型。想清楚“谁在什么场景下用哪几个核心功能”,比一开始就纠结用 Spring Cloud 还是 Go 微服务重要一百倍。