拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

自研CRM系统实战:从需求拆解到技术落地的完整指南

自研CRM系统实战:从需求拆解到技术落地的完整指南 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT跟进记录表;每次销售打开客户详情页系统会一次性加载该客户最近 30 条跟进记录并按时间倒序排成一条“客户沟通时间线”。这个设计参考了社交产品里信息流的思路——人最擅长阅读的是时间线而不是表格。销售可以像刷朋友圈一样从头翻到尾客户上个月说过什么、报价报了多少、当时是怎么回复的一目了然。有同事提过要不要做成树状结构、把每次沟通单独变成一个独立会话我否了因为实际使用场景是销售要快速回顾客户全貌不是看逐字稿时间线足够满足需求。4.3 商机与工单客户生命周期的左右两条腿商机表和工单表分别对应售前和售后两个阶段。商机表记录了一个潜在客户从“有意向”到“赢单”全过程的状态变化我设计了几个状态需求确认、方案报价、商务谈判、赢单或输单。每一个商机都要关联一个预计成交金额和预计成交日期这样管理者可以随时看到未来三个月的销售预测。工单表则是客户签约之后的售后服务跟踪记录客户提了什么问题、谁在处理、处理到哪一步了。为了实现“客户详情页一个页面看完所有历史”商机和工单都强制要求关联customer_id这是写入规范里的一条硬约束。当时我们内部讨论过一个客户能不能同时有多个商机我把规则定为“同一客户同一时间只能有一个进行中的商机但可以有多个历史商机”。原因是销售团队人不多同时一个客户开两条商机线多半是重复跟进会产生内耗。这个限制在系统里是做了唯一索引并校验商机状态来实现的。这么设计在初期会有一些误伤——比如一个客户又买产品 A 又买产品 B理论上是两个商机——但销售主管反馈实际跑下来这样反而逼着销售把两个需求合并起来谈更容易做大订单。5. 核心功能模块实现从线索池到销售工作台的完整闭环5.1 线索池与客户认领公平分线索规则背后的设计逻辑系统里每天会有来自官网留资、市场活动、客服转交的各类线索customer 表中 status0 的就是线索状态这些线索统一放在“线索池”里任何销售都可以浏览但要变成自己的客户必须操作“认领”。这里有一个关键的场景好几个人同时看中了一条线索认领冲突怎么处理我用的是 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 的权限模型分三层第一层是“角色功能权限”也就是经典的 RBACUser-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 和 RedisES 集群初期只有 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 的时区配置必须加上TZAsia/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 BOM10. 做这类系统最值得记住的三件事写到这里我想把这段时间做 DeskcommCRM 最深的感受分享出来。第一件事永远不要低估“简单”的力量。销售团队真正高频使用的功能其实不超过 10 个——录入客户、写跟进、看时间线、待办提醒、全局搜索、商机看板、工单处理。把这几个核心流程做到极致顺畅比堆砌 50 个低频功能有价值得多。我们的产品之所以能让销售坚持用下去很大程度上是因为“快捷记录”这个按钮真的足够快快到不打断他们原有的工作节奏。第二件事数据模型和权限模型是系统的地基这两个设计错了后面所有楼层都会歪。客户主键的设计、跟进记录的时间线结构、数据权限的边界划分这些在设计阶段多花三天想清楚后面能省下三个月的返工时间。权限模型如果做浅了后期补远比前期做要痛苦。第三件事系统上线只是起点不是终点。DeskcommCRM 上线后的第二个月我们根据销售反馈迭代了 4 个版本改的最多的是交互细节把客户级别选择框改成更醒目的大按钮、在时间线里增加按业务类型过滤的 Tab、把待办列表按客户级别做颜色标记。这些细节没有一个是技术上复杂的但每一个都直接影响用户坚持使用的意愿。自研系统的核心优势就是迭代速度一定要把这个优势发挥到极致。如果你也在规划类似的客户管理系统我的建议是先花足够时间把销售的真实工作流程摸透再动手设计数据模型最后才谈技术选型。想清楚“谁在什么场景下用哪几个核心功能”比一开始就纠结用 Spring Cloud 还是 Go 微服务重要一百倍。
返回列表