使用Elasticsearch代替数据库like以加快查询的各种技术方案+实现细节

文章目录

背景

系统内的订单查询模块整体上要查2张表

  1. 一张为订单核心表-order,该表主要存储订单ID,订单创建时间,订单联系人主键ID-contact_id
  2. 另一张为订单联系人表-contact,该表主要存储联系人信息,如:电话,名字,国家,州,市,邮件,邮编,街道信息等

查询逻辑主要是根据上述第二张表的各种信息模糊查询+订单创建时间过滤。SQL逻辑大致为

sql 复制代码
select c.*, o.order_id 
from order o 
join contact c on c.contact_id = o.contact_id
where c.first_name like '%test%' and 或各种其他过滤条件组合

系统当前order表和contact表几十亿,且前期已经通过优化索引的方式进行了大量优化,索引部分已无可优化空间。

所以我们决定使用Elasticsearch来帮助我们提升模糊查询的速度。

Q1 : 为什么有这么多数据,不清理吗?
A1: 这几十亿数据是从系统上线第一天到现在,十几年的所有数据。另一个团队正在做整体数据库的数据清理工作。但优化查询逻辑不可能等到另外一个团队把这部分清理完了在做,所以这就是面临的第1个问题,几十亿数据的迁移且不能出错

技术方案

由于我们系统正在从Oracle数据库逐渐向PostgreSQL数据库迁移,我们希望这个优化是在PostgreSQL数据库基础上去实现。整体技术方案如下

  1. 将Oracle数据库数据实时迁移到PostgreSQL数据库
  2. 监测PostgreSQL数据库相关表的变化
  3. 将数据库相关表的数据进行组合然后写入到Elasticsearch中
  4. 根据模糊匹配字段去Elasticsearch中模糊查询该数据对应的contact_id
  5. 拿到contact_id之后再去数据库查询得到order_id
  6. 根据order_id进行最终查询后返回结果给前端

Q1 : 为什么要再次查询数据库,不能把所有信息放在Elasticsearch中吗?
A1 : 实际的查询业务很复杂,涉及到的表很多,各表之间的关联关系也复杂。很难将所有必要信息都整合到一张宽表了,特别是考虑到现实中系统里的数据是十几年累计下来的所有数据,非常困难。上面给的示例SQL是非常简略的版本。同时,整体查询流程虽然复杂,但是瓶颈在于联系人表的模糊查询,所以我们只需要把模糊查询替换掉,拿到对应的contact_id,再去查询就非常快了。所以上述SQL在优化后变为

sql 复制代码
select c.*, o.order_id 
from order o 
join contact c on c.contact_id = o.contact_id
where c.contact_id in ()

传进去的contact_id集合就来自于Elasticsearch

Q2 :为什么不使用Clickhouse?它占用的资源比Elasticsearch还少
A2:我们正在逐步的把各种监控日志由Elasticsearch向Clickhouse迁移,未来是有替换掉Elasticsearch的计划。但是基于团队成员目前对Clickhouse的最佳实践了解有限,且Clickhouse还从未正式进入过生产环境,加上团队成员对Elasticsearch的各种实践都非常熟悉,所以暂时选择Elasticsearch

异构数据迁移

在把Oracle数据实时迁移到PostgreSQL这部分上,我们选择商业付费软件Qlick来帮助我们完成。我们也考虑准备使用开源的CDC解决方案替换掉它,但是我们团队对这种海量数据的同步的各种技术细节还在实验阶段,还无法推到生产环境。想要了解开源CDC的实现demo,可以参考利用Kafka Connect+Debezium通过CDC方式将Oracle数据库的数据同步至PostgreSQL中以及实现缓存一致性 这篇博文

Elasticsearch索引和分词器设计

