外呼客户管理系统搜一个号码要等八秒,助贷CRM的检索慢在三个环节,加机器只会让它更快地说没有,三种病的修法完全不在同一处(外呼怎样才能让客户办理业务) ypxx.net

一、电话已经接通了,屏幕上还在转圈

下午两点十分,一个做房产抵押的坐席接到回拨。上周这个号码打过两次,客户都没接,今天自己打回来了。

客户开口说:「我上周在你们这儿留过资料,姓周。」

坐席在系统里找这个人。他记得的只有三件事:姓周,做建材,号码尾号 6271。他在搜索框里输入 6271,回车,列表开始转圈。

电话那头客户在等,问了一句「你那边能查到吗」。八秒之后结果出来了,217 条。坐席把手机夹在肩膀上,一条一条往下翻。翻了十几条,不是。客户说「要不我加你微信发资料吧」。电话挂了。

这段是按常见配置推演出来的,不指向哪一家具体产品。它每天都在几十万个团队里发生,而且不会进任何一张报表——系统里既没有这通电话的记录,也没有一条「客户没找到」的日志。

真正的损失不是那八秒。是这通电话没有产生任何一条可以复盘的记录,而它本来是一周里少有的高意向通话。

多数人遇到这件事的反应是找一个能算账的理由:服务器配置低了,换个套餐,加内存,加带宽。这一类动作有一个共同特点——它们都不需要先搞清楚慢在哪。

二、先把三种「搜不到」分开:慢、空、多

检索出问题的时候,业务侧的描述往往是同一句话:「系统搜不到客户」。但这句抱怨底下其实是三种完全不同的病。

头一种是慢。有结果,只是要等。等三秒坐席还能忍,等八秒他会开始怀疑自己输错了,等十五秒他就改用微信了。

第二种是空。零结果。可能是号码存成了带横线的格式,可能是客户名里有一个错字,可能是这个客户根本不在本机构的库里,也可能是他在库里、但这个坐席的数据范围看不到他。

第三种是多。结果出来两百条,前二十条里没有要找的那个。

三种病要动的部位完全不同。慢,动的是查询与索引;空,动的是写入规范与数据质量;多,动的是排序与筛选。它们的体检指标也不一样:慢看 P95 响应时间,空看零结果率,多看「搜一次就点中目标」的比例。

把三种病当成一种治,是检索优化里代价很高的一种做法。加机器对「空」完全无效,它只会让系统更快地告诉你没有这个人。对「多」更无效,结果出得快一点,坐席翻两百条的时间一点没少。

一个可以当天做完的动作:把这三条指标各自记一天,再决定改哪里。多数团队记完会发现,抱怨「慢」的那个组,零结果率其实高得离谱——他们不是搜得慢,是搜不到,反复搜同一件事才显得慢。

「多」这一种还有自己的伪装。它看起来像「搜到了」,实际上是没搜准:输入「周」,出来三百个姓周的;输入拼音缩写,出来一批同音不同字的;输入公司名里的两个字,客户的正式名称里写的是简称。坐席只能一条条点进去看,点错一次退回来一次,这个动作在后台留下的是一串详情页请求,看起来像是「用得很活跃」。

三、号码在库里长成什么样,决定了你能不能秒找到人

同一串手机号 13800138000,在真实系统里至少有七种存法:带 +86 前缀、344 空格分段、带横线、全角数字、前后各带一个空格、被 Excel 导入成 1.38001E+10、以及干脆被填在「备注」这个长文本框里。

第八种更容易被忽略:客户留的是公司前台总机,「010-8888 6666 转 218」。这个号码在库里完全合法,但它永远打不到人。

这八种写法带来两个后果。

头一个后果是查不出来。「138 0013 8000」和「13800138000」在数据库里是两个不相等的字符串。坐席输的是不带空格的,库里存的是带空格的,等值查询直接返回零条——这就是「空」这个病的主要来源之一。

