
本文将大量用户和业务在使用多元索引时候遇到的问题总结为最佳实践提供给大家。索引字段类型多元索引中,不同类型字段的索引物理结构设计不同:Keyword 字段类型底层使用 FST+倒排链,因此在 Keyword 字段类型上做等值查询 TermQuery 速度快。Long 类型字段底层使用 BKD-tree,因此在 Long 类型上做范围查询 RangeQuery 速度快。虽然 Keyword 也支持范围查询,Long 也支持等值查询,但是性能会差很多,数据量越大性能差距越明显,因此在 Tablestore 的宽表(主表)中导数据时候要注意提前规划好字段类型。一些常见的容易用错的设计,说明如下:Type、Status 等枚举类型的值是0,1,2三种,虽然是数字,但是保存为字符串并索引为 Keyword 类型,对其进行 TermQuery 或 TermsQuery (类似 SQL 中 In 操作) 查询时候速度非常快。UserId 是一个长整形数字,但是 UserId 基本上都是等值查询 TermQuery,那么 UserId 需要用字符串存储。isDeleted 等状态,可能是0、1这种取值,需要保存为字符串或者 bool 类型。如果已经上线的业务使用错了,又不方便修改存量业务的数据类型,可以考虑使用 “虚拟列” + “动态修改 schema” 进行字段类型纠正来达到加速的目的,动态修改 schema 功能来提供对原有业务的平滑升级,虚拟列来提供字段类型纠正。主键设计Tablestore 的主表根据分区键进行 Range 范围分区,主键的设计会影响多元索引的同步速度和部分场景下的查询水平拓展。主键需要尽可能的离散,比如用 md5 进行哈希处理。一些常见的反例就是使用自增 id、当前时间戳进行当做分区键,可以参考 表格存储表设计 。如果需要在主表上根据主键前缀进行批量数据的拉取,可以进行一些特殊的主键设计,然后查询时候直接在主表上进行 GetRange 查询,可以快速拉取数据如果使用多元索引中经常使用某一个字段进行等值查询 TermQuery,例如淘宝 App 中每个查询 Query 基本都会携带 UserID=xxx,那么推荐将 UserId 放到主键中,具体优化详见本文“索引路由优化”这一章节。主键设计对索引同步速度有一定的影响,对主键的要求是写入要尽可能分散到主表的不同分区,因此如果写入 TPS 较高,需要尽可能分散在主表多个分区中,避免出现写单分区、写尾部分区等问题。多元索引目前仅支持从主表中异步写入到索引中,实时同步的架构升级还在设计中。错误例子:用户拼接“UserID+商品ID”当分区键,且商品ID是自增的,如果某个 UserID 是大用户,大用户写的 TPS 太高的话,会发生写尾部问题,永远写的是 Tablestore 主表的最后一个分区,这样写入对同步延时和查询都不太友好。可以考虑把商品ID进行 md5 处理,如果不需要在主表上根据 UserID 进行范围查询,那么分区键直接设置为 md5(商品ID),如果需要在主表上根据 UserID 进行批量查询,那么分区键就是 UserID+md5(商品ID)。索引预排序如果在多元索引中大多数查询 Query 都是按照某一个模式进行排序的,那么设置预排序能够节省一些排序时间。当命中的数据越多的情况下,预排序带来的性能优化效果会越好。默认情况下,多元索引按照主表的所有主键进行预排序。目前该功能仅支持使用 SDK 创建预排序的多元索引,参考:创建多元索引 。索引路由优化如果查询时候一定会带着某一个字段,例如淘宝 App 中每个查询 Query 基本都会携带 UserID=xxx,这时候就推荐使用路由优化。多元索引的数据分布默认情况下是根据主表的所有主键进行 Hash 分区,因此查询时候会访问索引引擎的所有分区,索引加上路由后,可以改变数据分布,根据路由键(而不是之前的所有主键)将数据进行 Hash 分区,因此一个确定的路由键一定会落在明确的一个或几个分区上。如何使用路由参考:多元索引路由字段的使用 ,使用路由 (Routing) 优化可以带来如下好处:显著减少长尾查询:没有使用路由前,每个查询都会访问索引引擎的每个分区,根据木桶效应,整体查询延时取决于最慢的那个分区,因此假如有一个分区有毛刺或者网络卡一下,会造成整体查询请求变慢。支持更高的 QPS 查询能力:携带路由的请求仅访问索引引擎的部分分区,不会访问所有分区,因此不会有读放大,对集群的资源消耗较少,可以支持更高的 QPS。带路由的多元索引设计合理的话,理论上没有规模上限。常见误区:用户 UserID 只有2个,但是表中有20亿行数据,那么意味着一个UserID 就有10亿数据,那么加路由后会导致索引分区过大,遇到这种情况可以联系多元索引研发进行评估和进行特殊方案处理。一般情况下,推荐路由键(如上述 UserID )的值要尽可能丰富,同一个路由键下的总数据要不要太多(如不要超过1亿),如果数据太多,可以考虑将多个不变的字段拼接为路由键。如果用户设置 UserID 为路由键,但是遇到了 Query 中不指定 UserID 的场景,那么查询时候会访问引擎里所有的分区,不影响查询结果的完整性。查询优化如果查询 Query 特别复杂,例如条件过多、嵌套太深、Terms 查询中的元素过多等,查询延时很可能会比较高,因此推荐用户精简 Query,没有必要的条件尽可能地去掉。除此之外,目前服务端会自动进行查询改写和查询优化,一般情况下不需要用户特别关注。如果发现查询延时高,可以联系多元索引研发进行查询优化。货币、价格之类的浮点数由于 Tablestore 只支持普通的 Double,暂时不支持 BigDecimal 类型,但是业务方涉及到金钱之类的字段需要非常精准,因此这类字段推荐用 Long 进行存储,例如:5元3角2分存为53200全文索引和模糊查询MatchQuery 和 MatchPhraseQuery 是专门为分词 Text 类型字段的全文索引场景设计的查询。在Keyword类型字段上,MatchQuery 和 TermQuery 可能查询结果一致,但是 MatchQuery 会多一些额外的分词处理流程,性能相对较差,因此不要在 Keyword 字段类型上误用 MatchQuery。对于通配符查询(WildcardQuery)中查询模式为 *word* 的场景,即任意子串查询需求,您可以使用模糊分词方式(模糊分词和短语匹配查询 MatchPhraseQuery 组合使用)来实现性能更好的模糊查询,具体参考 模糊查询。翻页和Token编码多元索引推荐使用 token 翻页方式 进行深度翻页,假如需要对 token (类型是byte[])进行持久化,可以使用 Base64 编码为字符串再进行存储。如果直接进行字符串编码,例如 new String(token) 会造成 token 内容丢失。逻辑字段和物理字段映射该章节主要解决“用户需要个性化的列名而多元索引支持的最多索引字段个数不够用”的问题。假如系统中有1k个用户,每个用户拥有个性化的列名。假如每个用户需要使用多元索引10个字段,那么总共需要10*1k=1万个字段,而当前多元索引不支持这么多字段,接下来使用“逻辑字段和物理字段映射”的思路来解决,让所有用户可以共享多元索引的一些字段。具体如下:索引设计:假设用户只需要 Keyword、Long 这2种数据类型,然后再多元索引中提前创建索引,这个索引中包含200个字段(不同类型的字段数量可以根据业务需要个性化设置)和其它必备的非个性化字段,索引中固定的字段名如下keyword_1,keyword_2,....,keyword_100long_1,long_2,...,long_100其它业务必要字段准备一个 meta 表,Tablestore 的 Table 表或其它数据库表都可以,这张表的内容如果量不大的话最好缓存在内存中,和上面的索引的关系如下:“用户1”有 Keyword 类型字段 a、b、c,那么在 meta 表中对“用户1”记录一行数据:字段a->索引keyword_1,字段b->索引keyword_2, 字段c->索引keyword_3,有 Long 类型字段类推。“用户2”有 Keyword 类型字段 b、c、d,那么在 meta 表中对“用户2”记录一行数据:字段b->索引keyword_1,字段c->索引keyword_2, 字段d->索引keyword_3,有 Long 类型字段类推。数据写入和查询需要根据 meta 表的映射来进行写入和查询。数据写入:根据 meta 映射表,将物理字段写入到逻辑列中数据查询:先根据 meta 映射表将用户查询字段进行转化,举例:“用户2”想查 b=4 && d=5,则转化为 keyword_1=4 && keyword_3=5。大批量导数据前准备第一次创建表格存储的 Table 后,在导数据之前,如果数据特别多,例如超过10亿行,可以联系研发进行 Table 的预分区,让导入速度更快。数据量太大的情况下,推荐先在主表导完数据再创建多元索引,可以显著提升存量数据的索引构建速度。分表问题多元索引目前推荐一个索引200亿行以内数据,但不是意味着最大是200亿行,如果超过200亿的话可以联系多元索引研发进行一起评估和设计。举个例子:某用户最大的 log 表当前61亿,一年增长21亿,3~5年内不超过200亿,因此可以不用分表。如果存量数据超过200亿或有潜在超过200亿的可能性,增长很快,可以考虑分表,具体设计可以联系多元索引研发一起评估和设计,同时可以避免数据量特别大的时候一些潜在问题。
当我们的应用服务遇到网络抖动、服务端进行分区分裂等问题时候,会出现请求超时或者失败。Tablestore的SDK里默认配置支持一些基本的重试逻辑,但是并不能满足所有业务,因此这里介绍一下常见语言的SDK中如何自定义进行重试。JAVA构造Client,传入指定的重试策略。ClientConfiguration clientConfiguration = new ClientConfiguration(); RetryStrategy retryStrategy = new AlwaysRetryStrategy(10, 1000); clientConfiguration.setRetryStrategy(retryStrategy); SyncClient client = new SyncClient("endpoint", "accessId", "accessKey", "instanceName", clientConfiguration);默认的重试策略,写入操作是不会进行重试的,如果写入也是幂等的,那么可以使用如上所示的AlwaysRetryStrategy重试策略,指定最大重试次数和最大重试间隔(重试间隔会指数增长到最大重试间隔)即可。当然还可以自定义重试策略:只需要实现RetryStrategy接口即可,自己通过判断action和Exception类型,可以实现哪些异常进行重试,哪些API可以进行重试,具体可以参考RetryStrategy的源码说明以及DefaultRetryStrategy中的实现逻辑GO设置网络相关的一些参数实现自定义重试函数。可以根据errorCode、errorMsg、action、httpStatus等信息判断要不要进行重试。在构造好client后设置该client的重试策略。// 设置网络相关的一些参数 var myConfig = &tablestore.TableStoreConfig{ RetryTimes: 10, HTTPTimeout: tablestore.HTTPTimeout{ ConnectionTimeout: time.Second * 15, RequestTimeout: time.Second * 60}, MaxRetryTime: time.Second * 10, MaxIdleConnections: 2000, } // 自定义重试函数 func MyRetryFunc(errorCode string, errorMsg string, action string, httpStatus int) bool { // 如果想要所有都重试,直接返回true if strings.Contains(errorCode, "OTSOperationConflict") || strings.Contains(action, "Search") || strings.Contains(action, "PutRow") { return true } return false } // 构造自己的client func newMyClient(endpoint string, instanceName string, accessKeyId string, accessKeySecret string) *tablestore.TableStoreClient { var myClient = tablestore.NewClientWithConfig(endpoint, instanceName, accessKeyId, accessKeySecret, "", myConfig) myClient.CustomizedRetryFunc = MyRetryFunc return myClient }
前言 本篇文章主要记录业务上的一个TermsQuery优化和分析的过程和一些思考。 在使用ES的时候,经常会遇到慢查询,这时候可以利用profile进行分析,当利用profile也查看不出什么端倪时候,可以尝试通过阅读代码查看查询为什么这么慢。如下是一个我们内部业务的一个慢查询,经常出现4s左右的延时,一模一样的查询,但是延时不一样,且很难复现。 { "from": 0, "size": 10, "query": { "bool": { "must": [ { "term": { "field_1": {"value": "a" }}}, { "terms": {"field_2": [ "a", "b", "c","f", "e", "f", "g", "h", "i" ]} }, { "range": { "time": {"from": 1, "to": 10}}} ] } }, "sort": [{ "index_sort_field": { "order": "asc"}} ], "track_total_hits": false } 优化和分析 当前索引单shard1亿多条数据,写入较少但是比较稳定,集群写入查询、cpu、io等资源都不紧张。且查询使用了index_sort,没有访问_source文件,没有访问total_hit,表面上已经没有了什么优化空间。 首先利用es的profile分析这个查询,并且请求中禁用了request_cache,但是并没有发现任何问题,查询响应速度很快,也没有任何的慢查询,因此通过profile去复现并分析这个问题,所以直接简单暴力的将shard进行扩容,降低单shard的容量。 1. shard扩容 当前单shard1亿的容量并不是很大,通过扩容将原来的30个shard扩容到了60个shard,效果还是十分明显的,偶尔的慢查询降低到了2s。 原理分析 之前单shard1亿的容量,shard扩容后变成了单shard5000万数据,那么单shard需要查询和计算的总量变少一倍,理论上确实可以降低一倍查询延时。 但是shard扩容也不能解决所有问题,比如当shard扩容到更多时候,效果并不是成倍的下降,特别是在节点不多时候,一个节点上放了更多的shard副本,反而增加了集群的各种负担,比如: shard变多造成refrensh、bulk、search等相关线程池压力可能更大单节点需要串行处理更多的meta信息shard越多越容易出现search的查询长尾问题:个别shard查询较慢,从而拖累整个查询造成整体查询响应变慢当shard超过256个后,查询会分为2个批次进行查询,查询延时会直接升高一倍 因此,这里并没有继续将shard扩容,而是想通过其他方式进行慢查询分析。 2. 复现问题 既然线上很稳定的隔几分钟出现一次慢查询,那么一定不是巧合和一些异常抖动,因此需要尝试其他方式来复现这个问题。 首先怀疑到cache让索引查询变得很快,所以查看了这个索引的queryCache命中率,大约接近80%的命中率,很显然我们需要降低cache命中率来复现这个问题。一般降低cache命中率的方式有: 提高写入吞吐。 当前写入比较少,几秒钟或者几分钟一条数据,因此造成很多数据都有缓存且没有失效。 提高查询qps 使用queryCache需要拿到读锁,如果并发很高导致查询线程拿不到锁,那么将利用不到缓存。 queryCache是LRU策略的cache,且cache结果很大概率被其它线程改写为新的造成cache失效,因此这里是一个读锁ES认为在高并发情况下,阻塞等待读锁的时间可能已经将查询执行完毕,因此没有必要一直阻塞等待读锁 原理分析 通过这两种策略,可以将慢查询很容易的复现出来,通过查看profile发现: 没有长尾问题terms查询十分耗时 问题已经定位到了TermsQuery,那么问题就变成了: 为什么TermsQuery比较慢?为什么有时候慢有时候快? TermsQuery是ES中的概念,它会转化为Lucene的TermInSetQuery。从下面的Lucene的代码注释中,我们可以看到: 需要构建DocIdSet的查询会比较慢term等点查特别快 TermInSetQuery要进行多个term查询,通常term查询是很快的,但是合并多个倒排链去构建DocIdSet会比较慢,最终导致TermIndexSetQuery很慢。TermInSetQuery合并倒排链使用的是DocIdSetBuilder,其流程和RoaringDocIdSet不一致,其中DocIdSetBuilder的流程为: RoaringDocIdSet.Builder中有很多更多的策略,相比DocIdSetBuilder更优一些,但是Terms查询的倒排链合并用不到这些优化: 在TermInSetQuery中,我们发现lucene还做了一个优化,当terms中term的个数小于16个时候,会将terms的查询转化为bool的should查询,直接合并倒排链可能比上面的构建bitSet会更快,可以用到RoaringDocIdSet的各种优化。这里也给了一些优化的灵感。 综上,整个TermInSetQuery中构建DocIdSet的核心查询流程流程为: 那么,为什么有时候慢有时候快呢,根据profile的第三个特征,基本上可以定位到是缓存问题,因此接下来我们来分析一下缓存问题。 3. 缓存分析 lucene中的具体cache策略管理在UsageTrackingQueryCachingPolicy中,具体的执行流程为: 是否允许缓存是一个比较复杂的问题,相关因素按顺序拆解为如下几个: Query的内部实现中,isCacheable函数可以决定是否允许缓存当前segment是否允许被缓存以及缓存,主要检查当前segment中的文件数量是否达到阈值是否可以拿到缓存的读写锁,并发高了拿不到锁则不能使用缓存IndexReader是否支持缓存,取决于IndexReader#getCoreCacheHelper的实现,如果返回null则不支持缓存QueryCachingPolicy中的策略 根据上面的一些判断标准,我们发现了2个需要注意的地方: 1. TermInSetQuery内部的isCacheable有什么特殊点吗? 当前我们使用的是TermInSetQuery,其内部的是否允许缓存策略有一些特殊之处,当terms中的term总size过大的时候不允许进行cache,当前阈值为1k,是一个比较小的值。lucene作者认为terms越长则代表着潜在有更多的term,那么其需要cache的内容可能很大,造成cache的浪费和内存溢出的风险。同时,我们分析terms的长度过长可能导致内容越不容易被下次利用到,因此这个角度考虑也有一定的道理。 线上的慢查询中,有大部分都是正好超过1k长度的,因此TermInSetQuery的缓存利用不起来,查询速度也就会变得很慢,这也是线上慢查询的一个原因之一。 2. UsageTrackingQueryCachingPolicy中哪些查询允许缓存? 根据如下shouldCache函数的代码可以得到: 不允许缓存的有:TermQuery,MatchAllDocsQuery,MatchNoDocsQuery,empty的复合查询耗时的查询需要最近(256次)执行过2次以上才可以缓存不耗时的查询需要最近执行过5次才可以查询,但是BooleanQuery和DisjunctionMaxQuery最近执行过4次就可以被缓存 4. 转化为shouldQuery TermInSetQuery的本质是多个term查询,一个term查询很快,多个term查询很慢,是因为需要将需要将多个term查询的倒排链求或集。我们线上的查询结构包含3个部分: 一个term,一个range,一个terms,最终需要将3个结果进行合并求交集。 测试发现,term包含结果200个,range包含100个,terms包含1亿个,并且已知我们线上的terms查询中的单词过多,且命中率很高,整个查询过程需要先求出terms查询的命中DocId集合,而求出这单shard命中1亿的DocId集合,速度肯定很慢很慢。求出这个DocId结合后,再和其它两个查询条件term、range进行结果合并,因此速度会特别慢。 这时候我们的优化方向变成了去掉terms查询,避免terms查询内部提前构造docIdSet,根据terms的语义,和shouldQuery比较相似,因此我们直接转化成为shouldQuery,并且设置minShouldMatch=1。通过这种改造,语义上并没有发生变化,但是可以避免1亿的docIdSet提前构建。 优化后,可以利用到Lucene的DocIdSet合并优化,会优先从最小长度的DocIdSet集合开始遍历,因此最大也就遍历100次,而之前的写法在构造terms的DocIdSet时候,需要先合并内部的倒排链,需要遍历1亿次,性能显然很差,而优化后节省的循环次数降低了100万倍。而之前的写法,只有在缓存命中时候才会有和现在一样的效果,特别依赖缓存,造成查询结果十分不稳定。 当查询中包含terms查询,且shouldQuery没有被利用时候,都可以采用类似的优化策略。当然,我们也可以修改Lucene的合并DocIdSet的策略,将该优化加入到其中。 5. 提高集群缓存 根据官方社区的建议,可以将集群的缓存相关参数调大,提高缓存的命中率。除了cache相关的大小优化外,上文中缓存分析中提高的TermInSetQuery的缓存相关参数也可以在适当的场景进行调账,提高缓存的使用率。 总结 本文主要通过profile查看可能潜在的问题原因,然后分析源码,查找缓存失效原理并提高缓存利用率,以及合理利用现有的Lucene的DocIdSet合并的优化,最终达到了查询延时下降数十倍的效果。 通过上述的分析,我们可以总结TermsQuery查询变慢的原因如下: cache失效Terms查询内部合并倒排链太慢 给出的优化建议如下: 提高缓存利用率 直接修改es层面的cache相关参数,如果有能力或者有特殊场景,还可以修改lucene层面的cache相关参数,包括LRU策略、TermsQuery的是否cache相关参数并发较大时候缓存会失效,对缓存依赖较高的场景可以通过 尽量去掉提前的无效倒排链合并 通过改写shouldQuery是一个有效的做法。如果嵌套复杂,可能不能通过该手段进行改写。这时候需要了解一些查询结果集合进行合并的知识,自己在写查询语句的时候,尽量先产生较小的结果集。 合理进行shard扩容 根据我们的经验,一般情况下一个shard维护5千万到2亿数据即可,过多或者过少都不是一个很好的选择。 参考 Lucene查询原理 https://zhuanlan.zhihu.com/p/35814539Lucene 7.2 禁用了TermInSetQuery的cache https://issues.apache.org/jira/browse/LUCENE-8058ES社区提高queryCacheSize参数 https://github.com/elastic/elasticsearch/pull/30655/filesLRU cache介绍 https://www.amazingkoala.com.cn/Lucene/Search/2019/0506/57.html
从最早的互联网高速发展、到移动互联网的爆发式增长,再到今天的产业互联网、物联网的快速崛起,各种各样新应用、新系统产生了众多订单类型的需求,比如购物订单、交流流水,外卖订单、支付账单、设备信息等。数据范围不仅越来越广,而且数据量越来越大,原有的经典架构方案已经很难满足当前新的业务场景。在新的需求下,对存储规模、开发效率、查询功能、未来扩展性等众多方面提出了更高的要求,要设计一款可靠稳定且扩展性好的系统不再是一件简单的事情,而是变得更加复杂,需要考虑的因素也越来越多。 需求分析 首先,我们来分析一下设计一个完整的订单系统需要考虑的因素有哪些,我们会从不同角度来阐述各种需求的关键点和作用。为了阐述的更加清晰,我们将众多需求分成了三种类型,分别是:基础需求、隐含需求和高级需求。其中“基础需求”是指要实现订单系统必须要考虑到的因素,也是大家最容易想到的点;“隐含需求”是指为了让架构更加优秀,需要考虑的一些更深层次的因素;“高级需求”是指为了满足业务未来更多可能性或者架构更加开放等可以考虑的可选因素。 基础需求 在设计一个优秀的订单系统时候,基础需求是需要优先考虑的,主要包括了“规模”、“功能”和“性能”等,如果这些基础需求无法满足,那么业务最简单的功能可能都无法实现。 规模 规模:是指订单系统中需要保存的订单条数。当我们预测规模的时候,不能以当前的订单规模来预估,而应该以未来一年到三年的规模来预估。 如果规模预估偏少,那么后面可能很快就会发现当前系统不能承载,这个时候就需要重新选择数据库,这里要特别注意的是,当因为规模选择了新的数据库后,可能原有的整个订单系统都需要推翻重新设计,这个代价是非常大的,所以,“规模”是最最重要的考量因素,具有翻天覆地的能力,一定要慎重选择,能激进尽量激进,尽量不要保守。 功能 功能:是指在订单系统中需要存储系统或数据库需要具备的能力。这些能力主要分为两部分,一部分是“写入能力”,另一部分是“查询能力”。“写入能力”最主要的就是单行原子性,而“查询能力”的要求会更加丰富,比如: 通过订单号查询特定订单。通过用户名和时间范围查询一批订单。通过商品信息查询订单。通过商品类目、买家地域统计订单数。通过部分商品名查询相关商品的订单。通过买家或卖家或某个商品统计月消费额度。...... 这些功能如果转换为数据库的功能,则是: 主键查询。非主键列的自由组合查询。排序。模糊查询。全文检索。翻页或跳页。统计聚合:count、max、sum、groupby等。 上述功能集合基本可以覆盖所有的查询需求,我们实现的订单系统的功能需求一般都在这些里面。 性能 性能:是指写入和查询时的耗时情况。性能要分“查询性能”和“写入性能”,这里我们分别进行讨论: 写入性能:大多数的订单系统规模较大是靠长时间的累积,因此写入一般不会有瓶颈。但是如果在较短时间内系统有大量订单写入,这时候就要优先考虑写入性能。比如双十一零点订单、中午12点左右外卖订单等场景。查询性能:订单系统的请求可以分为两类:OLTP 和 OLAP,其中核心是OLTP类请求,这类请求查询结果需要在毫秒内返回。在订单存储量较小的时候,性能问题不会突显出来,但是随着规模的增长,查询性能可能会越来越差,最终影响客户使用,所以这里要特别注意的时候,在规模增大以后的查询性能是否可以保持稳定。其次,有时候会有些特别大的客户,这些大客户会导致数据存在倾斜或热点,这些大客户的请求就会成为慢请求,这些请求的耗时情况也需要特别注意,如果太慢会严重影响客户体验,甚至面临流失大客户的风险。 隐含需求 考虑完“基础需求”后,一个订单系统最主要的轮廓应该已经出来了,可能也能满足当前的业务需求了,但是这个系统上线后是否可以稳定运行?遇到大促是否可以快速扩展?这里面临的就是一个从“60分”到“80分”的架构质量提升问题。要让系统架构达到“80分”,主要是需要考虑以下一些“隐含需求”:可靠性、可用性、扩展能力、低成本等。 可靠性 可靠性是指数据的可靠性,也就是数据不丢失能力,一般通过百分比来表示,比如99.99%,简称为4个9。4个9一般是关系型数据库的可靠性能力,而基于分布式文件系统的分布式系统的可靠性最高可以达到11个9,也就是保证数据不丢的能力大幅增强。 订单系统中存储的海量订单很多时候是一个企业核心资产,这部分数据的可靠性很重要,选择存储系统或数据库的时候最好能考虑到这一点。 可用性 可用性是指系统服务的可用能力,比如多长时间系统稳定,一般也是用百分比来表示,比如99.9%,简称3个9的可用性。为了保障上层业务系统的稳定性,依赖的数据库的可用性越高越好。但是当前可用性的提高一般都是通过系统冗余的方式来实现,比如“一主多备”“双集群”等,这样可用性越高的同时,成本也会越高。所以,当选择可用性的时候,需要根据业务特征和成本一起来权衡考虑。 扩展能力 随着业务的发展和时间推移,业务数据会越来越多,业务请求量会继续增长,如果遇到业务大爆发,那么数据和请求量更会快速爆发式增长,这些增长再给业务带来喜悦的同时,会给系统带来更大的压力。 这里需要考虑的扩展能力主要有两类,一类是存储部分,一类是写入和查询的请求数。这两个扩展能力如果没处理好,可能一年,甚至几个月后就需要推翻现有架构,重新选型数据库,然后再重新设计系统。如果一开始就选择一款可以更容易扩展的系统,那么后续就不会被这个问题限制系统能力。所以,扩展能力也是一个很重要的因素,最好需要在设计的时候就考虑周全。 成本 成本有多个角度需要考虑,包括从其他系统迁移过来的迁移成本、运维成本、硬件成本等,包含了从系统开始建设到最后稳定运行的各个阶段。 迁移成本:系统迁移可能发生在云上与云下的相互迁移,也可能发生在系统A不满足要求需要迁移到系统B,因此我们选择的系统组件要提供丰富的迁移手段,方便大规模订单系统进行前期准备。运维成本:高可靠、高可用、弹性等都可以节省运维成本,如果选用云上服务的话,Severless 全托管的云服务是一个不错的选择,避免处理CPU 打满、坏盘、网络故障等各种问题,同时按量付费,随时弹性扩容缩容,可以节省大量运维开销,这样就可以将资源全部投入研发。硬件成本:如果使用了云服务,则为云服务的价格。我们期望价格要尽可能低,因为订单量会越来越大,不能导致最后价格太高而承受不起,导致频繁改变系统架构。 高级需求 订单系统满足基础需求和隐含需求后,已经能够构建出健壮高效的大规模订单系统。随着用户需求越来越丰富,对系统要求越来越高,订单系统可能会在原有的 OLTP 类请求之上增加 OLAP的分析需求,这时候就需要具有一定的计算能力并能够完成一些实时计算和批量计算需求。因此这里列出两个最常见的高级需求:同时支持TP/AP、丰富的计算生态。 同时支持TP/AP 订单系统在最开始的时候可能都是简单查询请求(TP请求),随着业务量发展,可能会出现一些偏分析类型的请求(AP请求),但是 AP 类型请求对资源消耗可能会很大,一个简单的AP请求可能就会消耗完系统资源,从而影响现有的 TP 类线上业务请求,因此需要TP/AP进行隔离。 为了达到隔离,一般会引入一个ETL系统将TP数据复制一份给AP使用,这样会带来更高的维护成本和整合成本。因此最好选择能同时支持TP/AP请求的数据存储组件,一个实例就可以完成TP/AP两种请求,避免提升架构的复杂性。 丰富的计算生态 部分订单系统需要依靠计算来实现更丰富的功能,比如订单信息补全、稽核、刷单检测、用户行为分析等。目前计算主要分为批量计算和流计算,因此设计的订单系统要有能力对接 Spark、Flink 等流批计算引擎,通过计算引擎完成更多的用户需求。 上面我们介绍了在设计一个订单系统时候,需要考虑的多种因素,接下来,我们看一下订单类系统的架构演进。 架构演进 在订单增长迅速、需求愈加复杂的背景下,大规模订单系统的架构经过了多轮的演进,从单一的MySQL到结合各种NoSQL的架构方案,慢慢解决了各方面的问题来满足上一章提到的需求,接下来我们看一下订单系统的架构是如何演进的。 小规模 在订单系统早期,企业处于刚开始发展的阶段,订单量和查询量都不大,很多方案都可以满足需求。为了加快应用上线和版本迭代速度,很多订单系统都没有考虑后续的可扩展性,直接采用了单MySQL数据库这种集中式的架构方案。MySQL这一类数据库的优势十分明显,主要是支持SQL、事务。SQL结合事务的ACID特性,可以方便的完成业务逻辑并提供一些查询能力,同时SQL比较通用且容易迁移到同类产品中,这种架构也是订单架构中精简模型。 大规模 随着系统中的订单量和查询量迅速增长,系统规模从小规模阶段进入了大规模阶段,MySQL这类数据库的劣势逐渐凸显出来,用户需求变得更多且更复杂,订单系统也越来越难以维护,其中最主要的问题是性能问题和容量问题。 MySQL分库分表 容量和性能的单机瓶颈问题是伴随业务增长一直存在的,隔一段时间就需要提升MySQL实例规格进行MySQL扩容。基于MySQL解决单机瓶颈的方案是采用分库分表,将单个库的压力分散到多个库上,可以暂时解决容量瓶颈问题和部分查询性能问题,但是过一段时间最终还是会遇到当前分库分表量不够,需要重新水平切分的场景,然后会伴随数据迁移等繁琐的后续工作,升级压力十分巨大。而且分库分表会和上层的业务逻辑强耦合,带来更大的后续架构升级难度。 MySQL 分库分表+ Elasticsearch 为了解决 MySQL 在复杂查询场景下的查询性能瓶颈,一般的订单系统会引入 Elasticsearch 或者 Solr 等搜索引擎作为查询引擎,用来做查询加速。该架构选型通过 Elasticsearch 弥补了 MySQL 的查询短板问题,提供了一些更优秀的功能实现,比如更快的模糊查询,更好用的多字段自由组合查询,更丰富的统计接口和很强大的全文检索等等。该架构模式下,主要的业务逻辑还是通过 MySQL 完成,确实解决了查询的痛点,但同时暴露了一个新的问题,用户需要自己维护数据同步服务,保证两部分数据的一致性,因此,这里需要考虑如何完成数据同步。 常见的同步方案是监听 MySQL 的 binlog 异步写数据到 Elasticsearch 中,同步过程中为了解决消费乱序、消费失败、数据不一致等问题,引入了 Canal、Kafka 等中间件来,还需要自己定制化地开发同步组件。 该架构确实解决了查询的短板问题,为用户带来了优秀的查询体验,但还是有些问题没有解决: 当数据量和业务量翻倍后,容量和性能又再次遇到瓶颈。要解决新的单机瓶颈,这时候需要重新分库分表,带来的体验很差。或者是寻找一款可以横向扩展的数据库组件,不用考虑单机瓶颈,因此无需进行分库分表,从根本上解决单机瓶颈问题。该架构引入的数据同步链路较长,开发复杂、运维成本极高,一旦出现丢数据的问题,处理起来比较麻烦。Elasticsearch 需要进行运维和优化,即便是云上托管的 Elasticsearch 服务也需要进行运维和优化,因此这里也会存在很大一部分开销成本。 无分库分表 分库分表可以分散单库单表的压力,但是需要预先规划好容量,一旦触发再次扩容,需要重新进行数据 hash 迁移,代价较高,我们期望寻找一种无分库分表的解决方案。 MySQL + Tablestore 表格存储(Tablestore)是阿里云自研的结构化数据存储服务,提供海量结构化数据存储以及快速的查询和分析服务。表格存储的分布式存储和强大的索引引擎能够支持 PB 级存储、千万 TPS 以及毫秒级延迟的服务能力。 采用 MySQL+Tablestore 的架构方案后,上述架构遇到的各种问题都能够解决,包括MySQL查询瓶颈和容量问题、数据同步的开发成本问题、数据一致性问题、组件过多带来的维护复杂问题等等。该架构简单清晰,两个系统能力互补,可以发挥各自系统最大的优势,且成本最低、性能最好、扩展性最强,可以支撑X10,X100的业务增长。 该架构依赖 MySQL 和 Tablestore 作为核心组件,MySQL 完成业务逻辑相关任务,且数据根据需求仅保留最近0.5 ~ 3个月,不会触发 MySQL 的各项瓶颈,因此无需再分库分表。Tablestore 作为订单数据中台完成订单查询、分析等一系列功能。该架构方案能够满足我们在需求分析中提到的各项需求,总结如下: 千亿级的数据存储能力。MySQL仅仅存储近一段时间的数据,Tablestore可以无限水平扩展,因此该架构方案的存储能力和容量都不是问题。成本。Tablestore是采用存储计算分离架构,底层基于飞天盘古分布式文件系统,实现存储和计算成本分离,因此相对传统的MySQL等数据库成本要低很多。查询功能及其完整度。Tablestore具有丰富的查询方式,包括基于主表的主键查询、主键范围查询,基于多元索引的多字段自由组合查询、模糊查询、统计聚合、全文检索、地理位置查询等。因为订单查询场景丰富,不同的查询场景需要不同类型的索引。Tablestore 提供多元化的索引来满足不同类型场景下的数据查询需求,其多元索引基于倒排索引和列式存储,可以应对大规模数据和复杂查询场景下的各项查询难题。系统可用性和扩展能力。Tablestore 是一个 Serverless 服务化的产品,全托管,零运维。在大规模订单系统里,偶尔需要定期的大规模数据导入,来自在线数据库或者是来自离线计算引擎,此时需要有足够的计算能力能接纳高吞吐的写入,而平时可能仅需要比较小的计算能力,计算资源要足够的弹性。在订单量突增的情况下,Tablestore 能够自动水平扩容,用户无感知。Tablestore 的 Serverless 化让用户体验更好,做到开箱即用,弹性扩容,按量付费。 计算生态。企业在业务刚开始发展阶段,一般是不需要流计算和批计算的,只需要一个能处理业务逻辑、一个查询性能足够的订单系统即可,仅依赖 MySQL+Tablestore 即可满足大部分需求。当然,Tablestore 拥有丰富的计算生态,积极的拥抱开源,除了比较好的支持阿里云自研计算引擎如 MaxCompute 和 DataLakeAnalytics的计算对接,也能支持 Flink 和 Spark 等主流计算引擎的计算需求,无需数据搬迁。 基于MySQL+Tablestore的架构方案在阿里内部多个业务部门以及外部公司得到应用,使得订单查询和计算变得更加简单。阿里内部某百亿~千亿级订单系统采用基于Tablestore的设计后,效果改善十分明显: 存储瓶颈。之前MySQL容量告急,频繁扩容。采用基于 Tablestore 的方案后解决了大规模订单的容量问题,未来都不需要考虑扩容的问题。查询性能。慢查询接口平均耗时从 7 秒左右降低到 100 毫秒。成本。跟之前 MySQL 费用相比,成本下降7倍以上。计算。采用基于 Tablestore 作为数据仓库和计算引擎进行对接后,之前的定时批量计算可以采用流计算,实时性更高,同时批量计算拉取速度更快,离线计算的数据可见性周期更短。 Tablestore MySQL+Tablestore 的方案是一个相对完善的架构方案,如果不是强依赖MySQL,比如没有遗留系统必须依赖SQL等限制外,那么该架构还有精简的余地。去掉MySQL后,将仅仅依赖Tablestore完成订单系统相关的所有需求,系统的架构复杂度更低。 仅基于 Tablestore 的架构方案,是需要牺牲一些MySQL的能力,比如连表查询(join)、复杂事务、SQL语句支持等。 在 Tablestore上,一般是宽表的形式来存储订单,尽量避免 join。同时 Tablestore 支持分区键级别的事务,能满足一些简单的事务需求。关于 SQL 语句,大部分 SQL 都可以转化成 Tablestore 的 API。如果能够接受这些方面的不足,仅使用 Tablestore 将能够极大的降低架构复杂度,同时能够使用 Tablestore 更多的优势,包括大规模数据存储和扩展能力、较低的成本、完整的查询能力、高可用性、实时扩展能力、完善的计算生态等等。 总结 本篇文章主要探讨了大规模订单系统的需求和架构设计,从单 MySQL 到结合 NoSQL 弥补短板,到最后的MySQL+Tablestore,解决了容量、成本、查询性能、扩展、计算生态等各方面的问题,让大规模订单系统更加简单易实现。 除了订单外,其他类似订单的系统,比如物联网传感器数据、监控数据、日志、指令数据、通知消息等等也都可以采用订单系统类似的架构方案。 之后我们将会详细介绍大规模订单系统的一些实现细节。 存储篇:详细介绍基于 MySQL+Tablestore的大规模订单系统的架构实现细节。计算篇:详细介绍 Tablestore 作为数据中台的计算生态,以及基于计算系统如何完成大规模订单系统中的一些特殊需求。 最后,欢迎加入我们的钉钉公开群(钉钉号:23307953),与我们一起探讨大规模订单系统的一些实现问题。 相关文章引用 数据中台之结构化大数据存储设计结构化大数据分析平台设计
Elasticsearch是一款优秀的开源企业级搜索引擎,其查询接口主要为Search接口,提供了丰富的各类查询、排序、统计聚合等功能。本文将要介绍的是另一个查询接口SearchScroll,同时介绍一下我们在这方面做的一些性能和稳定性等方面的优化工作。 Elasticsearch的SearchScroll接口可用于从索引中检索大量数据,或者是所有的数据,值得注意的是Elasticsearch的SearchScroll请求不是为了用户进行实时请求,而是为了更快导出大量数据。同时该接口提供稳定的查询结果,不会因为用户一直在更新数据导致查询结果集合重复或缺失。典型场景如索引重建、将符合某一个条件的所有的数据全部导出来然后交给计算平台进行分析处理。SearchScroll支持多slice进行请求,在客户端以多并发的方式进行查询,导出速度可以更快。 为什么需要SearchScroll Search接口的功能已经足够丰富,那么为什么还需要SearchScroll?原因就是Search接口的速度不够快和结果不够稳定。 from+size Search接口进行翻页的方式主要有两种,一是size+from的翻页方式,这种翻页方式存在很大的性能瓶颈,时间复杂度O(n),空间复杂度O(n)。其每次查询都需要从第1页翻到第n页,但是只有第n页的数据需要返回给用户。那么之前n-1页都是做的无用功。如果翻的更深,那么消耗的系统资源更是翻倍增长,很容易出现OOM,系统各项指标出现异常。举个例子,假设每个文档在协调节点进行merge的ScoreDoc需要16字节,那么翻到一亿条时候,需要1.6G的内存,如果多来几个并发,普通用户的计算机根本扛不住这么大的内存开销。因此,很多产品在功能上直接禁止用户深度翻页来避免这种技术难题。 SearchAfter Search接口另一种翻页方式是SearchAfter,时间复杂度O(n),空间复杂度O(1)。SearchAfter是一种动态指针的技术,每次查询都会携带上一次的排序值,这样下次取结果只需要从上次的位点继续扫数据,前提条件也是该字段是数值类型且设置了docValue。举个例子,假设"val_1"是数值类型的字段,然后使用Search接口查询时候添加Sort("val_1"),那么response中可以拿到最后一条数据的"val_1"的值,,也就是response中sort字段的值,然后下次查询将该值放在query中的searchAfter参数中,下次查询就可以在上一次结果之后继续查询,如此反复,最后可以翻页很深,内存消耗相比size+from的方式降低了数倍。该方式效果类似于我们直接在bool查询中主动加一个rangeFilter,可以达到类似的效果。表面看这种方案能将查询速度降到O(1)的复杂度,实际上其内部还是会扫sort字段的docValue,翻页越深,则扫docValve越多,因此复杂度和翻页深度成正比,越往后查询越慢,但是相比size+from的方式,至少可以完成深度翻页的任务,不至于OOM,速度勉强可以接受。SearchAfter的翻页方式在性能上有了质的提升,但是其限制了用户只能一页一页往后翻,无法跳页,因此很多产品在功能设计时候是不允许跳页的,只能一页一页往后翻,也是有一定的技术原因的。 SearchScroll Search接口在使用SearchAfter后,相比size+from的翻页方式,翻页性能有质的提升,但是和SearchScroll相比,性能逊色很多,用户需要获取的数据越多,翻的越深,则差别越大。 在查询性能上,SearchScroll的翻页方式,时间复杂度O(1),空间复杂度O(1)。SearchScroll能够以恒定的速度翻页获取完所有数据,而采用SearchAfter的方式获取数据会随翻页深度增大而吞吐能力大幅下降。在我们的单机单shard2亿数据测试中,采用SearchScroll方式能够以每次50ms延时稳定获取完2亿数据,而SearchAfter深度翻页到千万级条数据后查询延时就到了秒级别,查询速度线性下降。 在吞吐能力上,SearchScroll请求天然支持多并发方式查询,因此SearchScroll特别适合批量快速拉取大量数据,然后交给spark等计算平台进行后续数据分析处理。在Elasticsearch中把每个并发称之为一个Slice(分片),Elasticsearch内部对用户的请求进行分片,分片越多则速度越快,拉取数据的速度翻倍提高。当然之前的普通的Search查询方式也可以并发访问,但是需要用户将Search请求的query进行拆分,比如原来是获取1年的数据,那么可以将query拆分为12个,一个月一个请求,体现在查询语句里就是将月份条件添加到query语句中的filter中来保证仅返回某一个月的数据。Search查询通过拆分query有时候可以达到类似的并发效果,来加速Search查询,但是有些query语句是难以拆分的,使用成本较高,因此直接利用SearchScroll让Elasticsearch帮助我们进行并发拆分是一个不错的选择。 在结果稳定性上,SearchScroll由于会“打snapshot”,context会保留目前的segments,后续写入的数据都是感知不到的,因此不会造成查到的结果中存在重复数据或者缺失数据。在批量导数据等要求结果稳定的场景下,SearchScroll特别适用。从另一个角度讲,对需要稳定结果的用户来说是件好事,但是会导致该部分segments暂时无法被merge,也会占用一些操作系统的文件句柄,因此需要留意系统的这些方面的指标,确保Elasticsearch系统稳定运行。 总之,SearchScroll的查询速度很快,吞吐能力很高,结果很稳定。 原理剖析 本节主要简单介绍SearchScroll的流程和SearchScroll的并发原理。 流程解读 使用SearchScroll功能,用户的请求主要分为两个阶段,我们将第一阶段称之为Search阶段,第二阶段称之为Scroll阶段。如下图所示。 其中第一阶段和传统的Search请求流程几乎一致,在Search流程的基础上进行了一些额外的特殊处理,比如Slice并发处理、Context上下文保留、Response中返回scroll_id、记录本次的游标地址方便下一次scroll请求继续获取数据等等。 第二阶段Scroll请求则大大简化,Search中的许多流程都不要再次进行,仅需要执行query、fetch、response三个阶段。而完整的search请求包含rewrite、can_match、dfs、query、fetch、dfs_query、expand、response等复杂的流程,因此其在es的代码实现中也没有严格遵循上述的流程流转的框架,也没有SearchPhaseContext等context实现。 Search阶段 第一个阶段是Search的流程,其中在 Elasticsearch内核解析 - 查询篇 有详细的介绍。这里按照查询流程,仅介绍一些不同的地方。 CreateContext 创建SearchContex后,如果是scroll请求,则在searchContext中设置ScrollContext。ScrollContext中主要包含context的有效时间、上一次访问了哪个文档lastEmittedDoc(即游标位置)等信息。具体如下: private Map<String, Object> context = null; public long totalHits = -1; public float maxScore; public ScoreDoc lastEmittedDoc; public Scroll scroll; queryPhase.preProcess中会处理sliceFilter,判断该slice请求到达哪个shard。这里是进行slice并发请求核心处理逻辑,简单来说根据slice的id和shard_id是否匹配来判断是否在本shard上进行请求。然后将query进行重写,将用户原有的query放入到boolQuery的must中,slice构建出的filter放入boolQuery的filter中。 SearchScroll通过SearchContext保留上下文。每个context都有一个id,它是单机原子自增的,后续如果还需要使用则可以根据id拿到该context。context会自动清理,默认5分钟的keepAlive,新来的请求会刷新keepAlive,或者通过clearScroll来主动清除该context。 LoadOrExecuteQueryPhase SearchScroll请求结果永远不会被cache,判断条件很简单,如果请求中携带了scroll参数,这一步会直接跳过。 QueryPhase.execute 该步骤为search查询的核心逻辑,search请求携带scroll和不携带scroll在这里几乎是一模一样的,具体参考上述链接的文章介绍。 FetchSearchPhase fetch阶段,需要将query阶段返回的doc_id进行fetch其doc内容。如果是scroll类型的search请求,则需要buildScrollId,scrollid中保存了一个数组,每个元素包含2个值: nodeid,下次请求知道上一次请求在哪个shard上进行的。RequestId(ContextId),找到上一次请求对应的searchContext,方便进行下一次请求。 fetch结束的时候,需要将本次请求发给用户的最后一个元素的排序字段的值的大小保留下来,这个值是哪个字段取决于search请求中的sort设置了什么值。elasticsearch推荐使用_doc进行排序,这样性能最好。当获取到最后一个文档后,需要更新到searchContext中的ScrollContext的lastEmittedDoc值,这样下次请求就知道从哪里开始进行搜索了。 小结 总结一下Search和Scroll的核心区别,主要是在query阶段需要处理并发的scroll请求(slice),fetch阶段需要得到本次返回给用户的最后一个文档lastEmittedDoc,然后告知data节点的context,这样下次请求就可以继续从上一个记录点进行搜索。 Scroll阶段 该阶段是在elasticsearch中是通过调用SearchScrollRequest发起请求,其参数主要有两个: scroll_id,方便在data节点上找到对应的context,继续上一次的请求。scroll失效时间,即刷新context的aliveTime,aliveTime过后该context失效。这个参数一般使用不多,使用默认值即可。 该阶段从api层面来看已经区别很大,一个是SearchRequest,另一个是SearchScrollRequest。search的流程上面主要是分析了一些不同的地方,接下来讲一下scroll的流程,只有query、fetch、response三个phase,其中response仅仅是拼装和返回数据,这里略过。 query 在协调节点上,将scroll_id进行parse,得到本次请求的目标shard和对应shard上的searchContext的id,将这两个参数通过InternalScrollSearchRequest请求转发到data节点上。在data节点上,从内存中获取到对应的searchContext,即获取到了用户原来的query和上次游标信息lastEmittedDoc。然后再执行QueryPhase.execute时,会将query进行改写,如下代码所示。改写后将lastEmittedDoc放入boolQuery的filter中,这就是为什么scroll请求可以知道下次请求的数据应该从哪里开始。并且这个MinDocQuery的性能是比传统的rangeQuery要快很多的,它仅仅匹配 >=after.doc + 1的文档,可以直接跳过很多无效的扫描。 final ScoreDoc after = scrollContext.lastEmittedDoc; if (after != null) { BooleanQuery bq = new BooleanQuery.Builder() .add(query, BooleanClause.Occur.MUST) .add(new MinDocQuery(after.doc + 1), BooleanClause.Occur.FILTER) .build(); query = bq; } fetch 在协调节点上,将各个shard返回的数据进行排序,然后将用户想要的size个数据进行fetch,这些数据同样需要得到lastEmittedDoc, 与Search阶段一致,都是通过ShardFetchRequest告知data节点上searchContext本次的lastEmittedDoc,并更新在context中供下次查询使用。在data节点上,如果传入的request.lastEmittedDoc不为空,则更新searchContext中的lastEmittedDoc。 SearchScroll的并发原理介绍 SearchScroll天然支持基于shard的并发查询,而Search接口想要支持并发查询,需要将query进行拆分,虽然也能进行并发查询,但是其背后浪费的集群资源相对较多。 首先从API使用方式上介绍SearchScroll的并发,我们用一个简单的例子做说明。Slice参数是SearchScroll控制并发切分的参数,id、max是其最主要的两个参数,id取值为[0,max),max取值没有特别的限制,一般不超过1024,但是推荐max取值为小于等于索引shard的个数。id、max两个参数决定了后续在data节点如何检索数据。 GET /bar/_search?scroll=1m { "slice": { "id": 0, "max": 128 }, "query": { "match" : { "title" : "foo" } } } SearchScroll并发获取数据只需要我们多个线程调用Elasticsearch的接口即可,然后请求到达data节点后,开始处理slice,如果该slice不应该查询本shard,则直接返回一个MatchNoDocsQuery这样的filter,然后本shard上的查询会迅速得到执行。如果并发数等于shard数,就相当于一个并发真实的查询了一个shard。而用Search接口拆query后进行并发查询,每个并发还是会访问所有的shard在所有数据上进行查询,浪费集群的资源。 SearchScroll如何判定一个slice是否应该查询一个节点上的shard,只需要进行简单的hash值判断即可。有4个参数id、max、shardID、numShards(索引shard个数)决定了是否会进行MatchNoDocsQuery,具体规则如下: 当max>=numShards,如果 id%numShards!=shardID,则返回MatchNoDocsQuery当max<numShards,如果 id!=shardId%max,则返回MatchNoDocsQuery 为什么推荐SearchScroll的max取值小于等于索引shard个数?简单说明就是并发数大于索引shard数后,需要将一个shard切分为多份来给多个slice使用,而切分单个shard是需要消耗一些资源的,会造成首次查询较慢,且有内存溢出风险。 首先看一下slice是如何切分shard的,规则如下: numShards=1 直接TermsSliceQuery切分,单个shard的slice_id就是TermsSliceQuery请求的slice_id,单个shard内如何切分见下方介绍。 max<=numShards 一个slice对应numShards/max个完整shard max>numShards 靠前的单个shard被分为(max/numShards + 1)份,后面的被分为(max/numShards)份例如: 5shard 8个slice,则 shard0->slice0、5shard1->slice1、6shard2->slice2、7shard3->slice3shard4->slice4 单shard内slice是根据slice.field参数来切分的,推荐使用_id或者_uid来进行切分,_uid也是该参数的默认值。其它支持DocValue的number类型的field都可以进行切分。 根据_uid字段进行切分,则使用TermsSliceQuery进行切分 这个filter是O(N*M),其中N是term的枚举数量,M是每个term出现的平均次数。每个segment会生成一个DocIdSet 首轮Search请求由于score没有cache,需要真正的去遍历拿docid,因此执行较慢。针对每个segment,遍历term dictionary,计算每个term的hashCode, Math.floorMod(hashCode, slice_max) == slice_id 来决定是否放入到DocIdSet。 计算hash值的函数:StringHelper.murmurhash3_x86_32 其它DocValue数值类型字段进行切分,则使用DocValuesSliceQuery进行切分 DocValuesSliceQuery和TermsSliceQuery类似,只是没有使用_uid作为切分,它使用了指定field的排序好的SortedNumericDocValues它构造出的DocIdSet是一个全量的DocIdSet(DocIdSetIterator.all),但是在scorer时候有一个两阶段的过程,TwoPhaseIterator中如果match才会取出,不然就指向下一个。match中定义的逻辑和上面_uid切分是一致的,都是根据hash值是否和slice_id对应。如果Math.floorMod(hashCode, slice_max) == slice_id就拿出来,不然就跳过。计算hash值:BitMixer.mix 该计算hash值的速度估计会比string的要快,因为实现要比murmurhash3_x86_32简单很多。 注意点: 该字段不能更新,只能设置一次该字段的分布要均匀,不然每个slice获取到的docId不均匀。 单shard内切分slice的两种方式总结: TermsSliceQuery耗内存,可能会造成jvm内存紧张;DocValuesSliceQuery不占用内存,但是依赖读DocValue,因此速度没有TermsSliceQuery快。TermsSliceQuery真实的遍历了_uid的值,而DocValuesSliceQuery遍历了doc_id序号,根据这个doc_id去取DocValue。 性能、稳定性优化改进 当前Elasticsearch在SearchScroll接口上有很多地方存在性能或者稳定性问题,我们对他们进行了一些优化和改进,让该接口性能更好和使用更佳。本节主要介绍的是我们在SearchScroll接口上做的一些优化的工作。 queryAndFetch 这个优化是Elasticsearch目前就有的,但是还有改进的空间。 当索引只有一个shard的时候,Elasticsearch能够启用该优化,这时候SearchScroll查询能够启用queryAndFetch查询策略,这样在协调节点上只需要一步queryAndFetch操作就可以从data节点上拿到数据,而默认的查询策略queryThenFetch需要经历一个两阶段操作。如图所示,queryAndFetch这种查询方式可以节省一次网络开销,查询时间缩短。 当用户的shard数不等于1时候,Elasticsearch没有任何优化。但是,当用户的SearchScroll的max和shard数一致的时候,也是可以开启queryAndFetch优化的,因为一个并发仅仅在一个shard上真正的执行。我们将这些case也进行了优化,在多并发时候也能进行queryAndFetch优化,节省CPU、网络、内存等资源消耗,提高整体吞吐率。 查询剪枝 SearchScroll多并发场景下,请求刚到协调节点上,会查询出每个shard在哪些节点上,然后将请求转发到这些节点上。当查询请求到达data节点上,根据slice参数重写query时候,会判断该shard应不应该被当前slice进行查询。主要判断逻辑本文上述章节已经介绍。如果该slice不应该查询本shard,则直接返回一个MatchNoDocsQuery这样的filter,相当于该请求在data节点上浪费了一次查询。虽然加了MatchNoDocsQuery的原请求执行速度很快,但是会占用线程池浪费一些cpu时间,而且会浪费线程池的队列空间。 假如用户有512个shard,且用户用512个并发进行访问。需要注意的是,每个并发请求都会转发到所有的shard上,因此在集群的data节点上瞬间会有512*512=26万个任务需要执行,其中仅有512个任务是真正需要执行的,其它的请求都是在浪费集群资源。默认情况下单个节点查询线程池队列是1000,一般集群也没有那么多data节点,难支撑26万个请求。 针对该问题,我们将slice的MatchNoDocsQuery的filter过滤提前到协调节点,不需要再转发这些无用的请求。在协调节点上会计算哪些shard需要真正执行查询任务,因此我们将MatchNoDocsQuery的filter逻辑前置,达到查询剪枝的目的。 除此之外,在并发数和shard数不相等时候,一个并发请求可能会发送到n个shard上。假如用户需要返回m条数据,会向n个shard各请求m条数据,然后在协调节点需要将n*m条数据进行排序,选出前m条进行fetch然后再返回给用户,这样相当于浪费了(n-1)*m条数据的计算和io资源。因此可以仅从一个shard上获取数据,按顺序将所有shard上的数据拉取结束,在挨个拉取的过程中,还要保持之前在各个shard创建的searchContext,避免SearchContext失效。 查询剪枝后,并发访问方式下,scroll_id也将变得特别短。之前用户拿到的scroll_id特别长,跟用户的shard数成正比,当shard数较多时候,scroll_id也特别长,在传输过程和scroll_id编码解析过程中都会浪费一些系统资源。 shard选择策略 一个索引通常会有很多副本,当请求到达协调节点后,请求应该转发到哪个副本呢? 默认情况下,采用的是随机策略,将所有副本打乱随机拿出一个副本即可。默认的随机策略能够将请求均匀地打散在每一个shard上。假如我们的data节点处理能力不一致,或者由于一些原因造成某些机器负载较高,那么采用随机策略可能不太适用。Elasticsearch提供了一个自适应的选择策略,其能够根据当前的每个节点的状态来选择最佳的副本。参考因素如下源码列出的,包括节点的client数、队列长度、响应时间、服务时间等。因此,通过"cluster.routing.use_adaptive_replica_selection"参数将副本自适应选择策略打开,能够发挥每一台机器的能力,请求延时能够有效降低,每台机器的负载能够更加均匀。 ComputedNodeStats(int clientNum, NodeStatistics nodeStats) { this(nodeStats.nodeId, clientNum,(int) nodeStats.queueSize.getAverage(), nodeStats.responseTime.getAverage(), nodeStats.serviceTime); } 针对SearchScroll请求,如果是频率较高的拉取不同索引的少量数据,那么副本自适应选择策略可以满足需求。但是针对一些大索引拉取数据的case则不再适用。假如某一个索引有512个shard,且需要拉取的数据较多,那么集群资源可能仅够该索引大量拉取,不会再有其他请求过来。当512个并发请求一下子进来协调节点,这时候协调节点会拉取每个data节点的状态来决定把请求发往哪个副本。但是512个并发是一起过来的,因此拿到的nodeStats可能是一致的,会造请求发往相同的data节点,造成一些data节点负载较高,而其他data节点负载较低。SearchScroll的首轮请求会决定了后续请求在哪个data节点执行,因此后续所有请求和首轮一样,造成各个data节点负载不一致。 针对这种情况,如果索引shard较多,且用户是SearchScroll请求,则需要不再使用副本自适应选择策略。 请求支持重试 自Elasticsearch支持SearchScroll以来,scroll_id都是不变的,所有的游标位点信息都是维护在data节点的searchContext中。scroll_id仅仅编码了node_id和context_id。协调节点根据node_id将请求转发到对应的data节点,在data节点上根据context_id拿到searchContext,最后拿到所有相关的具体信息。 当前scroll_id是不支持重试的,强行进行重试可能会造成数据丢失,推荐遇到失败全部重新拉取。比如用户有100条数据需要拉取,每次拉10条。当拉取20~30条时候,Elasticsearch已经拿到数据,代表着data节点的游标位点信息已经更新,但是用户网络发生问题,没有取到这10条数据。这时候用户忽略网络异常而继续请求的话,会拿到30~40的10条数据,而20~30的10条数据再也拿不到,造成读取数据丢失。针对这一问题,我们将searchContext中维护的last_emitted_doc编码到scroll_id中,这样在部分场景失败下就可以进行重试。 之前scroll_id的编码是为query_type + array_size + array[context_id + node_id],我们优化后的scroll_id为增加了version、index_name、last_emitted_doc等信息: version字段是为了以后做版本兼容使用,当前的scroll_id并没有版本的概念,因此版本兼容难做。index_name是索引的名字。虽然该字段对查询没有任何用处,但是在stats监控中需要用到。之前我们仅能统计SearchScroll的整个集群或者Node级别的监控,现在拿到index_name后,可以做到索引级别更细粒度的监控,比如拿到某一个索引Scroll阶段的query、merge、sort、fetch等各项监控信息。last_emitted_doc是新增的字段,在Elasticsearch中是ScoreDoc.java,主要编码的是doc和score两个字段。如果ScoreDoc是FieldDoc子类型,则还会编码fields。 scroll_id中编码last_emitted_doc后,用户的每次请求我们都能拿到当前的游标位点信息。在协调节点中,通过InternalScrollSearchRequest将该Request从协调节点发送到data节点,最终data节点不再从searchContext中拿last_emitted_doc,而是从InternalScrollSearchRequest拿到last_emitted_doc。 除此之外,当前Elasticsearch的SearchContext是不支持并发访问的,且没有给出任何提示,如果并发访问会造成拿到的数据错乱。因此,我们将SearchContext加了状态,如果访问一个正在被访问的SearchContext,则抛出冲突异常。 最后 本文介绍了SearchScroll的基本概念和一些内部原理,最后介绍了我们在SearchScroll方面做的一些性能优化工作,希望大家对SearchScroll有更深的理解。 如果您是Java老司机,或者对Lucene、Elasticsearch、Solr等相关引擎运用熟练、理解到位,或者想从事搜索引擎相关的一些工作,可以钉钉或邮箱联系寻剑<xunjian.sl@alibaba-inc.com>,团队技术氛围浓厚、简单淳朴,欢迎大家私聊交流。
本文介绍表格存储(Tablestore)多元索引Search接口查询数据的limit提高到1000的方法。 为了提高使用多元索引Search接口单次查询的返回结果数,当查询数据时只查询多元索引中的数据没有反查数据表时,则limit限制自适应提高到1000,如果查询数据时需要反查数据表,则limit限制为100。 limit限制提高到1000的前提是没有反查主表,只返回在多元索引中的属性列,具体要求如下: 创建多元索引时候,指定字段的附加存储为true 如果通过新版控制台创建索引,默认为true,不需要用户选择 如果通过旧版控制台创建索引,将“附加存储”选项打开 如果通过SDK创建索引,将字段的“FieldSchema”参数中的“store”参数设置为true 通过search接口查询时候,通过设置SearchRequest的ColumnsToGet参数,参数中仅返回设置过附加存储的字段,且ColumnsToGet中不能有“数组类型”、“geo地理位置类型”、“nested嵌套类型”三种类型的字段。 注意:如果包含上述三种类型的字段,还是会触发反查主表,则limit限制为100;如果ColumnsToGet中设置的是常规的字段,则limit限制自适应提高到1000。后续我们会陆续放开数组字段、geo地理位置字段、nested嵌套字段可以返回。 Java代码示例 此处以Java SDK为例介绍如何设置ColumnsToGet参数,其他语言的SDK实现类似,只需修改SearchRequest中的ColumnsToGet参数即可。 SearchQuery searchQuery = new SearchQuery(); searchQuery.setQuery(new MatchQuery()); searchQuery.setLimit(1000); SearchRequest searchRequest = new SearchRequest(tableName, indexName, searchQuery); ColumnsToGet columnsToGet = new ColumnsToGet(); columnsToGet.setReturnAll(false); columnsToGet.setColumns(Arrays.asList("field_1", "field_2", "field_3")); // 列全字段名字,字段类型为数组、nested嵌套字段、geo地理位置字段不能在里面,不然会反查主表 searchRequest.setColumnsToGet(columnsToGet); SearchResponse response = client.search(searchRequest); // java-sdk-5.6.1及以后版本 ColumnsToGet参数支持returnAllColumnsFromIndex参数,获取索引上的所有属性列 ColumnsToGet columnsToGet = new ColumnsToGet(); columnsToGet.setReturnAllFromIndex(true); searchRequest.setColumnsToGet(columnsToGet); SearchResponse response = client.search(searchRequest);
表格存储Tablestore是阿里云自研的结构化数据存储平台,提供海量结构化数据存储以及快速的查询和分析服务。表格存储Tablestore的分布式存储和强大的索引引擎能够支持PB级存储、千万TPS以及毫秒级延迟的服务能力。阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。 网盘是一种在线存储服务,服务商为用户提供文件的存储、访问、共享等文件管理必备的基本功能。我们将其数据分为两种: 文件。 用户存放的真实文件。早期,网盘中的文件大多存储在文件系统中,如本地磁盘、HDFS、NAS等。现在网盘更多的选择存储在对象存储中,如OSS,其在稳定性、安全性、成本、弹性上都会具有优势。 元数据(Meta)。 描述文件信息的数据,如归属者、目录、大小、创建时间、文件类型等。利用该元数据可以找到真实的文件地址,也可以利用元数据做文件的查询、分享、归类等。 文件数据直接使用OSS存储即可,简单高效。最复杂的是元数据的存储和查询,接下来我们看下网盘元数据(Meta)的存储和查询解决方案。 网盘元数据特点 网盘类型的元数据非常适合存储在Tablestore,主要是因为网盘类型元数据的一些特征和Tablestore匹配。 数据量大 存储海量网盘元数据的同时,需要满足强一致、高可用、低成本等要求。用户的文件数量巨大,比如图片、视频、工作文档是数量最多的数据,一个企业的网盘元数据规模通常在TB级别。传统的Mysql方案中,数据量一旦达到瓶颈,便需要重新创建更大规模的分库然后进行数据的全量迁移,同时数据迭代、膨胀带来的困扰是MySql方案难于逾越的门槛。因此,如此规模的数据存储已经不适合用单机的关系型数据库了,也不适合分库分表了,而需要一款分布式NoSQL数据库,这样可以将数据按一定的路由规则分布到不同机器上,实现自动的水平扩展,非常适合存储海量数据。 查询类型多样 网盘类型元数据与一般元数据不同的是有很多的tag标签,因此系统需要具备搜索查询多维度嵌套标签的能力。以图片为例:用户通过智能媒体的分析服务,分析图片然后为图片增加标签,用户还可提取人脸识别相关信息,提取的信息也需要存储到文件元数据信息中。图片的元数据信息量不断增加,格式、类型也在不断呈现多元化。因此,需要具备丰富的查询类型,如多维度、范围、模糊查询、地理位置等,同时具备排序、统计等功能。比如用户希望查找在杭州市某个商圈1km内的照片?查找2019年所有关于儿子成绩单的excel文件?这时候关系型数据库可能在查询功能和性能上不一定能满足需求,需要额外使用一些查询引擎如Solr、Elasticsearch等。 服务性能及成本 新业务需要产品快速上线,这时候既要高并发,又要保证低延迟,同时又希望兼顾成本最低。随着系统用户的增多减少以及不可预期的热点事件,需要能够动态的扩容和缩容来节约成本,比如系统起步阶段可以使用一款全托管的Serverless数据库,来专注于业务研发,无需担心软硬件配置、故障、集群扩展、安全等问题,在保证服务高可用性的同时,极大地减少了管理及运维成本,帮助产品快速上线。 开源解决方案 传统的网盘元数据可能会采用关系数据库Mysql的方案,借助关系型数据库强大的查询能力,用户可直接通过SQL语句实现文件的元数据多维度查询、数据统计分析等。但是,Mysql等关系数据库通常有如下一些问题: 随着数据量的膨胀无法水平扩展。数据库要满足易扩展、低成本等基本要求;对文件的多维查询需求支持不佳。数据库需要具备强大的查询能力,如多类型索引、多维度组合查询等,同时具备排序、统计等功能;在数量比较大的情况下,写入性能较差。数据库需要应对高并发请的同时,保证低延迟、强一致、高可用等特性。 为了解决如上问题,多种数据库组合的方案便孕育而生,如Mysql分库分表+Elasticsearch的方案解决写入和查询能力等问题,这些方案的架构可能如下: 借助多个数据库各自的优势可以分别解决不同场景的需求,但多个数据库组合的方案同样也带来了新的问题: 牺牲了空间成本,Mysql和Elasticsearch中的数据存在无用的冗余数据;较难保证两个数据库的数据一致性,存在数据丢失的可能性;增加了开发工作量与运维复杂度。 Tablestore云解决方案 在现有的各种各样的云产品中,能满足上述网盘元数据需求的产品并不多。表格存储Tablestore是阿里云借鉴谷歌Bigtable论文研发的一款分布式数据库产品,可以提供超大规模的存储容量,天然的分布式架构也提供了易于横向扩展的特性,理论上可以存储的数据量是不受限制的。该数据库10年前就开始自研,经过多年的双十一、公有云客户的锤炼沉淀,保证了系统的可靠性和稳定性。 基于Tablestore存储网盘架构方案如图: 网盘文件的存储 OSS和表格存储Tablestore一样,都是Serverless的云服务,其数据设计持久性不低于99.9999999999%(12 个 9),服务设计可用性(或业务连续性)不低于 99.995%。OSS按量使用且无需关心运维。使用oss进行文件的存储会使应用在稳定性、安全性、成本、弹性上都会具有优势。不用自己搭建和维护文件系统,开发人员可以把精力放在核心功能的开发上。 网盘元数据的存储 网盘元数据数据存储在Tablestore中,Tablestore可以提供10个9的数据可靠性保障,数据可靠性有保障。网盘元数据和索引都在一个系统里面,写入,读取都是通过同一套API写入和查询,易用性更高。Tablestore支持二级索引、多元索引等索引功能,完全满足上述提出的查询需求,包括: 多字段自由组合查询。范围查询。地理位置查询。 Tablestore是分布式系统,可以水平扩展,目前生产环境单表最大有几十PB,每秒写入有几千万行,完全能胜任任何大数据的写入和存储。 示例 接下来,我们通过一个网盘元数据的使用示例增加理解。 假设我们的产品需要给用户提供个人相册服务。将相册中的图片和视频存储在对象存储OSS中,将图片和视频的元数据(Meta Data)存储在表格存储Tablestore中。一般的文件Meta信息包括:用户id、文件id、文件类型、创建时间、照片地点、文件大小、文件状态、标签信息、OSS链接、是否星标文件、用户描述信息。 首先,我们设计Tablestore中主表结构: 主键列 主键列 主键列 属性列 属性列 属性列 属性列 属性列 属性列 属性列属性列 字段描述 用户id 创建时间 文件id 文件类型 拍摄地点 文件大小 文件状态 标签信息 OSS链接 是否星标文件 用户描述信息 数据类型 String Integer String String String Double String String String Boolean String 举例 abc 1573543009 aaaa1 image 35.8,-45.91 42.5 uploading [{"name:"place", "tag": "北京" }, { "name": "people", "tag": "男" }, { "name": "annimal", "tag": "狗" } ] http://abc.oss-cn-hangzhou.aliyun.com/xxxxxx/aa.png true 考上大学 举例 def 1573543012 bbbb2 video 35.8,-45.91 9822.34 available [{"name:"place", "tag": "上海" }, { "name": "weather", "tag": "雷阵雨" } ] http://abc.oss-cn-hangzhou.aliyun.com/xxxxxx/aa.mp4 false 和小明一起上海跨年 Tablestore的主表在查询时候可以理解为是几个主键列上的前缀索引,上述主表结构中,支持的查询包括: 一个用户下的所有图片或视频一个用户某一段时间内的图片或视频查看用户最近的图片或视频 Tablestore支持全局二级索引。全局二级索引支持在指定列上建立索引,生成的索引表中数据按您指定的索引列进行排序,主表的每一个写入都将自动异步同步到索引表。您只向主表中写入数据,根据索引表进行查询,在许多场景下,将极大的提高查询的效率。例如网盘场景下如果主键查询不能满足需求,对于简单的查询(例如单个字段查询)我们可以直接创建全局二级索引,通过全局二级索引能够快速的查询信息。 通过主表和二级索引,已经能够完成相册的基本功能,如文件的上传下载等。如果想完成更加丰富和复杂的查询功能(多个字段关联查询),可以使用Tablestore的多元索引功能,其中多元索引的索引定义: 列 列 列 列 列 列 列 列 用户id 创建时间 文件类型 拍摄地点 文件大小 标签信息 是否星标文件用户描述信息Keyword Long Keyword GeoPoint Double Nested,Nested中的name和tag都是Keyword Boolean Text,分词采用: 单字分词 用户描述信息采用了Text类型, 支持分词,分词使用了单字分词。该字段可以进行模糊匹配查询。拍摄地点采用了GeoPoint类型,这个类型支持范围查询。比如某个点附近几公里内的所有照片,同时也支持某个多边形范围内的所有照片信息。标签信息是Nested嵌套类型的,其支持的查询和非嵌套类型一致,只是使用方式上有一些小差别。多元索引支持多字段组合查询,可以满足任意几个列与或非组合查询,如:查询文件类型是图片且拍摄地点是上海且文件大小超过100MB且是标星文件且创建时间在9月份之前的所有照片? 另外,可能还有一些统计聚合需求,在运营产品和制作报表时候可能会用到。这些都可以基于多元索引完成,比如: 目前所有用户的相册中一共有多少张?通过Aggregation.Count(*);所有用户中超过1G的文件有多少个?GroupBy.GroupByRange(文件大小);视频和图片各有多少张?通过 GroupBy.GroupByField(文件类型);过去三年每年相册的上传数量? 通过 GroupBy.GroupByRange(创建时间);视频文件中最大文件有多大? 通过Aggregation.Max(文件大小);... 基于上述的表、二级索引和多元索引,我们就能满足几乎所有的查询需求。 总结 至此,开源解决方案和Tablestore云解决方案都介绍完了,使用了Tablestore云解决方案后,能带来不少的收益。 减少运维负担 在Tablestore云解决方案中,用户只需要运维自己的应用程序,不需要运维任何的存储系统、查询系统和其他中间件,运维压力大大减少,同时也减少了运维方面的成本开支。另外,Tablestore是Serverless的服务化产品,不需要关注扩容、水位等事项,只需要关注功能开发即可。 系统架构更简单 在开源解决方案中使用多个系统,而Tablestore解决方案中只有1个系统,系统数减少后,系统架构会更简单,系统可能产生的风险会更少,系统的稳定性等会更高,可以提供更优质的服务体验。 减少时间成本 Tablestore云架构方案相对于开源方案,系统更少,从零开始到上线需要的开发时间更少,同时,Tablestore是serverless的云服务,全球多个区域即开即用,可以大大降低客户的开发上线的时间,提前将产品推出,抢先争取市场领先优势。 数据可靠性更高 在开源解决方案中,数据有可能会丢失,影响最终的业务,而在Tablestore解决方案中,可以提供更高的可靠性,可以让数据不丢失,保障业务的准确执行。 扩展阅读 表格存储Tablestore权威指南(持续更新)海量智能元数据管理系统实现解析表格存储数据模型和查询操作Aliyun Tablestore Examples 表格存储 (Tablestore) 提供专业的免费技术咨询服务,欢迎加入我们的钉钉讨论群。群号 : 23307953
表管理接口概述 API 描述 createTable 创建表 deleteTable 删除表 listTable 列出实例下的所有表 updateTable 更新表(在表被创建之后,动态的更改表的配置或预留吞吐量) describeTable 获取表的详细信息 上述API操作是Tablestore最基础的API。官方提供了Java、Go、Node.js、Python、PHP、C#、C++语言的SDK。 createTable 创建表可以在控制台进行。在使用API进行创建的时候,需要指定表的配置信息和预留吞吐量等信息。 说明: 表格创建好后服务端有初始化时间,需要等待几秒钟才能对表进行读写,否则会出现异常 表限制 单个实例最多可以创建 64 张数据表表名长度1-255 Bytes,需由[a-z, A-Z, 0-9]和下划线(_)组成。首字符必须是字母或下划线(_)其它限制见: https://help.aliyun.com/document_detail/91524.html?spm=a2c4g.11186623.6.568.357f228382mAWN 参数说明 创建表的参数主要包括如下几部分: TableMeta: 表的结构信息,包含表的名称以及表的主键定义TableOptions: 表的配置选项,用于配置TTL、MaxVersions等ReservedThroughput:表的预留读写吞吐量设置 表结构 TableMeta 参数定义说明TableName表名无List表的主键定义 注意: 最多可设置 4 个主键,主键的配置及顺序一旦设置便不可修改。表格存储可包含多个主键列。主键列是有顺序的,与用户添加的顺序相同,例如, PRIMARY KEY (A, B, C) 与 PRIMARY KEY (A, C, B) 是不同的两个主键结构。表格存储会按照主键的大小为行排序,具体参见表格存储数据模型和查询操作。第一列主键作为分片键。分片键相同的数据会存放在同一个分片内,所以相同分片键下最好不要超过 10 G 以上数据,否则会导致单分片过大,无法分裂。另外,数据的读/写访问最好在不同的分片键上均匀分布,有利于负载均衡。属性列不需要定义。表格存储每行的数据列都可以不同,属性列的列名在写入时指定。 表配置 TableOptions 参数定义说明TTLTimeToLive,数据存活时间 单位:秒。如果期望数据永不过期,TTL 可设置为 -1。数据是否过期是根据数据的时间戳、当前时间、表的 TTL三者进行判断的。当前时间 - 数据的时间戳 > 表的 TTL时,数据会过期并被清理。在使用 TTL 功能时需要注意写入时是否指定了时间戳,以及指定的时间戳是否合理。如需指定时间戳,建议设置MaxTimeDeviation。 MaxVersions每个属性列保留的最大版本数 如果写入的版本数超过 MaxVersions,服务端只会保留 MaxVersions 中指定的最大的版本。 MaxTimeDeviation写入数据的时间戳与系统当前时间的偏差允许最大值 该参数绝大多数情况下用户使用不到,使用服务端的默认值即可。默认情况下系统会为新写入的数据生成一个时间戳,数据自动过期功能需要根据这个时间戳判断数据是否过期。用户也可以指定写入数据的时间戳。如果用户写入的时间戳非常小,与当前时间偏差已经超过了表上设置的 TTL 时间,写入的数据会立即过期。设置 MaxTimeDeviation 可以避免这种情况。单位:秒。可在建表时指定,也可通过 UpdateTable 接口修改。 预留吞吐量 ReservedThroughtput ReservedThroughtput表的预留读/写吞吐量配置。 设置 ReservedThroughtput 后,表格存储按照您预留读/写吞吐量进行计费。当 ReservedThroughtput 大于 0 时,表格存储会按照预留量和持续时间进行计费,超出预留的部分进行按量计费。更多信息参见计费,以免产生未期望的费用。默认值为 0,即完全按量计费。容量型实例的预留读/写吞吐量只能设置为 0,不允许预留。 Java代码示例 // 创建普通表(不使用索引等功能) public void createTable() { TableMeta tableMeta = new TableMeta(TABLE_NAME); // 为主表添加主键列。 tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_1", PrimaryKeyType.STRING)); tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_2", PrimaryKeyType.INTEGER)); tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_3", PrimaryKeyType.BINARY)); // 设置该主键为自增列 tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_4", PrimaryKeyType.INTEGER, PrimaryKeyOption.AUTO_INCREMENT)); // 数据的过期时间,单位秒, -1代表永不过期,例如设置过期时间为一年, 即为 365 * 24 * 3600。 int timeToLive = -1; // 保存的最大版本数,设置为3即代表每列上最多保存3个最新的版本。(如果使用索引,maxVersions只能等于1) int maxVersions = 3; TableOptions tableOptions = new TableOptions(timeToLive, maxVersions); CreateTableRequest request = new CreateTableRequest(tableMeta, tableOptions); // 设置读写预留值,容量型实例只能设置为0,高性能实例可以设置为非零值。 request.setReservedThroughput(new ReservedThroughput(new CapacityUnit(0, 0))); client.createTable(request); } 表设计相关技巧 分区键选择 表格存储Tablestore第一个主键是分区键,决定了数据分布式存储的位置。分区键的选择决定了数据是否均匀散列在不同的后端服务器上。散列是分布式数据库中常见的问题之一,数据的散列能够避免热点问题。因此在选择主键的时候,首先要选好分区键。其中比较常用的两种方式如下: 分区键可以选择业务上比较分散的Key放到第一列,如userID、DeviceId等。如果每个用户数据分布严重不均匀,则需要另外选择其它字段。如果分区键不好设计,可以对想要当做分区键的值拼接MD5,这样能够保证数据散列。 更多的设计技巧见两篇表设计实践。 表设计的最佳实践 、表设计实践 数据生命周期TTL设计 数据生命周期(Time To Live,简称 TTL)是数据表的一个特性,即数据的存活时间,单位为秒。表格存储会在后台对超过存活时间的数据进行清理,以减少用户的数据存储空间,降低存储成本。 TTL特性能够实现数据过期自动删除,因此在很多场景能够用到,这些场景中随着时间的流逝数据的价值会降低,特别适合TTL。 舆情监控。如果用户只关心最近3个月的信息,因此超过3个月的数据可以自动删除。物流轨迹。物流轨迹的记录信息在用户收到货物后就失去了其大部分的价值,因此可以设置1个月的TTL,来节省大量的存储费用。系统日志。系统的运行日志大多数只有在系统出问题时候才会查看,而随着时间流逝,很早之前的系统日志也就失去了存在的价值,而这部分数据存储在数据库中会占用存储成本,设置合适的TTL能够自动将之前的数据删除。 TTL暂时仅支持主表,索引等还暂时不支持TTL特性。因此如果使用了二级索引或者多元索引,需要主表的TTL=-1,即永久不会失效,对于已经创建好表的用户,可以通过 UpdateTable 接口动态更改主表的 TTL。 Java 示例代码 public void updateTTL() { UpdateTableRequest request = new UpdateTableRequest(TABLE_NAME); int ttl = -1; request.setTableOptionsForUpdate(new TableOptions(ttl)); client.updateTable(request); } 主键自增 主键列自增功能是指若用户指定某一列主键为自增列,在其写入数据时,表格存储会自动为用户在这一列产生一个新的值,且这个值为同一个分区键下该列的最大值。 主键自增列的功能特性主要有生成数字ID且ID严格递增保证顺序,这个特性决定了许多场景能够使用。 如电商网站的商品 ID、大型网站的用户 ID等,这些场景中数值越大,表示该商品、用户越新。如论坛帖子的 ID、聊天工具的消息 ID等消息保序的场景,这些场景需要严格保证消息递增,不然用户读取到的顺序就会乱序。举个例子,假如用户发朋友圈场景,用户在1点5分11.1秒时刻发送了一个朋友圈记录a,另一个用户在1点5分11.2秒发送了一个朋友圈记录b,则需要严格保证记录b的id比记录a的id大。主键自增在聊天系统IM中的应用见 Table Store主键列自增功能在IM系统中的应用 主键自增的一些限制: 表格存储支持多个主键,第一个主键为分区键,分区键不允许设置为自增列,其它任一主键都可以设置为自增列。每张表最多只允许设置一个主键为自增列。属性列不能设置为自增列。仅支持在创建表的时候指定自增列,对于已存在的表不支持创建自增列。 Java示例代码 // 主键自增 创建主表 public void createTableWithPKAutoIncrement() { TableMeta tableMeta = new TableMeta(TABLE_NAME + "AUTO_INCREMENT"); tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_1", PrimaryKeyType.STRING)); // 设置该主键为自增列 tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_autoI_increment", PrimaryKeyType.INTEGER, PrimaryKeyOption.AUTO_INCREMENT)); tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_3", PrimaryKeyType.INTEGER)); TableOptions tableOptions = new TableOptions(-1, 1); CreateTableRequest request = new CreateTableRequest(tableMeta, tableOptions); client.createTable(request); } // 自增列的数据插入 public void putRow(){ PrimaryKey primaryKey = PrimaryKeyBuilder.createPrimaryKeyBuilder() .addPrimaryKeyColumn("pk_1", PrimaryKeyValue.fromString("test1")) .addPrimaryKeyColumn("pk_auto_increment", PrimaryKeyValue.AUTO_INCREMENT) .addPrimaryKeyColumn("pk_3", PrimaryKeyValue.fromLong(100)) .build(); RowPutChange rowPutChange = new RowPutChange(TABLE_NAME , primaryKey); rowPutChange.addColumn("attr_1", ColumnValue.fromLong(100)); PutRowRequest request = new PutRowRequest(rowPutChange); client.putRow(request); } deleteTable 删除表,只需要指定表名字即可。 说明: 如果表中创建了多元索引,需要先删除多元索引才可以删除表。 Java代码示例 public void deleteTable() { DeleteTableRequest request = new DeleteTableRequest(TABLE_NAME); client.deleteTable(request); } listTable 该API能够列出当前实例下已创建的所有表的表名。 Java代码示例 public void listTable() { ListTableResponse response = client.listTable(); System.out.println("表的列表如下:"); for (String tableName : response.getTableNames()) { System.out.println(tableName); } } updateTable 该API能够动态的更改表的配置或预留吞吐量。可以只修改配置或只修改预留吞吐量,也可以一起修改。 说明: 该API调用频率有限制,为每 2分钟1次。 Java代码示例 public void updateTable() { UpdateTableRequest request = new UpdateTableRequest(TABLE_NAME); // 修改预留吞吐 request.setReservedThroughputForUpdate(new ReservedThroughput(new CapacityUnit(0, 0))); // 修改表的最大保留版本、TTL等 request.setTableOptionsForUpdate(new TableOptions(-1, 1)); client.updateTable(request); } describeTable 该API可以获得表的结构信息(TableMeta)、配置信息(TableOptions)和预留读/写吞吐量的情(ReservedThroughputDetails,包括调整时间)、表分区信息等, 如图所示。 Java代码示例 public void describeTable() { DescribeTableRequest request = new DescribeTableRequest(TABLE_NAME); DescribeTableResponse response = client.describeTable(request); TableMeta tableMeta = response.getTableMeta(); System.out.println("表的名称:" + tableMeta.getTableName()); System.out.println("表的主键:"); for (PrimaryKeySchema schema : tableMeta.getPrimaryKeyList()) { System.out.println("\t主键名字:" + schema.getName() + "\t主键类型:" + schema.getType() + "\t自增列:" + (schema.getOption() == null ? "false" : schema.getOption().equals(PrimaryKeyOption.AUTO_INCREMENT))); } System.out.println("预定义列信息:"); for (DefinedColumnSchema schema : tableMeta.getDefinedColumnsList()) { System.out.println("\t主键名字:" + schema.getName() + "\t主键类型:" + schema.getType()); } System.out.println("二级索引信息:"); for (IndexMeta meta : response.getIndexMeta()) { System.out.println("\t索引名字:" + meta.getIndexName()); System.out.println("\t\t索引类型:" + meta.getIndexType()); System.out.println("\t\t索引主键:" + meta.getPrimaryKeyList()); System.out.println("\t\t索引预定义列:" + meta.getDefinedColumnsList()); } TableOptions tableOptions = response.getTableOptions(); System.out.println("TableOptions:"); System.out.println("\t表的TTL:" + tableOptions.getTimeToLive()); System.out.println("\t表的MaxVersions:" + tableOptions.getMaxVersions()); ReservedThroughputDetails rtd = response.getReservedThroughputDetails(); System.out.println("预留吞吐量:"); System.out.println("\t读:" + rtd.getCapacityUnit().getReadCapacityUnit()); System.out.println("\t写:" + rtd.getCapacityUnit().getWriteCapacityUnit()); System.out.println("\t最近上调时间: " + new Date(rtd.getLastIncreaseTime() * 1000)); System.out.println("\t最近下调时间: " + new Date(rtd.getLastDecreaseTime() * 1000)); List<PrimaryKey> shardSplits = response.getShardSplits(); System.out.println("表分区信息:"); for (PrimaryKey primaryKey : shardSplits) { System.out.println("\t分裂点信息: " + primaryKey.getPrimaryKeyColumnsMap()); } } 专家服务 如有疑问或者需要更好的在线支持,欢迎加入钉钉群:“表格存储公开交流群”(群号:23307953)。群内提供免费的在线专家服务,欢迎扫码加入。
表管理接口概述 API 描述 createTable 创建表 deleteTable 删除表 listTable 列出实例下的所有表 updateTable 更新表(在表被创建之后,动态的更改表的配置或预留吞吐量) describeTable 获取表的详细信息 上述API操作是Tablestore最基础的API。官方提供了Java、Go、Node.js、Python、PHP、C#、C++语言的SDK。 createTable 创建表可以在控制台进行。在使用API进行创建的时候,需要指定表的配置信息和预留吞吐量等信息。 说明: 表格创建好后服务端有初始化时间,需要等待几秒钟才能对表进行读写,否则会出现异常 表限制 单个实例最多可以创建 64 张数据表 表名长度1-255 Bytes,需由[a-z, A-Z, 0-9]和下划线(_)组成。首字符必须是字母或下划线(_) 其它限制见: https://help.aliyun.com/document_detail/91524.html?spm=a2c4g.11186623.6.568.357f228382mAWN 参数说明 创建表的参数主要包括如下几部分: TableMeta: 表的结构信息,包含表的名称以及表的主键定义 TableOptions: 表的配置选项,用于配置TTL、MaxVersions等 ReservedThroughput:表的预留读写吞吐量设置 表结构 TableMeta 参数 定义 说明 TableName 表名 无 List 表的主键定义 1. 注意: 最多可设置 4 个主键,主键的配置及顺序一旦设置便不可修改。 2. 表格存储可包含多个主键列。主键列是有顺序的,与用户添加的顺序相同,例如, PRIMARY KEY (A, B, C) 与 PRIMARY KEY (A, C, B) 是不同的两个主键结构。表格存储会按照主键的大小为行排序,具体参见表格存储数据模型和查询操作。3. 第一列主键作为分片键。分片键相同的数据会存放在同一个分片内,所以相同分片键下最好不要超过 10 G 以上数据,否则会导致单分片过大,无法分裂。另外,数据的读/写访问最好在不同的分片键上均匀分布,有利于负载均衡。4. 属性列不需要定义。表格存储每行的数据列都可以不同,属性列的列名在写入时指定。 表配置 TableOptions 预留吞吐量 ReservedThroughtput ReservedThroughtput表的预留读/写吞吐量配置。 设置 ReservedThroughtput 后,表格存储按照您预留读/写吞吐量进行计费。 当 ReservedThroughtput 大于 0 时,表格存储会按照预留量和持续时间进行计费,超出预留的部分进行按量计费。更多信息参见计费,以免产生未期望的费用。 默认值为 0,即完全按量计费。 容量型实例的预留读/写吞吐量只能设置为 0,不允许预留。 Java代码示例 // 创建普通表(不使用索引等功能) public void createTable() { TableMeta tableMeta = new TableMeta(TABLE_NAME); // 为主表添加主键列。 tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_1", PrimaryKeyType.STRING)); tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_2", PrimaryKeyType.INTEGER)); tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_3", PrimaryKeyType.BINARY)); // 设置该主键为自增列 tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_4", PrimaryKeyType.INTEGER, PrimaryKeyOption.AUTO_INCREMENT)); // 数据的过期时间,单位秒, -1代表永不过期,例如设置过期时间为一年, 即为 365 * 24 * 3600。 int timeToLive = -1; // 保存的最大版本数,设置为3即代表每列上最多保存3个最新的版本。(如果使用索引,maxVersions只能等于1) int maxVersions = 3; TableOptions tableOptions = new TableOptions(timeToLive, maxVersions); CreateTableRequest request = new CreateTableRequest(tableMeta, tableOptions); // 设置读写预留值,容量型实例只能设置为0,高性能实例可以设置为非零值。 request.setReservedThroughput(new ReservedThroughput(new CapacityUnit(0, 0))); client.createTable(request); } 表设计相关技巧 分区键选择 表格存储Tablestore第一个主键是分区键,决定了数据分布式存储的位置。分区键的选择决定了数据是否均匀散列在不同的后端服务器上。散列是分布式数据库中常见的问题之一,数据的散列能够避免热点问题。因此在选择主键的时候,首先要选好分区键。其中比较常用的两种方式如下: 分区键可以选择业务上比较分散的Key放到第一列,如userID、DeviceId等。如果每个用户数据分布严重不均匀,则需要另外选择其它字段。 如果分区键不好设计,可以对想要当做分区键的值拼接MD5,这样能够保证数据散列。 更多的设计技巧见两篇表设计实践。 表设计的最佳实践 、表设计实践 数据生命周期TTL设计 数据生命周期(Time To Live,简称 TTL)是数据表的一个特性,即数据的存活时间,单位为秒。表格存储会在后台对超过存活时间的数据进行清理,以减少用户的数据存储空间,降低存储成本。 TTL特性能够实现数据过期自动删除,因此在很多场景能够用到,这些场景中随着时间的流逝数据的价值会降低,特别适合TTL。 舆情监控。如果用户只关心最近3个月的信息,因此超过3个月的数据可以自动删除。 物流轨迹。物流轨迹的记录信息在用户收到货物后就失去了其大部分的价值,因此可以设置1个月的TTL,来节省大量的存储费用。 系统日志。系统的运行日志大多数只有在系统出问题时候才会查看,而随着时间流逝,很早之前的系统日志也就失去了存在的价值,而这部分数据存储在数据库中会占用存储成本,设置合适的TTL能够自动将之前的数据删除。 TTL暂时仅支持主表,索引等还暂时不支持TTL特性。因此如果使用了二级索引或者多元索引,需要主表的TTL=-1,即永久不会失效,对于已经创建好表的用户,可以通过 UpdateTable 接口动态更改主表的 TTL。 Java 示例代码 public void updateTTL() { UpdateTableRequest request = new UpdateTableRequest(TABLE_NAME); int ttl = -1; request.setTableOptionsForUpdate(new TableOptions(ttl)); client.updateTable(request); } 主键自增 主键列自增功能是指若用户指定某一列主键为自增列,在其写入数据时,表格存储会自动为用户在这一列产生一个新的值,且这个值为同一个分区键下该列的最大值。 主键自增列的功能特性主要有生成数字ID且ID严格递增保证顺序,这个特性决定了许多场景能够使用。 如电商网站的商品 ID、大型网站的用户 ID等,这些场景中数值越大,表示该商品、用户越新。 如论坛帖子的 ID、聊天工具的消息 ID等消息保序的场景,这些场景需要严格保证消息递增,不然用户读取到的顺序就会乱序。举个例子,假如用户发朋友圈场景,用户在1点5分11.1秒时刻发送了一个朋友圈记录a,另一个用户在1点5分11.2秒发送了一个朋友圈记录b,则需要严格保证记录b的id比记录a的id大。主键自增在聊天系统IM中的应用见 Table Store主键列自增功能在IM系统中的应用 主键自增的一些限制: 表格存储支持多个主键,第一个主键为分区键,分区键不允许设置为自增列,其它任一主键都可以设置为自增列。 每张表最多只允许设置一个主键为自增列。 属性列不能设置为自增列。 仅支持在创建表的时候指定自增列,对于已存在的表不支持创建自增列。 Java示例代码 // 主键自增 创建主表 public void createTableWithPKAutoIncrement() { TableMeta tableMeta = new TableMeta(TABLE_NAME + "AUTO_INCREMENT"); tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_1", PrimaryKeyType.STRING)); // 设置该主键为自增列 tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_autoI_increment", PrimaryKeyType.INTEGER, PrimaryKeyOption.AUTO_INCREMENT)); tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema("pk_3", PrimaryKeyType.INTEGER)); TableOptions tableOptions = new TableOptions(-1, 1); CreateTableRequest request = new CreateTableRequest(tableMeta, tableOptions); client.createTable(request); } // 自增列的数据插入 public void putRow(){ PrimaryKey primaryKey = PrimaryKeyBuilder.createPrimaryKeyBuilder() .addPrimaryKeyColumn("pk_1", PrimaryKeyValue.fromString("test1")) .addPrimaryKeyColumn("pk_auto_increment", PrimaryKeyValue.AUTO_INCREMENT) .addPrimaryKeyColumn("pk_3", PrimaryKeyValue.fromLong(100)) .build(); RowPutChange rowPutChange = new RowPutChange(TABLE_NAME , primaryKey); rowPutChange.addColumn("attr_1", ColumnValue.fromLong(100)); PutRowRequest request = new PutRowRequest(rowPutChange); client.putRow(request); } deleteTable 删除表,只需要指定表名字即可。 说明: 如果表中创建了多元索引,需要先删除多元索引才可以删除表。 Java代码示例 public void deleteTable() { DeleteTableRequest request = new DeleteTableRequest(TABLE_NAME); client.deleteTable(request); } listTable 该API能够列出当前实例下已创建的所有表的表名。 Java代码示例 public void listTable() { ListTableResponse response = client.listTable(); System.out.println("表的列表如下:"); for (String tableName : response.getTableNames()) { System.out.println(tableName); } } updateTable 该API能够动态的更改表的配置或预留吞吐量。可以只修改配置或只修改预留吞吐量,也可以一起修改。 说明: 该API调用频率有限制,为每 2分钟1次。 Java代码示例 public void updateTable() { UpdateTableRequest request = new UpdateTableRequest(TABLE_NAME); // 修改预留吞吐 request.setReservedThroughputForUpdate(new ReservedThroughput(new CapacityUnit(0, 0))); // 修改表的最大保留版本、TTL等 request.setTableOptionsForUpdate(new TableOptions(-1, 1)); client.updateTable(request); } describeTable 该API可以获得表的结构信息(TableMeta)、配置信息(TableOptions)和预留读/写吞吐量的情(ReservedThroughputDetails,包括调整时间)、表分区信息等, 如图所示。 Java代码示例 public void describeTable() { DescribeTableRequest request = new DescribeTableRequest(TABLE_NAME); DescribeTableResponse response = client.describeTable(request); TableMeta tableMeta = response.getTableMeta(); System.out.println("表的名称:" + tableMeta.getTableName()); System.out.println("表的主键:"); for (PrimaryKeySchema schema : tableMeta.getPrimaryKeyList()) { System.out.println("\t主键名字:" + schema.getName() + "\t主键类型:" + schema.getType() + "\t自增列:" + (schema.getOption() == null ? "false" : schema.getOption().equals(PrimaryKeyOption.AUTO_INCREMENT))); } System.out.println("预定义列信息:"); for (DefinedColumnSchema schema : tableMeta.getDefinedColumnsList()) { System.out.println("\t主键名字:" + schema.getName() + "\t主键类型:" + schema.getType()); } System.out.println("二级索引信息:"); for (IndexMeta meta : response.getIndexMeta()) { System.out.println("\t索引名字:" + meta.getIndexName()); System.out.println("\t\t索引类型:" + meta.getIndexType()); System.out.println("\t\t索引主键:" + meta.getPrimaryKeyList()); System.out.println("\t\t索引预定义列:" + meta.getDefinedColumnsList()); } TableOptions tableOptions = response.getTableOptions(); System.out.println("TableOptions:"); System.out.println("\t表的TTL:" + tableOptions.getTimeToLive()); System.out.println("\t表的MaxVersions:" + tableOptions.getMaxVersions()); ReservedThroughputDetails rtd = response.getReservedThroughputDetails(); System.out.println("预留吞吐量:"); System.out.println("\t读:" + rtd.getCapacityUnit().getReadCapacityUnit()); System.out.println("\t写:" + rtd.getCapacityUnit().getWriteCapacityUnit()); System.out.println("\t最近上调时间: " + new Date(rtd.getLastIncreaseTime() * 1000)); System.out.println("\t最近下调时间: " + new Date(rtd.getLastDecreaseTime() * 1000)); List<PrimaryKey> shardSplits = response.getShardSplits(); System.out.println("表分区信息:"); for (PrimaryKey primaryKey : shardSplits) { System.out.println("\t分裂点信息: " + primaryKey.getPrimaryKeyColumnsMap()); } } 专家服务 如有疑问或者需要更好的在线支持,欢迎加入钉钉群:“表格存储公开交流群”(群号:23307953)。群内提供免费的在线专家服务,欢迎扫码加入。
表格存储Tablestore是阿里云自研的结构化数据存储平台,提供海量结构化数据存储以及快速的查询和分析服务。表格存储Tablestore的分布式存储和强大的索引引擎能够支持PB级存储、千万TPS以及毫秒级延迟的服务能力。阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。 网盘是一种在线存储服务,服务商为用户提供文件的存储、访问、共享等文件管理必备的基本功能。我们将其数据分为两种: 文件。 用户存放的真实文件。早期,网盘中的文件大多存储在文件系统中,如本地磁盘、HDFS、NAS等。现在网盘更多的选择存储在对象存储中,如OSS,其在稳定性、安全性、成本、弹性上都会具有优势。 元数据(Meta)。 描述文件信息的数据,如归属者、目录、大小、创建时间、文件类型等。利用该元数据可以找到真实的文件地址,也可以利用元数据做文件的查询、分享、归类等。 文件数据直接使用OSS存储即可,简单高效。最复杂的是元数据的存储和查询,接下来我们看下网盘元数据(Meta)的存储和查询解决方案。 网盘元数据特点 网盘类型的元数据非常适合存储在Tablestore,主要是因为网盘类型元数据的一些特征和Tablestore匹配。 数据量大 存储海量网盘元数据的同时,需要满足强一致、高可用、低成本等要求。用户的文件数量巨大,比如图片、视频、工作文档是数量最多的数据,一个企业的网盘元数据规模通常在TB级别。传统的Mysql方案中,数据量一旦达到瓶颈,便需要重新创建更大规模的分库然后进行数据的全量迁移,同时数据迭代、膨胀带来的困扰是MySql方案难于逾越的门槛。因此,如此规模的数据存储已经不适合用单机的关系型数据库了,也不适合分库分表了,而需要一款分布式NoSQL数据库,这样可以将数据按一定的路由规则分布到不同机器上,实现自动的水平扩展,非常适合存储海量数据。 查询类型多样 网盘类型元数据与一般元数据不同的是有很多的tag标签,因此系统需要具备搜索查询多维度嵌套标签的能力。以图片为例:用户通过智能媒体的分析服务,分析图片然后为图片增加标签,用户还可提取人脸识别相关信息,提取的信息也需要存储到文件元数据信息中。图片的元数据信息量不断增加,格式、类型也在不断呈现多元化。因此,需要具备丰富的查询类型,如多维度、范围、模糊查询、地理位置等,同时具备排序、统计等功能。比如用户希望查找在杭州市某个商圈1km内的照片?查找2019年所有关于儿子成绩单的excel文件?这时候关系型数据库可能在查询功能和性能上不一定能满足需求,需要额外使用一些查询引擎如Solr、Elasticsearch等。 服务性能及成本 新业务需要产品快速上线,这时候既要高并发,又要保证低延迟,同时又希望兼顾成本最低。随着系统用户的增多减少以及不可预期的热点事件,需要能够动态的扩容和缩容来节约成本,比如系统起步阶段可以使用一款全托管的Serverless数据库,来专注于业务研发,无需担心软硬件配置、故障、集群扩展、安全等问题,在保证服务高可用性的同时,极大地减少了管理及运维成本,帮助产品快速上线。 开源解决方案 传统的网盘元数据可能会采用关系数据库Mysql的方案,借助关系型数据库强大的查询能力,用户可直接通过SQL语句实现文件的元数据多维度查询、数据统计分析等。但是,Mysql等关系数据库通常有如下一些问题: 随着数据量的膨胀无法水平扩展。数据库要满足易扩展、低成本等基本要求; 对文件的多维查询需求支持不佳。数据库需要具备强大的查询能力,如多类型索引、多维度组合查询等,同时具备排序、统计等功能; 在数量比较大的情况下,写入性能较差。数据库需要应对高并发请的同时,保证低延迟、强一致、高可用等特性。 为了解决如上问题,多种数据库组合的方案便孕育而生,如Mysql分库分表+Elasticsearch的方案解决写入和查询能力等问题,这些方案的架构可能如下: 借助多个数据库各自的优势可以分别解决不同场景的需求,但多个数据库组合的方案同样也带来了新的问题: 牺牲了空间成本,Mysql和Elasticsearch中的数据存在无用的冗余数据; 较难保证两个数据库的数据一致性,存在数据丢失的可能性; 增加了开发工作量与运维复杂度。 Tablestore云解决方案 在现有的各种各样的云产品中,能满足上述网盘元数据需求的产品并不多。表格存储Tablestore是阿里云借鉴谷歌Bigtable论文研发的一款分布式数据库产品,可以提供超大规模的存储容量,天然的分布式架构也提供了易于横向扩展的特性,理论上可以存储的数据量是不受限制的。该数据库10年前就开始自研,经过多年的双十一、公有云客户的锤炼沉淀,保证了系统的可靠性和稳定性。 基于Tablestore存储网盘架构方案如图: 网盘文件的存储 OSS和表格存储Tablestore一样,都是Serverless的云服务,其数据设计持久性不低于99.9999999999%(12 个 9),服务设计可用性(或业务连续性)不低于 99.995%。OSS按量使用且无需关心运维。使用oss进行文件的存储会使应用在稳定性、安全性、成本、弹性上都会具有优势。不用自己搭建和维护文件系统,开发人员可以把精力放在核心功能的开发上。 网盘元数据的存储 网盘元数据数据存储在Tablestore中,Tablestore可以提供10个9的数据可靠性保障,数据可靠性有保障。 网盘元数据和索引都在一个系统里面,写入,读取都是通过同一套API写入和查询,易用性更高。 Tablestore支持二级索引、多元索引等索引功能,完全满足上述提出的查询需求,包括: 多字段自由组合查询。 范围查询。 地理位置查询。 Tablestore是分布式系统,可以水平扩展,目前生产环境单表最大有几十PB,每秒写入有几千万行,完全能胜任任何大数据的写入和存储。 示例 接下来,我们通过一个网盘元数据的使用示例增加理解。 假设我们的产品需要给用户提供个人相册服务。将相册中的图片和视频存储在对象存储OSS中,将图片和视频的元数据(Meta Data)存储在表格存储Tablestore中。一般的文件Meta信息包括:用户id、文件id、文件类型、创建时间、照片地点、文件大小、文件状态、标签信息、OSS链接、是否星标文件、用户描述信息。 首先,我们设计Tablestore中主表结构: Tablestore的主表在查询时候可以理解为是几个主键列上的前缀索引,上述主表结构中,支持的查询包括: 一个用户下的所有图片或视频 一个用户某一段时间内的图片或视频 查看用户最近的图片或视频 Tablestore支持全局二级索引。全局二级索引支持在指定列上建立索引,生成的索引表中数据按您指定的索引列进行排序,主表的每一个写入都将自动异步同步到索引表。您只向主表中写入数据,根据索引表进行查询,在许多场景下,将极大的提高查询的效率。例如网盘场景下如果主键查询不能满足需求,对于简单的查询(例如单个字段查询)我们可以直接创建全局二级索引,通过全局二级索引能够快速的查询信息。 通过主表和二级索引,已经能够完成相册的基本功能,如文件的上传下载等。如果想完成更加丰富和复杂的查询功能(多个字段关联查询),可以使用Tablestore的多元索引功能,其中多元索引的索引定义: 用户描述信息采用了Text类型, 支持分词,分词使用了单字分词。该字段可以进行模糊匹配查询。 拍摄地点采用了GeoPoint类型,这个类型支持范围查询。比如某个点附近几公里内的所有照片,同时也支持某个多边形范围内的所有照片信息。 标签信息是Nested嵌套类型的,其支持的查询和非嵌套类型一致,只是使用方式上有一些小差别。 多元索引支持多字段组合查询,可以满足任意几个列与或非组合查询,如:查询文件类型是图片且拍摄地点是上海且文件大小超过100MB且是标星文件且创建时间在9月份之前的所有照片? 另外,可能还有一些统计聚合需求,在运营产品和制作报表时候可能会用到。这些都可以基于多元索引完成,比如: 目前所有用户的相册中一共有多少张?通过Aggregation.Count(*); 所有用户中超过1G的文件有多少个?GroupBy.GroupByRange(文件大小); 视频和图片各有多少张?通过 GroupBy.GroupByField(文件类型); 过去三年每年相册的上传数量? 通过 GroupBy.GroupByRange(创建时间); 视频文件中最大文件有多大? 通过Aggregation.Max(文件大小); ... 基于上述的表、二级索引和多元索引,我们就能满足几乎所有的查询需求。 总结 至此,开源解决方案和Tablestore云解决方案都介绍完了,使用了Tablestore云解决方案后,能带来不少的收益。 减少运维负担 在Tablestore云解决方案中,用户只需要运维自己的应用程序,不需要运维任何的存储系统、查询系统和其他中间件,运维压力大大减少,同时也减少了运维方面的成本开支。另外,Tablestore是Serverless的服务化产品,不需要关注扩容、水位等事项,只需要关注功能开发即可。 系统架构更简单 在开源解决方案中使用多个系统,而Tablestore解决方案中只有1个系统,系统数减少后,系统架构会更简单,系统可能产生的风险会更少,系统的稳定性等会更高,可以提供更优质的服务体验。 减少时间成本 Tablestore云架构方案相对于开源方案,系统更少,从零开始到上线需要的开发时间更少,同时,Tablestore是serverless的云服务,全球多个区域即开即用,可以大大降低客户的开发上线的时间,提前将产品推出,抢先争取市场领先优势。 数据可靠性更高 在开源解决方案中,数据有可能会丢失,影响最终的业务,而在Tablestore解决方案中,可以提供更高的可靠性,可以让数据不丢失,保障业务的准确执行。 扩展阅读 表格存储Tablestore权威指南(持续更新) 海量智能元数据管理系统实现解析 表格存储数据模型和查询操作 Aliyun Tablestore Examples 表格存储 (Tablestore) 提供专业的免费技术咨询服务,欢迎加入我们的钉钉讨论群。群号 : 23307953
以下所有过程以mac操作系统下(macOS 10.13.6)为例进行。 环境准备 安装jdk10 因为es需要高版本jdk进行开发,所以我们直接安装jdk10。 前往 http://www.oracle.com/technetwork/java/javase/downloads/jdk10-downloads-4416644.html ,直接下载安装mac的dmg版本即可。 jdk8和jdk10并存 如果平时开发用的jdk8,为了不影响之后使用,jdk8和jdk10将会同时存在。如果之前没用过java低版本请忽略。在 .bash_profile 中设置如下: export JAVA8_HOME=$(/usr/libexec/java_home -v 1.8) export JAVA10_HOME=$(/usr/libexec/java_home -v 10) # 默认为jdk8 export JAVA_HOME=$JAVA8_HOME # 随时切换jdk8 和jdk10 alias jdk8="export JAVA_HOME=$JAVA8_HOME" alias jdk10="export JAVA_HOME=$JAVA10_HOME" 其他操作系统与mac不同,请自行修改jdk配置。 IDEA开发准备 idea 自行下载宇宙第一IDE,我们这里使用的是IntelliJ IDEA 2018.2 (Ultimate Edition),不同版本应该影响不大。 建议将idea的内存调大,因为es项目有点大。 下载ES源码 我们以es 6.2.4为例,直接在github上下载es源码即可。https://github.com/elastic/elasticsearch/releases/tag/v6.2.4 解压到随意位置即可。 gradle操作 es 6.2.4 采用了宇宙第一自动化建构工具 gradle,相比maven会好用很多。 1. 修改maven源 首先,我们修改maven仓库源,打开源码根目录下的 build.gradle (相当于maven的pom.xml,但是功能强大,用法简单,如果用maven实现里面的功能会无比复杂): # 35行(import结束后)增加以下代码 allprojects { repositories { maven{ url 'http://maven.aliyun.com/nexus/content/groups/public/'} } } 2. 生成idea文件 为了idea能修改编译源码,需要执行 ./gradlew idea (windows下为 gradlew.bat idea),这里会经过漫长的过程,如果不换maven源的话,将几个小时。 idea打开源码 idea中直接open刚操作的es源码文件夹,右下角提示`import gradle project`,点击后弹出如下 如图选择jdk10,点击ok。 开始自动编译java源码,如下图红色框。 首次build,这个过程会比较慢,因为要额外下载jar包等。 运行源码 直接在idea中运行 打开文件工程根目录/server/src/main/org/elasticsearch/bootstrap/Elasticsearch.java,右键 Run Elasticsearch.main(),运行main方法。 提示报错 首先在项目根目录下创建3个文件夹 ./home/conf ./home/plugins ./home/modules 然后前往es官网下载一个binary版本的es6.2.4,将其中的conf全部复制到 ./home/conf,将全部的 modules 复制到./home/modules 在下图中增加jvm参数: -Des.path.conf=/Users/xunjian/Desktop/elasticsearch-6.2.4/home/config -Des.path.home=/Users/xunjian/Desktop/elasticsearch-6.2.4/home -Dlog4j2.disable.jmx=true 并勾选 包含provide scope的jar。 然后运行报错: org.elasticsearch.bootstrap.StartupException: java.security.AccessControlException: access denied ("java.lang.RuntimePermission" "createClassLoader") at org.elasticsearch.bootstrap.Elasticsearch.init(Elasticsearch.java:125) ~[main/:?] at org.elasticsearch.bootstrap.Elasticsearch.execute(Elasticsearch.java:112) ~[main/:?] at org.elasticsearch.cli.EnvironmentAwareCommand.execute(EnvironmentAwareCommand.java:86) ~[main/:?] at org.elasticsearch.cli.Command.mainWithoutErrorHandling(Command.java:124) ~[main/:?] at org.elasticsearch.cli.Command.main(Command.java:90) ~[main/:?] at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:92) ~[main/:?] at org.elasticsearch.bootstrap.Elasticsearch.main(Elasticsearch.java:85) ~[main/:?] Caused by: java.security.AccessControlException: access denied ("java.lang.RuntimePermission" "createClassLoader") 解决方法:在 home/config 新建 java.policy 文件,填入 grant { permission java.lang.RuntimePermission "createClassLoader"; }; 之前修改过的jvm参数中再增加一个 -Djava.security.policy=/Users/xunjian/Desktop/elasticsearch-6.2.4/home/config/java.policy 启动:不报错,正常启动。 修改源码 做个小小的例子,在 server/src/main/java/org/elasticsearch/action/main/MainResponse.java 中修改119行左右为 builder.field("tagline", "Hello ! 寻剑的ES ^_^"); 重新再idea中启动主函数,访问 http://localhost:9200/ 得到 Debug 直接用debug模式启动即可,然后随时添加断点和减少断点。以刚才我们修改的文件为例,增加两个断点: 浏览器访问网页,在idea中可方便的debug
本文将会给大家介绍如何开发一个简单的即时通讯系统(IM)。 为什么不简单 我们的站点加一个即时通讯(IM)的功能,那么我们怎么做? 在回家的路上,问了同是实习生(网络方向)的舍友这样一个问题,他回答:“很简单,只需要在服务端保存一个list,收到一个人的message后,我转发给list中指定的另一个人就好啦” 接着,我们讨论了一晚上下面的几个问题 对方不在线怎么办? 内存里保存list的话,人多了怎么办? 怎么查看历史记录?怎么多端同步数据? 为什么微信用户只能建立有限个群,并且群聊功能很晚才开放?为什么微信好友数是有上线的? 怎么保证消息有序、不丢失数据? 如何保证消息的时效性? 如何承担高并发流量? 最后讨论的结果是:开发一个稳定高效的IM产品是相对困难的,上面这些难题,QQ、微信等产品都遇到过。而且IM产品一旦量达到一定程度,性能、稳定性、可用性等的挑战会越来越大,开发维护都十分困难,需要不断的打磨锤炼,才能保证是一个可靠稳定的IM产品。 为什么简单 同样是一个IM的小白,在看到 tableStore产品的 timeline模型后,只花了一个下午的时间,就理解了IM和做出一个可使用的demo。 Timeline 模型是 TableStore 团队针对消息数据场景所新创的一个数据模型,它的特色在于能够满足消息数据场景对消息保序、海量消息存储、实时同步的特殊需求。目前 Timeline 模型主要能够解决以下场景的需求: IM : 如钉钉、微信 Feed流:如微博、朋友圈 IOT消息下推:如天猫精灵 无限Topic的队列 具体的文章可以参考: TableStore Timeline:轻松构建千万级IM和Feed流系统 TableStore数据模型 - WideColumn和Timeline 介绍 =================================================================== 接下来我们来完成这样的一个即时通讯产品的demo。正因为是一个demo,我们关注核心功能,所以在设计和其他功能上都会从简,方便大家理解和阅读。 1. 设计功能 一对一私聊 群聊 2. 表结构 目前版本的 timeline 只解决消息存储和同步问题,其他元数据相关的表还是需要我们自己来完成的。下面所有的表都使用tableStore 这款NoSQL分布式数据库进行存储,存储量和并发不用担心。 User 表 主要包含用户相关的信息。虽然大部分信息在demo中并没有使用。 private String userName; //用户名,为了简化,我们使用userName作为id,在tablestore中作为pk private int age; //年龄 private String gender; //性别 private String sign; //个性签名 Group 表 主要包括群组的信息。demo中实际上我们仅仅使用了 `groupName` private String groupName; //群名,为了简化,我们使用groupName作为id,在tablestore中作为pk private String groupType; //群标签,群类别 private String groupDescription; //群描述 groupUser 表 主要记录了一个群中包含哪些群成员,这样当收到一条群消息就知道了同步给哪些成员。在 tablestore中这样设计。 主键(pk) 类型 groupName String userName String 3. 工程结构 --------------------------------------------------------------------- 工程采用springboot做的后端框架,前端用了vue.js用来简单展示数据,具体代码附件中。 4. 核心代码逻辑 ----------------------------------------------------------------------- 工程上其实主要在使用框架(springboot、vue.js),这里就不在一一列举,下面主要介绍timeline相关的一些使用。而这些核心代码在官方的github仓库(https://github.com/aliyun/tablestore-timeline)的测试用例里也有样例代码,不仅仅有IM相关的,还有朋友圈、微博这种feed流场景的样例代码,而我作为一个使用者只需要拿过来直接用就好啦,十分方便! ### 4.1 给指定好友发送消息 /** * 发送个人消息 */ public void sendPersonalMessage(String userNameFrom, String userNameTo, IMessage message) { //创建发送方的timeline Timeline sender = new Timeline(userNameFrom, store); //存储消息:发送者存到自己的发件箱 sender.store(message); //创建接收者的timeline Timeline receiver = new Timeline(userNameTo, sync); //同步消息:存到接收者的收件箱 receiver.store(message); logger.debug("【" + userNameFrom + "】send Message to 【" + userNameTo + "】"); } [](https://lark.alipay.com/tablestore/timeline/vqg89g#ob2dbk)4.2 群发消息 /** * 发送群组消息 */ public void sendGroupMessage(String groupName, IMessage message) { //获取该群组所有的人员列表 List<String> groupMembers = userAndGroupService.listGroupMembers(groupName); logger.debug("Begin send Message to " + groupMembers.size() + " members"); //存储消息:存到自己的发件箱 Timeline sender = new Timeline(groupName, store); sender.store(message); //同步消息:给群里的所有人发一份, for (String user : groupMembers) { Timeline receiver = new Timeline(user, sync); receiver.store(message); } logger.debug("End send Message to " + groupMembers.size() + " members"); } 4.3 元数据、消息内容搜索 IM产品经常需要搜索数据,其中主要包括: 用户、群的元数据搜索 历史消息内容的搜索 这些功能将会在上线 TableStore 2.0 开篇:SearchIndex 后一起发布,届时Timeline模型将原生支持强大的搜索能力,满足开发者不同的需求。 运行 大家可以尝试自己运行一下代码,很简单的几个步骤就把系统运行起来了。 1. 开通服务(免费) tablestore有足够多的免费额度,可以做很多事情。我们去官网 https://www.aliyun.com/product/ots 开通实例,通过控制台创建一个实例 endpoint : 自己的实例的网址,类似 https://xxxxxx.cn-hangzhou.ots.aliyuncs.com instanceName :自己的实例的名字,即上面网址的前缀 2. 获取AK 阿里云所有的服务几乎都是通过AccessKeyID 和AccessKeySecret来做鉴权的。我们点击用户的如下按钮,按照提示获取一个AccessKeyID 和AccessKeySecret。 3. 运行代码 下载指定分支代码从附件中下载源码。 在源码中编译代码 linux下 ./gradlew build windows下: gradlew.bat build 提示:网络不好时间可能会比较久,如果自己本机安装过gradle的话,也可以直接使用 gradle build 来进行编译 启动项目 其中xxxx相关的要换成自己的阿里云实际的配置。 java -jar build/libs/im-demo.jar \ --aliyun.tablestore.endpoint="xxxxx" \ --aliyun.tablestore.AccessKeyID="xxxxx" \ --aliyun.tablestore.AccessKeySecret="xxxxx" \ --aliyun.tablestore.instanceName="im-demo" 浏览器访问 http://localhost:8081/ 自己多开几个页面孤独聊天,或者将地址发给好友一起体验! 感受 tableStore提供的timeline模型,把IM的开发变得如此简单,任何人都能够简单的使用,在并发、容量、消息顺序等各种问题上都不用担心。 简单的demo开发很容易,但是一个功能完善的IM产品开发,还是需要开发人员了解Timeline如何和其他组件一起使用。Timeline仅仅是提供了IM产品的核心支持,作为一个企业级的IM产品支柱而存在。如果Timeline产品能提供补充IM产品的完整设计,那么相信更多的人会感觉IM易如反掌。 Demo的细节设计上目前都是从简的,仅为了体验Timeline模型。
本文将会给大家介绍如何开发一个简单的即时通讯系统(IM)。 为什么不简单 我们的站点加一个即时通讯(IM)的功能,那么我们怎么做? 在回家的路上,问了同是实习生(网络方向)的舍友这样一个问题,他回答:“很简单,只需要在服务端保存一个list,收到一个人的message后,我转发给list中指定的另一个人就好啦” 接着,我们讨论了一晚上下面的几个问题 对方不在线怎么办? 内存里保存list的话,人多了怎么办? 怎么查看历史记录?怎么多端同步数据? 为什么微信用户只能建立有限个群,并且群聊功能很晚才开放?为什么微信好友数是有上线的? 怎么保证消息有序、不丢失数据? 如何保证消息的时效性? 如何承担高并发流量? 最后讨论的结果是:开发一个稳定高效的IM产品是相对困难的,上面这些难题,QQ、微信等产品都遇到过。而且IM产品一旦量达到一定程度,性能、稳定性、可用性等的挑战会越来越大,开发维护都十分困难,需要不断的打磨锤炼,才能保证是一个可靠稳定的IM产品。 为什么简单 同样是一个IM的小白,在看到 tableStore产品的 timeline模型后,只花了一个下午的时间,就理解了IM和做出一个可使用的demo。 Timeline 模型是 TableStore 团队针对消息数据场景所新创的一个数据模型,它的特色在于能够满足消息数据场景对消息保序、海量消息存储、实时同步的特殊需求。目前 Timeline 模型主要能够解决以下场景的需求: IM : 如钉钉、微信 Feed流:如微博、朋友圈 IOT消息下推:如天猫精灵 无限Topic的队列 具体的文章可以参考: TableStore Timeline:轻松构建千万级IM和Feed流系统 TableStore数据模型 - WideColumn和Timeline 介绍 接下来我们来完成这样的一个即时通讯产品的demo。正因为是一个demo,我们关注核心功能,所以在设计和其他功能上都会从简,方便大家理解和阅读。 1. 设计功能 一对一私聊 群聊 2. 表结构 目前版本的 timeline 只解决消息存储和同步问题,其他元数据相关的表还是需要我们自己来完成的。下面所有的表都使用tableStore 这款NoSQL分布式数据库进行存储,存储量和并发不用担心。 1.User 表 主要包含用户相关的信息。虽然大部分信息在demo中并没有使用。 private String userName; //用户名,为了简化,我们使用userName作为id,在tablestore中作为pk private int age; //年龄 private String gender; //性别 private String sign; //个性签名 2.Group 表 主要包括群组的信息。demo中实际上我们仅仅使用了 groupName private String groupName; //群名,为了简化,我们使用groupName作为id,在tablestore中作为pk private String groupType; //群标签,群类别 private String groupDescription; //群描述 3.groupUser 表 主要记录了一个群中包含哪些群成员,这样当收到一条群消息就知道了同步给哪些成员。在 tablestore中这样设计。 主键(pk) 类型 groupName String userName String 3. 工程结构 工程采用springboot做的后端框架,前端用了vue.js用来简单展示数据,具体代码附件中。 4. 核心代码逻辑 工程上其实主要在使用框架(springboot、vue.js),这里就不在一一列举,下面主要介绍timeline相关的一些使用。而这些核心代码在官方的github仓库(https://github.com/aliyun/tablestore-timeline)的测试用例里也有样例代码,不仅仅有IM相关的,还有朋友圈、微博这种feed流场景的样例代码,而我作为一个使用者只需要拿过来直接用就好啦,十分方便! 4.1 给指定好友发送消息 /** * 发送个人消息 */ public void sendPersonalMessage(String userNameFrom, String userNameTo, IMessage message) { //创建发送方的timeline Timeline sender = new Timeline(userNameFrom, store); //存储消息:发送者存到自己的发件箱 sender.store(message); //创建接收者的timeline Timeline receiver = new Timeline(userNameTo, sync); //同步消息:存到接收者的收件箱 receiver.store(message); logger.debug("【" + userNameFrom + "】send Message to 【" + userNameTo + "】"); } 4.2 群发消息 /** * 发送群组消息 */ public void sendGroupMessage(String groupName, IMessage message) { //获取该群组所有的人员列表 List<String> groupMembers = userAndGroupService.listGroupMembers(groupName); logger.debug("Begin send Message to " + groupMembers.size() + " members"); //存储消息:存到自己的发件箱 Timeline sender = new Timeline(groupName, store); sender.store(message); //同步消息:给群里的所有人发一份, for (String user : groupMembers) { Timeline receiver = new Timeline(user, sync); receiver.store(message); } logger.debug("End send Message to " + groupMembers.size() + " members"); } 4.3 元数据、消息内容搜索 IM产品经常需要搜索数据,其中主要包括: 用户、群的元数据搜索 历史消息内容的搜索 这些功能将会在上线 TableStore 2.0的SearchIndex功能后一起发布,届时Timeline模型将原生支持强大的搜索能力,满足开发者不同的需求。 运行 大家可以尝试自己运行一下代码,很简单的几个步骤就把系统运行起来了。 1. 开通服务(免费) tablestore有足够多的免费额度,可以做很多事情。我们去官网 https://www.aliyun.com/product/ots 开通实例,通过控制台创建一个实例 endpoint : 自己的实例的网址,类似 https://xxxxxx.cn-hangzhou.ots.aliyuncs.com instanceName :自己的实例的名字,即上面网址的前缀 2. 获取AK 阿里云所有的服务几乎都是通过AccessKeyID 和AccessKeySecret来做鉴权的。我们点击用户的如下按钮,按照提示获取一个AccessKeyID 和AccessKeySecret。 3. 运行代码 下载指定分支代码从附件中下载源码。 在源码中编译代码 linux下 ./gradlew build windows下: gradlew.bat build 提示:网络不好时间可能会比较久,如果自己本机安装过gradle的话,也可以直接使用 gradle build 来进行编译 启动项目 其中xxxx相关的要换成自己的阿里云实际的配置。 java -jar build/libs/im-demo.jar \ --aliyun.tablestore.endpoint="xxxxx" \ --aliyun.tablestore.AccessKeyID="xxxxx" \ --aliyun.tablestore.AccessKeySecret="xxxxx" \ --aliyun.tablestore.instanceName="xxxxx" 浏览器访问 http://localhost:8081/ 自己多开几个页面孤独聊天,或者将地址发给好友一起体验! 感受 tableStore提供的timeline模型,把IM的开发变得如此简单,任何人都能够简单的使用,在并发、容量、消息顺序等各种问题上都不用担心。 简单的demo开发很容易,但是一个功能完善的IM产品开发,还是需要开发人员了解Timeline如何和其他组件一起使用。Timeline仅仅是提供了IM产品的核心支持,作为一个企业级的IM产品支柱而存在。如果Timeline产品能提供补充IM产品的完整设计,那么相信更多的人会感觉IM易如反掌。 Demo的细节设计上目前都是从简的,仅为了体验Timeline模型。 最后,欢迎加入我们的钉钉群(群号:11722164)进行交流。
2022年08月