json 复制代码
PUT _index_template/order-contact-template
{
  "priority": 1,
  "template": {
    "settings": {
      "index": {
        "number_of_shards": "3",
        "number_of_replicas": "1"
      },
      "analysis": {
        "analyzer": {
          "order_contact_keyword_exact": {
            "type": "custom",
            "tokenizer": "keyword",
            "filter": [
              "lowercase"
            ]
          }
        }
      }
    },
    "mappings": {
      "properties": {
        "@timestamp": {
          "type": "date"
        },
        "city": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        },
        "company": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        },
        "country_iso3": {
          "type": "keyword"
        },
        "email": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        },
        "first_name": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        },
        "last_name": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        },
        "order_id": {
          "type": "keyword"
        },
        "organization_id_ref": {
          "type": "keyword"
        },
        "phone": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        },
        "postal_code": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        },
        "state": {
          "type": "keyword"
        },
        "street_address": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        },
        "title": {
          "type": "text",
          "analyzer": "order_contact_keyword_exact"
        }
      }
    },
    "aliases": {}
  },
  "index_patterns": [
    "order-contact-*"
  ]
}
  1. 我们为这种快速搜索只提供近5年的订单数据查询,所以我们会按照年来分割index,index示例:order-contact-2021, order-contact-2022等。这样有助于我们优化查询和清理历史遗留数据
  2. 由于我们要求ES的模糊查询完全等于数据库like,所以不能对查询字段做分词,都使用Keyword类型。为了规范输出,我们将所有字段都转为小写,所以需要自定义分词器
  3. 使用wildcard查询来替代数据库like查询,详细写法见Elasticsearch查询中有关wildcard的示例
  4. 验证分词器和查询是否满足我们预期
    • 我们对上述自定义分词器,使用中文,英文,日语,韩语,德语,emoji表情都进行了测试,查询结果都符合我们预期,和数据库like表现一致
  5. Elasticsearch自身的document_id我们指定为order_id,而不是其内部生成
  6. 时间戳字段@timestamp实际为order表的creation_date

同步数据

同步数据我们都在Java代码中完成,其实最好的方式是使用CDC.

统一时间为UTC

Elasticsearch中的时间都存储的是UTC时间,所以在写入Elasticsearch之前需要将数据库时间戳转为UTC,且涉及到冬令时EDT和夏令时EST切换,所以对应字段类型为OffsetDateTime

java 复制代码
document.setCreationDate(localDateTime
                    .atZone(ZoneId.of("America/New_York")
                    .withZoneSameInstant(ZoneOffset.UTC)
                    .toOffsetDateTime());

同样在下面查询部分也要把时间转为UTC时间

同步存量历史数据

对于历史数据,我们按照1个月为区间来同步

验证Elasticsearch和数据库总数
sql 复制代码
SELECT
	COUNT(O.ORDER_ID)
FROM
	ORDER O
	JOIN CONTACT C ON O.CONTACT_ID = C.CONTACT_ID
WHERE
	O.CONTACT_ID IS NOT NULL
	AND O.CREATION_DATE IS NOT NULL
	AND O.CREATION_DATE >= '2021-01-01'::date 
    AND O.CREATION_DATE < '2021-02-01'::date;

Elasticsearch查询时需要声明时区

json 复制代码
POST order-contact-2021/_count
{
  "query": {
    "range": {
      "@timestamp": {
        "time_zone": "America/New_York",
        "gte": "2021-01-01",
        "le": "2021-02-01"
      }
    }
  }
}
Elasticsearch和数据库总数不相等

同事在做测试时,发现写入Elasticsearch的数据条数和数据库不一致,比数据库要少。第一感觉应该是写入Elasticsearch的数据有重复,所以给覆盖了。但是我们是根据order_id来作为document_id的,有点没道理。去review了下同事的代码,发现他在使用LIMIT OFFSET分页查询时,ORDER BY 的条件是订单创建时间,这就说的通了,因为订单创建时间很可能是一样的,那么对于这种不是唯一键的排序的结果集是不可预测的。以下内容引用自PostgreSQL官方文档

When using LIMIT, it is important to use an ORDER BY clause that constrains the result rows into a unique order. Otherwise you will get an unpredictable subset of the query's rows

把ORDER BY 排序列改为order_id,问题解决

深度分页导致数据库CPU飙升

当某个月的数据超过100万时,数据库服务器CPU飙升,一度飙升到90%。应该是LIMIT OFFSET导致的,我们的分页大小为5000。

把同步每个月的数据的逻辑拆分为按照每天来查询,优化后的代码如下

java 复制代码
LocalDate startDate = LocalDate.of(year, month, 1);
LocalDate endDate = startDate.with(TemporalAdjusters.lastDayOfMonth());
 for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
     while (true) {
         queryDatabaseThenWriteIntoElasticsearch()
         if (noMoreDatas) {
             break;
         }
     }
 }