第二个后果是索引失效。有些团队意识到了这个问题,于是在查询里做兼容:把列里的横线和空格用函数剥掉再比。写法大概是把字段套一层替换函数。这一层套上去,字段上的索引就彻底用不上了,数据库只能逐行算一遍函数再比——数据量一上来,一次查询就是一次全表扫描。

对的做法是反过来的:在写入那一刻把号码归一化,另存一列纯数字,查询时把输入也归一化,然后两列做等值比较。归一化只在写入时做一次,读的时候不对列做任何运算,索引才能用上。

还有一个安静得多的问题:隐式类型转换。手机号列是字符型,如果查询参数传进去的是数字类型(前端把输入框的值直接当数字传了),数据库会先把整列转成数字再比,索引整体失效。这类问题在测试环境几乎发现不了,因为测试数据是干净的、量也小,全表扫描一样是毫秒级。

一句可以拿去用的话:号码检索的准确率不取决于搜索算法,取决于入库那一刻有没有人管格式。搜索框做得再聪明,也救不了写进去的脏数据。

落地的时候有两个小决定值得单独做。一是把「可直拨的号码」和「单位总机」拆成两栏,回拨优先取直拨;二是在导入环节做一次号码体检,把位数不对、号段明显异常、带分机的记录单独列出来让人确认,而不是静默接受。这两件事都不难,难的是有人负责。

四、前置通配符是原罪,但业务需求是真的

搜索「尾号 6271」这种需求,工程师给的写法通常是模糊查询,前后都挂通配符。这个写法用不上 B+ 树索引:索引是按从左往右的顺序排好的,前面一挂通配符,等于让数据库从每一行的中间开始找,它只能一行一行比过去。

但需求是真的。客户只报后四位;坐席只记得公司名中间那两个字;主管要查名字里带某个行业词的所有客户。这三种需求都要求「任意位置匹配」,而任意位置匹配和索引在原理上就是冲突的。

工程上有四条路,代价各不同。

头一条是反向列。另存一列,把号码倒过来存,查后缀就变成了查前缀,「6271」倒过来是「1726」,用前缀匹配就能走索引。成本很低,公开的技术文章里被反复提到,但它只解决后缀,中间包含仍然无效。

第二条是分段。按 344 把号码切成头三位、中间四位、末四位三列,各自建索引,查询时按输入的位数改写:输四位查两次(中段与尾段),输八位先取后四位缩小范围再校验。索引更精确,代价是要动查询层,而且对外要统一成一个搜索框。

第三条是前缀索引。只索引字段的前若干个字符,省空间,但只支持前缀匹配——对「公司名中间两个字」这类需求没有帮助。

第四条是全文索引或独立检索引擎。这条路被推荐得频繁,也容易做错。做错的典型是分词方式:号码如果用通用分词器处理,会被切成「188」「1234」这样的碎片,查一个完整号码反而命中一堆无关文档。公开实践里靠谱的做法是写入时就把号码按 344 预处理成分词单元,或者改用不分词的字段类型。

这里有一条判断值得记住:搜索框允许输入什么,是产品决定;能不能走索引,是存储决定。两者的接缝在「写入时多存一份」,不在「查询时多写一层」。所有把优化堆在查询侧的做法,末了都会退化成一次全表扫描,只是退化得快慢不同。

如果走的是索引这条路,还有两个细节:列表页只要六个字段,可以让索引把这六个字段带全,查询时不用回表,这一步的收益往往比再加一个索引大;反过来,索引不是越多越好,二十几个字段全建索引,每写一条记录要维护二十几棵树,写入会明显变慢。

判断有没有真的走上去,不用猜,看一个对比就够:这条查询扫过的行数与实际返回的行数。扫描行数是返回行数的几十倍甚至几百倍,说明索引没起作用,或者被用在了一个区分度很低的列上。区分度这个事常被忽略——在一个只有三种取值的状态列上建索引,数据库扫一遍索引和扫一遍表差不多,优化器干脆不用它。号码、公司名这类高区分度的列才值得单独建。

