文章目录
背景
系统内的订单查询模块整体上要查2张表
- 一张为订单核心表-order,该表主要存储订单ID,订单创建时间,订单联系人主键ID-contact_id
- 另一张为订单联系人表-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数据库基础上去实现。整体技术方案如下
- 将Oracle数据库数据实时迁移到PostgreSQL数据库
- 监测PostgreSQL数据库相关表的变化
- 将数据库相关表的数据进行组合然后写入到Elasticsearch中
- 根据模糊匹配字段去Elasticsearch中模糊查询该数据对应的contact_id
- 拿到contact_id之后再去数据库查询得到order_id
- 根据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-*"
]
}
- 我们为这种快速搜索只提供近5年的订单数据查询,所以我们会按照年来分割index,index示例:order-contact-2021, order-contact-2022等。这样有助于我们优化查询和清理历史遗留数据
- 由于我们要求ES的模糊查询完全等于数据库like,所以不能对查询字段做分词,都使用Keyword类型。为了规范输出,我们将所有字段都转为小写,所以需要自定义分词器
- 使用wildcard查询来替代数据库like查询,详细写法见Elasticsearch查询中有关wildcard的示例
- 验证分词器和查询是否满足我们预期
- 我们对上述自定义分词器,使用中文,英文,日语,韩语,德语,emoji表情都进行了测试,查询结果都符合我们预期,和数据库like表现一致
- Elasticsearch自身的document_id我们指定为order_id,而不是其内部生成
- 时间戳字段@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个或N个index,例如查询开始日期为2022-01-01,结束日期为2023-01-01的订单,我们只需要查询order-contact-2022和order-contact-2023即可
- 对于分页查询,使用search_after,它和前面提到的游标查询思想一样
- 拆分查询
- 当Elasticsearch中index的最大max_date的日期比当前查询的结束日期大,则查询全部走Elasticsearch
- 如果比当前日期小,那么我们会把查询分为[startDate, maxDateFromEs],这个区间查询走Elasticsearch;(maxDateFromEs, endDate] 这个区间查询走数据库
- 如果为max_date和endDate是同一天,我们还是会走2的逻辑,只不过这时会让max_date - 1天,因为我们认为同步数据差异不会超过1天
生产环境Elasticsearch集群规格
- 自建3节点集群
- 每台instance的配置为Rocky 8 + ARM + 4 CUP + 16GB 内存 + 60GB 磁盘空间
- UI工具并没安装Kibana,为了操作方便,使用Elasticvue