同时不使用OFFSET,使用游标查询,也就是keyset (seek) pagination。把上一次查询结果集最后1条数据的order_id传入下一次查询中。优化后的代码如下

java 复制代码
" where o.order_id > " + seek +
" and o.contact_id is not null and o.creation_date is not null" +
" and o.creation_date >= '" + currentDay + " 00:00:00'" +
" and o.creation_date < '" + nextDayStr + " 00:00:00'" + 
" order by o.order_id LIMIT " + PAGE_SIZE

同步增量数据

捕获变动的数据

前面说到过,Oracle数据库的变动可以由Qlik负责实时同步到PostgreSQL中。但是我们同步数据到Elasticsearch的数据源是PostgreSQL,如何知道order表和contact表发生变化了呢?最好的方式是使用CDC. 鉴于前文所述原因,我们暂时使用数据库触发器+Java代码定期轮询来实现

当Qlik将Oracle数据库的变化同步到PostgreSQL时,其底层也是会触发insert/update的,所以触发器就设置在这个时机,利用触发器把变动的数据写入一个宽表

sql 复制代码
create function insert_changed_contact()
    returns trigger as
$$
declare
    v_operation_type smallint;
    v_contact_id     bigint;
begin
    if tg_op = 'INSERT' then
        v_operation_type := 1;
        v_contact_id := new.contact_id;
    elsif tg_op = 'UPDATE' then
        v_operation_type := 2;
        v_contact_id := old.contact_id;
    elsif tg_op = 'DELETE' then
        v_operation_type := 3;
        v_contact_id := old.contact_id;
    end if;

    insert into changed_contact (contact_id,
                                 title,
                                 city,
                                 state,
                                 zip,
                                 company,
                                 country_iso3,
                                 organization_id_ref,
                                 email,
                                 first_name,
                                 last_name,
                                 address1,
                                 day_phone,
                                 operation)
    values (v_contact_id,
            new.title,
            new.city,
            new.state,
            new.zip,
            new.company,
            new.country_iso3,
            new.organization_id_ref,
            new.email,
            new.first_name,
            new.last_name,
            new.address1,
            new.day_phone,
            v_operation_type);

    return new;
    -- For BEFORE triggers, return NEW to allow the operation to proceed (potentially modified)
    -- For AFTER triggers, return NEW (or OLD if appropriate)
end;
$$ language plpgsql;

create trigger contact_changed_trigger
    after insert or update or delete
    on contact
    for each row
execute function insert_changed_contact();

通过Java定时任务定期轮询查询新数据写入Elasticsearch,然后就把写入的数据从宽表中删除

Logstash同步(备选)

这一方案实际上是Java代码的翻版,只不过所有工作都交给Logstash去做。由于我们决定逐渐抛弃ELK技术栈+Logstash也非常吃资源,所以最终没有选择该方案。这里给出当时的实现,下列配置都经过严格的验证,可以根据自己实际情况优化具体配置

logstash配置优化
yaml 复制代码
- pipeline.id: eng-5361
  # Logstash tries to load only files with .conf extension in the conf directory and ignores all other files, so we use .cfg extension
  path.config: "/usr/share/logstash/pipeline/logstash-eng-5361.cfg"
  # default value is 125
  pipeline.batch.size: 100000
  pipeline.workers: 1