五、数据范围过滤插在哪一步,决定索引怎么建

三十人的团队,主管只能看本组。这条限制加在哪个位置,直接决定系统快不快。

加在应用层,也就是先把符合条件的记录全查出来,再在代码里过滤掉不属于本组的。这个写法在十个人的团队里毫无问题,到三十人、三百人就是灾难:数据库干了十倍于必要的活,而且把不该给这个角色看的记录取到了内存里。慢,同时是合规隐患。

加在 SQL 里,也就是把负责人条件写进 WHERE,是对的。但位置很讲究。

联合索引的顺序决定它能服务哪种查询。租户、负责人、号码三列组成一个索引,适合「本组内按号码搜」;租户加号码两列,适合「全公司在权限范围内按号码搜」。两套都给,让数据库自己挑,比赌一套要稳。

反过来,如果租户条件被放在 WHERE 的末尾、索引里根本没有它,那这条 SQL 在高并发下迟早出问题——它每查一次都要扫过所有租户的数据。

一句可以带走的话:数据范围不是查询的附加条件,它是索引的头一列。凡是把租户、组织、负责人放到条件末尾的系统,数据量一上来迟早会慢,而且慢得找不到原因。

附带一个经常被忽略的开销:分页的总数。每翻一页都实时算一次总数,在大表上等于每页多跑一次全表统计。三种做法各有取舍:用执行计划估出来的行数(快,但不精确)、把总数缓存几十秒(够用,但要接受短暂不一致)、改成「还有更多」式的游标分页(不显示总页数,坐席也不需要知道一共多少页)。

这一节与权限模型是两件事。谁能看哪些记录,是规则设计的问题;这条规则落在 SQL 的哪一步、进不进索引,是查询实现的问题。规则设计得再细,落点错了照样慢。

六、列表页的四个隐形放大器

前面讲的是搜索框。真正让坐席觉得「整个系统都卡」的,往往是列表页。

头一个是把整行都取出来。备注、跟进记录这类长文本跟着一页二十条一起返回,一页就是几百 KB。坐席在列表上其实只看得到六列。

第二个是关联带出。列表要显示客户名和末次跟进时间,查询却把合同、订单、工单都连了一遍。关联的表越多,数据库要做的中间结果越大。

第三个是状态现算,这个出现得频繁,也隐蔽。有一个被公开记录过的生产案例:客户列表要显示「当前状态」,而状态存在一张变更历史表里,于是每次查列表都去历史表里算一次当前值。单条查很快,一页就慢,因为它对每一行都跑了一次子查询。后来改成状态变更时同步写一个冗余字段,查询时间从十几秒降到几百毫秒。

这个案例值得记住的不是那个数字,是它的形态:凡是「显示一个需要现算的值」的列表,都会在数据量上来之后突然变慢,而且慢得和行数不成比例。

第四个是前端一次性加载。不做服务端分页或虚拟滚动,一进页面就拉两千条,浏览器渲染本身就要几秒。

一句判断:列表页慢,通常是这一页要求数据库干的活太多,而不是数据库不行。先把「这一页到底要显示什么」砍到六个字段,比换数据库有效得多。

怎么快速认出「状态现算」这一类问题:看这条查询的耗时是不是随行数线性增长。查一条很快、查二十条慢十倍,基本就是每行都算了一遍。另一种认法是看执行计划里有没有出现被反复执行的子查询——它不在 SQL 的正文里,而藏在 SELECT 的列表里,所以扫一眼 SQL 往往看不出来。

七、搜出 217 条,头一条该给谁

假设慢的问题已经解决,两百条结果在两百毫秒内出来了。坐席的麻烦才刚开始。

排序在这种情况下不是锦上添花,是功能本身。合理的顺序是:完全等于输入的排在前面,其次是前缀匹配,再次是包含;同一档里再按末次跟进时间或末次通话时间倒序。

