用户画像是以用户为中心,从不同角度抽取信息,抽象成标签。这些标签一般都会很多,针对不同的业务需求、应用场景会刻画不同的标签。可以通过标签来圈选适合的人群,来进行精准投放。广告系统、活动营销、内容推荐都可以是基于用户画像的应用。
这个和业务体量、业务流程、产品形态都有密切关系。
互联网行业的用户规模往往千万乃至上亿,那么可能各区域、各行业、各阶层、各年龄段都能覆盖,用户的天然属性就可以抽象的维度就会很多。再加上和业务相关的各种属性,比如用户的浏览行为、购买行为、售后行为等。最终产生出几十、上百个的标签都是很正常的。
这里的“标签”能够涵盖的数据形式可以多种多样。
性别:1-男/2-女,会员等级:1-金/2-银/3-铜,贡献度:1-高/2-中/3-低。
这些枚举类的属性是和标签概念天然相配的。
历史下单频次:35,历史下单金额:35000,历史客单价:1000,城市id:23,注册日期:20201212,末单日期:20201215。
如果非要去和标签这个概念较真的话,这些数值类的属性不能称之为标签,但是站在业务应用的角度,这些不定数据,通过一个区间范围的筛选,可以实现更灵活的人群圈选。下钻类也是同理。
某用户:
近90天、图书类、买过3次
近90天、家电类、买过1次
近30天、家电类、买过1次
这种数据并非是最细粒度的流水数据,是聚合后的较细粒度的购买行为,也是为了提供更精细化的圈选条件。
这样的数据和用户是一对多的关系,在es存储层可通过父子文档或者嵌套文档的方式组织起来,但为了提高搜索性能,优先选择嵌套文档。
这里从业务角度出发来看,可以总结出两种比较常见的应用需求。
主要讨论数据持久化存储层的选型。技术选型和很多因素都会有关联,自身业务的应用场景,企业内部的技术栈,物理资源分布情况等。我们这里主要讨论应用场景。
对于根据单个用户id,获取用户标签。要承接高并发流量的话,肯定是需要前置一层缓存的,一般用redis。重点在于持久化存储这一层应该如果选择。
用户标签的数据体现出以下几个特性。
如果用mysql存储:
对于既能满足单条数据查询性能、又适合大批量数据写入、又能便于列扩展的数据库,hbase是最适合了。
大批量的数据写入,无论用关系型数据库、还是nosql数据库。大量的磁盘io都是不可避免的,都会影响到整个数据库的性能稳定性。mysql不适合是因为,很多数据量不是那么大的业务、对数据一致性要求比较高的业务大都会基于mysql存储,因为技术成熟,开发便捷。这其中肯定会涉及到很多核心业务系统、以及核心业务接口。生产环境,如果为每个业务库或者每个业务系统单独配备一个mysql实例或者一个物理机,是不太现实的,往往都是多方混用。所以一旦某一个业务方向对于mysql的使用不当,势必会影响到其他方向的业务稳定性,导致某些核心功能波动或者异常。
根据标签查用户,就一定要关注查询性能,上一小节的应用场景里,我们已经确定了hbase是比较适合存储用户画像数据的,按照用户id设置rowkey,那么其单条查询性能也是有保障的。但是hbase默认是不支持根据列搜索的,虽然可以通过二级索引等技术手段实现列搜索功能,但开发成本较高且比较鸡肋(好像自己实现了一波mysql索引结构一样),也影响写入性能。所以对于标签组合查询场景,hbase不合适。
elasticsearch(下文部分地方简称为es)为搜索而生,既能应对复杂的组合搜索,还能应对需要相关性评分的全文检索。es可以很好的解决根据标签组合查询用户的需求。根据单用户id查标签,本质上也是搜索,只是条件更简单了而已。
由于hbase的前两个阶段之后,meta表是可以被客户端的缓存的,所以hbase的绝大部分请求都是一次网络往复,网络开销上要比es低。但是es的多余出来网络开销都是内网开销,单条数据请求的数据量不大,这部分开销不会造成特别大的性能差异(不至于相差到几十毫秒)。当然这里面还有很多其他的影响因素的,分片(es)数量、节点数量、hbase节点数量、内存设置的大小等,会影响到数据的分布、数据被内存的缓存的多少等情况,也会影响到节点内搜索性能。
应用服务的接口设计,为了应对高并发流量,肯定会前置一层redis缓存,那么就可以提前预热活跃用户数据到redis缓存,消化掉绝大部分流量,即使少部分请求穿透到es层面,对es的冲击也不会很大。
所以最终选择用elasticsearch作为用户画像的底层存储。当时的版本选择是5.6
基于elasticsearch的5.6版本。
{
"sex" : {
"type" : "integer",
"doc_values": false
}
}
该属性在mapping映射文件中的具体字段中定义。
doc_values是默认开启的,用于构建正排索引,正排索引在排序、聚合、地理位置信息过滤、和字段相关的脚本计算等场景是必须的。但对于根据条件搜索而言是非必要的。数据写入时,构建正排索引会消耗es较多处理能力,也会占用更多的存储空间。所以在对于单纯的只应用于搜索的索引,在大批量数据写入时,针对非排序字段把doc_values都设置为false。可以极大的提升写入性能和减少存储消耗。
我们的应用场景:用户标签有将近200个字段,排序字段只有用户id一个。禁用doc_values就十分必要。
### index.refresh_interval:1s
### index.refresh_interval:30s
index.refresh_interval:-1
es是近实时搜索。写入的数据到可以被搜索到是有一定的时间延迟的。该延迟默认是1s,指的是1s刷新一次内存缓冲区数据到文件缓存,让新写入的数据可以被搜索到。es的官方文档里关于性能调优有相关说明,当你的业务上可以忍受一定时间的数据延迟的话,可以将这个refresh_interval的值适当的调大一些。比如设置为30s。这样可以减少内存缓冲区数据刷新到文件缓存的频率(减少了段的数量),也可以减轻一些系统开销,提升数据写入的性能。
如果将refresh_interval设置为-1,就意味着关闭了自动refresh到文件缓存的步骤,前文说过如果数据只在内存缓冲区,没有刷新到文件缓存(不会产生新的段),那么是不能被搜索到的(文件缓存没有,且磁盘也没有)。如果关闭了内存缓冲区到文件缓存的自动刷新,数据还是会根据其他设置,当数据量达到一定阈值(日志达到512M)时或者flush的周期(默认30m),直接flush到磁盘的,所以数据也还是能被搜索到的,只不过这个时间延迟就不好预估了。而且最后一批写入的数据,如果没有达到flush的数据量阈值,那么被搜索到的延迟就是30m了。所以当数据批量导入完成后,手动恢复refresh_interval的值为1s或者其他合适的值,让内存缓冲区数据自动被刷到文件缓存,达到让数据可被搜索的效果。
该我的实际项目中,80G的数据定时批量写入到es中,多个任务并发写入,es每个节点每秒被写入的数据量在10M左右,在refresh_interval为-1和30s的设置下,整体的任务消耗时间(40分钟左右)和1s设置时相差不多。refresh_interval调高、可降低数据段的生成频率,不至于产生特别多的小段,可有助于提升段合并的性能,但是段合并是后台进程自动完成的。在共享资源充足的情况下,不会对前端写入和搜索产生太大影响。
index.number_of_replicas:0
number_of_replicas的默认值是1,默认每个分片有一个副本,以保证集群高可用。官方文档上有说明:
文档在复制的时候,整个文档内容都被发往副本节点,然后逐字的把索引过程重复一遍。这意味着每个副本也会执行分析、索引以及可能的合并过程。如果在做大批量数据导入,可以通过设置number_of_replicas为0,关闭副本。等数据导入完成后,在设置number_of_replicas为1(副本数根据实际情况决定)来开启副本,副本的恢复过程本质上只是一个字节到字节的网络传输。相比重复索引过程,这个算是相当高效的了。
这个手段,从实际项目中使用效果来看,性能提升十分明显。
es是以json结构存储数据,json结构是k-v键值对结构,一个正常的数据可能像下面这样
{
"user_id": 994118681,
"user_name": "范特西",
"register_time": 20201207,
"last_order_time": 0,
"frequency": 0,
"lifecycle": 1,
"contribution": 0,
"first_order_time": 0,
"monetary": 0,
"recency": 2,
"total_amount": 0,
"rfm": 0,
"status": 1,
...
}
这是一个很常规的用户标签的数据展现,有id、名称、状态、注册日期、订单日期、频次等信息。但是当你的数据达到几十个G甚至T级别的时候,而你的es机器资源有限,但受限于es的吞吐能力,写入时间也可能会很长,这时你就要考虑如果能够压缩写入数据量了。
通常用户标签类数据,大部分标签都是枚举集合类型,可以通过单位维护的数据字典将标签值解析为标签名称。枚举类的标签值往往都是很小的数值,就如我们样例数据一样,很多数据的值都是0,1,2这类。key是字符串,是描述具体的标签含义的,key的长度远大于值的长度,也就是我们的数据中,大部分的传输以及存储消耗都是在key上。如果能够把key精简,那么一条json文档的字符数量就可以大幅减少,上千万条的json文档节省下来的数据量会相当可观,数据精简后可以很大程度提升数据的网络传输性能和减少存储空间的使用。一中可能的优化思路是用更精简的字符传来代替key,例如:A1代替user_id,A2代替user_name,B1代替…等。当然肯定更需要你自己维护这样一套对应关系,在查询和写入的逻辑上应该都需要做一些改造。这个技术成本也是需要去评估的。
笔者的项目中采用过这种方案,从写入性能提升角度来讲,性能的提升和数据总量的压缩幅度基本能成正比。
{
"sex" : {
"type" : "integer",
"index": false,
"doc_values": false
}
}
上文已经介绍过doc_values。而index的作用是用来指定是否构建倒排索引的。es的存在就是为了搜索,所以每个字段(除了text类型)的index属性默认都是true,也就是默认会为每个字段构建倒排索引,用来提升查询性能。也正是因为这样,才让我们容易忽略到这个属性的存在,而如果你的某个项目中的某个索引的数据,只是根据主键查询(例如:用户的行为日志,就是根据用户id查询多条日志明细),不需要根据其他列来查询。那么就不需要为其他多余的列也构建倒排索引,可以设置index为false。同样可以减少es在数据写入时的性能消耗以及存储空间。
笔者的项目中采用过这种方案:有几个索引的数据是用户维度一些不同业务的下钻数据(行为数据、下单数据),需要通过一个数据聚合服务将这些数据汇总到一个索引,将这些数据以嵌套文档的形式关联到用户标签的主文档上。所以这些存储着一对多的下钻数据的索引本质上就是临时的中转数据,最终的业务是不会查询这些索引的数据的,而数据聚合任务从这些索引中查数据也都是根据一批用户id来查询,不会根据其他属性来查询,所以针对这些中间索引,只保留了用户id的倒排索引,其他列全部禁用了index和doc_values属性。最终,数据聚合任务的性能提升效果也比较明显。
最佳实践:提前通过api定义好mapping结构,要包含数据中的所有属性。
不要让es根据文档数据自动推导,因为自动推导出来的属性的index、doc_values都是默认开启的,可能会与我们再4)、5)中讨论的优化手段相悖。
用户画像的应用场景,都是多个标签条件组合,精确的查询一个或者一批用户。那就应该使用filter上下文。
{
"query": {
"bool": {
"filter": [ // 所有的查询都封装到filter上下文里
{...},
{...}
]
}
}
}
filter上下文,es会忽略相关性评分,还会尽可能的缓存查询结果,以提升性能。
使用es的search api,默认返回的结果里面包含的信息比较多。
{
"took": 1, // es内部的查询耗时,毫秒
"timed_out": false, // 查询是否超时
"_shards":{ // 和本次查询相关的分片信息
"total" : 1, // 一共涉及到几个分片
"successful" : 1, // 查询成功的分片个数
"skipped" : 0, // 跳过的(忽略的)分片个数
"failed" : 0 // 查询失败的分片个数
},
"hits":{ // 查询结果(命中结果)
"total" : 10, // 满足条件的文档个数
"hits" : [ // 满足条件的文档
{
"_index" : "user_tag", // 文档所属的索引
"_type" : "default", // 文档所属的类型
"_id" : "131", // 文档id
"_score": 0, // 评分(filter查询,该值所有文档都是一样的)
"_source" : { // 文档的数据(这里才是业务最关心的数据)
"user_id" : 131,
"frequency": 0,
"lifecycle": 1,
"contribution": 0,
"first_order_time": 0,
"monetary": 0,
"recency": 2,
"total_amount": 0,
....
}
}
... // 其他文档
]
}
}
针对于上面的返回结果,大部分情况下业务逻辑只关心:
"filter_path":"hits.total,hits.hits._source"
注意:filter_path的设置,是一个逗号分隔的字符串。属性名称要包含从顶层节点到自己的所有节点。
通过这个设置后,返回的结果就是下面的样子:
{
"hits":{ // 查询结果(命中结果)
"total" : 10, // 满足条件的文档个数
"hits" : [ // 满足条件的文档
{
"_source" : { // 文档的数据(这里才是业务最关心的数据)
"user_id" : 131,
...
}
}
{
"_source": {
...
}
}
... // 其他文档
]
}
}
通过filter_path可以精简返回结果,只保留核心的文档json数据,去除了无关的一些附加信息。
但是,以用户画像为例,业务逻辑上往往只关心用户的部分属性而非全部属性,甚至有时只关心返回的用户id。而返回的_source里包含着所有的属性。如果每个用户有上百个标签,而业务上只想要用户id,那么可以想象这其中浪费了多少数据传输成本。针对只想返回文档部分属性的场景。可以dsl中设置_source属性数组,指定要返回哪些属性。
{
"query" : {
...
},
"_source": ["user_id"]
}
这样的就可以得到如下的比较精简的返回结果
{
"hits":{
"total" : 10,
"hits" : [
{
"_source" : {
"user_id" : 131,
}
}
{
"_source": {
"user_id" : 132,
}
}
... // 其他文档
]
}
}
在上一项内容中介绍过,如果我们业务需求,只是根据各种标签组合来查询用户的id,那么我们最终需要的业务数据只有用户id一项而已。可以通过《"_source": [“user_id”]》这样的设置方式来精简返回结果。在es内部,每个文档都有一个元数据属性"_id",是文档的唯一标识,可以指定业务上的具有唯一特性的数值,也可以让es自动生成。es的官方讲,自动生成id的算法可以保证唯一,在写入时性能会比较好。如果是业务自定义的id(比如以用户id作为文档id),在新增文档时,都会去校验一下id的唯一性。在用户画像业务上,用户数据是一个持续递增的过程,新的用户注册,就应该会插入一条文档,用户信息变更,就应该修改对应文档。为了便于文档的修改,往往都会以业务上的唯一主键来作为文档的id。所以一条文档的数据信息类似如下:
{
"_index" : "user_tag",
"_type" : "default",
"_id" : "131", // 文档id,和用户id一致
"_score": 0,
"_source" : {
"user_id" : 131, // 用户id,和文档id一致。
....
}
}
看完上面的数据,再来回顾我们谈论的只返回用户id的需求。我们完全就可以不依赖_source的内容了,只需返回_id就可以了。要达到这样的效果,只需要通过类似下面的设置
"filter_path":"hits.total,hits.hits._id"
就可以得到如下的返回结果
{
"hits":{
"total" : 10,
"hits" : [
{
"_id" : 131
}
{
"_id": 132
}
... // 其他文档
]
}
}
hits里的数据结果要比3)中讨论的最后的返回结果还要精简。但是这只是第一步,返回内容的精简的确会节省带宽占用,减少网络IO,但如果每批次只返回几百上千条这样的数据,性能优势不会特别明显,起码不至于有几十或者上百毫秒的性能差异。
所以,接下来才是重点,es的查询分为两个阶段,query阶段、fetch阶段。
所有分片的文档数据获取成功后,在基于客户端的查询设置,统一打包返回给客户端。
关于filter_path,其实是在query、fetch两个阶段都完成后,最后一步打包阶段,把多余的信息排除掉了而已。所以当我们只需要文档id的时候,根本就不需要让es去执行fetch阶段的流程,因为query阶段满足条件的文档id就已经都得到了。
想要避免fetch阶段的流程,只需要在查询请求中,将_source置为false即可。
{
"query" : {
...
},
"_source": false
}
因数据体量、硬件性能、分页大小都存在差异,在不同的场景下,性能的提升幅度肯定会有差异,这个优化手段应该可以将响应性能缩减几十甚至上百毫秒。在笔者的项目中,单次请求2000条数据,响应时间可缩减200ms左右。
深分页这个问题无论是在传统的关系型数据库如mysql,还是我们现在讨论的es,都会面临性能问题。分页越深,性能越差。尽管性能会变差,但是传统的关系型数据库,也不会限制分页的深度。
但es会,超过他限定的深度,es会返回异常。
index.max_result_window : 10000
max_result_window默认值是10000,这个值的意思是分页查询时,from + size 的值如果超过 10000。 那么将查询失败。
这就意味着,如果你的某个查询,满足条件的超过10000个文档,那么你想用分页把这些文档循环获取出来,是不可能的。因为你最多只能分页取出来10000个。所以一个简单粗暴的方式,就是调大这个设置。但是es以及业内最佳实践其实都不建议这样做。es会建议用scroll api的方式来滚动获取所有的数据。
scroll就像是一个可以中途下车,但不能中途上车的列车。你一定是从头开始,但可以自由决定何时终止查询,哪怕数据还未取完。但是你不能从中间开始。它比较适合后台任务类型的、全量去取数场景,不适合前端交互类、跳页查询的场景。
当然除了scroll,还可以基于自己业务数据上的具有唯一性的数据字段,进行偏移查询(gt、lt),也可以实现全量取数。那么遗留下来的跳页查询怎么办,还是无法解决呀?
跳页查询,这个问题往往就是产品和业务层面要做一些折衷了。10000条的限制,20条一页,可以分500页。没有几个人会无聊到一直一页一页的翻500次,前几页如果没有他想要的数据,那么他的操作思路肯定就应该是增加搜索条件了。