pipeline
shell 复制代码
input {
    jdbc {
        jdbc_driver_library => "/usr/share/logstash/postgresql-42.5.0.jar"
        jdbc_driver_class => "org.postgresql.Driver"
        jdbc_connection_string => "${JDBC_CONNECTION_STRING}"
        jdbc_user => "${JDBC_USER}"
        jdbc_password => "${JDBC_PASSWORD}"
        jdbc_default_timezone => "UTC"
        schedule => "*/10 * * * * *"
        statement_filepath => "/tmp/data-import/sql/delta.sql"
        jdbc_fetch_size => 100000
        jdbc_paging_enabled => true
        jdbc_page_size => 100000
        use_column_value => true
        tracking_column_type => "numeric"
        tracking_column => "unix_ts_in_secs"
        last_run_metadata_path => "/tmp/data-import/last-value/order-contact-info-sql_last_value.yml"
        tags => ["order_contact_info"]
    }
}
filter {
    if "order_contact_info" in [tags] {
        mutate {
            rename => {"order_creation_date" => "@timestamp"}
            copy => { "order_id" => "[@metadata][order_id]" }
            remove_field => ["order_id","contact_update_date","unix_ts_in_secs"]
        }
    }
}
output {
    if "order_contact_info" in [tags] {
        elasticsearch {
            hosts => "elasticsearch:9200"
            ssl => true
            cacert => "config/elasticsearch-ca.pem"
            user => "elastic"
            password => "${ELASTIC_PASSWORD}"
            index => "order-contact-%{+YYYY}"
            action => "update"
            doc_as_upsert => true
            document_id => "%{[@metadata][order_id]}"
        }
    }
}
delta.sql内容
sql 复制代码
SELECT c.*,extract(epoch from c.contact_update_date) AS unix_ts_in_secs 
FROM contact c 
WHERE extract(epoch from c.contact_update_date) > :sql_last_value 
AND c.contact_update_date < LOCALTIMESTAMP 
ORDER BY c.contact_update_date ASC, contact_id

Q1 :这里为什么要把时间转为unix时间戳并且要大于最后一次同步的值并小于当前时间戳呢?
A1 :见 Elasticsearch官方博客,这篇博客演示的是如何把MySQL中的数据通过Logstash定期刷到Elasticsearch中,里面详细解释了为什么这么写

order-contact-info-sql_last_value.yml的值就是上一次同步的最后1条数据的unix时间戳。上述配置为同步增量数据的配置,对于存量数据,配置大同小异,看下Logstash文档即可反推出来

查询

对于查询,我们需要同时支持数据可以从Elasticsearch和数据库中查询。以防Elasticsearch挂掉或处于维护升级时,查询模块无法服务的场景。

对于Elasticseach的查询逻辑优化则为

  1. 根据查询范围,只查询1个或N个index,例如查询开始日期为2022-01-01,结束日期为2023-01-01的订单,我们只需要查询order-contact-2022和order-contact-2023即可
  2. 对于分页查询,使用search_after,它和前面提到的游标查询思想一样
  3. 拆分查询
    • 当Elasticsearch中index的最大max_date的日期比当前查询的结束日期大,则查询全部走Elasticsearch
    • 如果比当前日期小,那么我们会把查询分为[startDate, maxDateFromEs],这个区间查询走Elasticsearch;(maxDateFromEs, endDate] 这个区间查询走数据库
    • 如果为max_date和endDate是同一天,我们还是会走2的逻辑,只不过这时会让max_date - 1天,因为我们认为同步数据差异不会超过1天

生产环境Elasticsearch集群规格

  1. 自建3节点集群
  2. 每台instance的配置为Rocky 8 + ARM + 4 CUP + 16GB 内存 + 60GB 磁盘空间
  3. UI工具并没安装Kibana,为了操作方便,使用Elasticvue
相关推荐
LDG_AGI2 小时前
【搜索引擎】Elasticsearch(五):prefix前缀匹配方法大全(包含search_as_you_type等6种解法)
人工智能·深度学习·算法·elasticsearch·搜索引擎
isNotNullX2 小时前
数据分析指标有哪些?如何理解常见数据分析指标?
大数据·数据挖掘·数据分析
AnalogElectronic2 小时前
拉多买菜项目报告
大数据·人工智能
智能化咨询2 小时前
(199页PPT)DG企业架构企业IT战略规划架构设计方案(附下载方式)
大数据·架构
亚林瓜子2 小时前
AWS Catalog中数据搬到Catalog中
大数据·python·spark·云计算·aws·pyspark·glue
金融小师妹2 小时前
AI政策函数重构视角:凯文·沃什听证前信号释放与联储独立性再定价
大数据·人工智能·深度学习·能源
趣味科技v2 小时前
当人工智能遇上科研:AI4S开启未来科技新篇章
人工智能·科技·搜索引擎·百度
一生了无挂2 小时前
Python大数据可视化:基于大数据技术的共享单车数据分析与辅助管理系统_flask+hadoop+spider
大数据·python·信息可视化