实现上有一个坑:不要查三次再合并。查三次会让分页彻底失效,翻到第二页时三个结果集的偏移量各算各的,会出现重复和漏项。正确做法是一次查询里给匹配方式算一个权重,按权重排序。

拼音首字母是另一个需要说清边界的东西。它能解决「我只记得读音」,但解决不了「到底是哪一个」:输入 zs,可能是张三、赵四、周松;输入 ln,可能是李娜、李宁、刘娜。首字母索引的价值是把候选从几万条缩到几十条,剩下的靠人判断。

同名不同人则完全是另一回事。搜「张伟」出来三条,靠什么区分?靠公司名、号码尾号、末次跟进时间。所以搜索结果列表必须显示这三个字段——如果列表只显示姓名和电话,等于把区分成本全推给坐席,他只能点进去看,一次一次退回来。

一句判断:搜索结果的头一屏不是「全部匹配」,是「大概就是他的那一个」。默认按创建时间倒序的搜索框,等于没做搜索。

还有一个可以在半小时内改掉的设计:把「姓周、建材、尾号 6271」变成可叠加的筛选条件。条件越多越准,但每加一个条件就多一次匹配,所以条件的默认顺序要按区分度排——号码优于公司名,公司名优于姓名,姓名优于行业。

八、缓存要带三个维度,少一个就是事故

检索一慢,第二个被提出来的方案总是加缓存。缓存确实是有效的,但它同时是这类系统里权限事故的主要来源。

缓存的键至少要有三样东西:租户、角色与数据范围、以及完整的查询条件(含分页和排序)。

少租户维度,就是把 A 公司的客户名字显示给 B 公司。这不是慢的问题,是数据泄露。

少数据范围维度,就是坐席搜到了只有主管能看的客户。表面上是性能优化,实质上是绕过权限。

少排序和分页,就是坐席翻第二页时拿到另一套结果,翻回去又变了。

粒度也要选。缓存「结果的 ID 列表」比缓存「完整对象」安全,因为取详情时还会过一次权限;反过来,缓存完整对象更快,但权限一变更,缓存里就是脏数据,而且没人知道它脏了。

失效策略上,短 TTL 加写操作主动失效,比长 TTL 靠过期要安全。检索结果的时效性要求本来就不高,几十秒的过期对体验几乎无感,但能把脏数据的窗口压到一个可以接受的量级。

一句判断:缓存既是性能手段,也是权限漏洞的主要来源。凡是没有在键里带权限维度的缓存,都是一颗延时引爆的雷。

九、导出别和列表共用一条查询

导出的特征和列表完全不同:一次几万行、字段多、不能分页、用户点完就走、HTTP 请求的超时时间还很短。

让导出和列表共用一条查询,后果是很具体的:导出把连接池占满,全公司的列表一起卡。很多团队遇到过「下午三点大家都卡」,查来查去是有人在导客户名单——而这个动作在日志里看起来只是一条慢查询。

三条做法,从轻到重。

异步任务。点导出先建任务,后台跑,跑完通知下载。这是必做的一条,不做的话,几万行导出在同步请求里迟早会超时。

单独限流。同时只允许若干个导出在跑,其余排队,并告诉用户在排队。这一条比加机器有用,因为它保护的是别人。

独立数据源。导出走只读副本,不和交易型查询抢资源。这一条成本高一些,但它是根治。

一句判断:导出不是「大一点的查询」,它是另一种负载。把它和列表放在同一个池子里,等于让卡车和小轿车共用一条车道,而且是卡车不限行的那条。

十、上线后拿四个数验收,别拿「感觉快了」验收

改完之后,怎么证明改对了。四个数,一天就能拿到。

P95 响应时间。不看平均值。平均三百毫秒可能是九成五的请求在一百毫秒、半成在四秒,而坐席记住的永远是那四秒。

慢查询占比。超过一秒的检索请求占全部检索请求的比例。这个数决定要不要继续往下改。

零结果率。搜了但没搜到。这一条要紧,因为它是数据问题与性能问题的分界线——零结果率高说明问题在入库,不在查询。这时候继续优化查询,等于给一个空仓库装更快的传送带。

一次命中率。搜一次就点进目标客户的比例。这个数低,说明排序和筛选做得不好,而不是搜得不快。

算一笔时间的账,按机制推演:三十人的团队,每人每天四十次检索,一次多等三秒,一天就是三千六百秒,约一小时;按两百五十个工作日算,一年两百五十小时。这个数比「换一台更好的服务器」更能说服老板,因为后者说不清换完省下了什么。

这四个数不需要什么高级工具,埋点做对两件事就够:每次检索记一条日志,写清输入的内容、命中的条数、耗时、是否点了结果;以及「点了哪一条、排在第几位」。有了这两个字段,零结果率和一次命中率都是当天能算出来的数,P95 也是。

一句判断:检索的验收标准只能是分布,不能是感觉。「今天好像快了点」不是结论,「P95 从四点一秒降到零点九秒」才是。

十一、五个反例,都是真实踩过的

反例一,一慢就加内存加带宽。瓶颈在查询计划的时候,加硬件只是让全表扫描跑得更欢快。判断方法很简单:看数据库的 CPU 和磁盘在慢查询期间是否打满,如果都没满,加硬件没有用。

反例二,给所有字段建索引。写入变慢是一个后果,另一个后果更麻烦:索引多了,优化器可能选错索引,某几条查询反而更慢。索引要按真实查询条件建,判断依据是慢查询日志,不是想象。

反例三,直接上检索引擎但没解决数据质量。脏数据进搜索引擎还是搜不到,只是搜不到得更快。上引擎之前,先做号码归一化。

反例四,把搜索做成筛选。所有条件都必选,或者一进页面先让人选完五个下拉框再搜。坐席手里只有一个模糊记忆,必选条件等于逼他放弃搜索,直接去微信里问客户。

反例五,为了快先取全量再在应用层过滤。性能和合规双输,而且这个写法在数据量小的时候看不出问题,等到看出来的时候,代码已经长在几十个接口里了。

自家四条短板

写到这儿该说说我们自己的问题了。这一层我们在做的过程中有四件事没做好,列出来比藏着有用。

一是跨系统的号码检索要过一层中间层。当客户数据分散在两个库里时,我们的检索做不到跨库的联合下推,P95 比单库明显差。同一份数据在一个库里,体验才是一致的。

二是拼音只支持全拼首字母。双拼和模糊音不支持,南方口音里 n 和 l 不分、前后鼻音不分的场景,首字母会漏。

三是大导出要排队。几千行以上走异步队列,用户会看到等待提示。这是主动的取舍——宁可让一个人等,不让全公司一起卡。

四是检索日志只保留三十天。做季度以上的检索行为分析要单独取数,产品内的报表看不到那么远。

十二、八条清单,这周就能动

结语

回到那通电话。

客户说「我上周在你们这儿留过资料」,坐席要做的其实只有一件事:在四秒内看到这个客户上次说了什么、上次是谁跟的、上次卡在哪。这件事跟搜索框做得多漂亮关系不大,它取决于三件很土的事——号码入库时有没有被统一过、查询条件有没有落在索引上、权限过滤插在了哪一步。

检索的难处从来不在「搜得快」,在「知道该搜什么」和「知道搜不到是谁的问题」。前者是产品设计,后者是工程素养。这两件事做对了,加不加机器就变成一个次要问题。

「助销云CRM」在这一层上的做法是把号码归一化、后四位索引、列表字段收敛这几件事做成默认配置,不留给每个项目自己决定。原因很实在:这些决定一旦留给现场,做对的概率不高,而做错之后,所有人只会说一句「这系统真慢」。