问题描述
高并发账户记录查询在银行、互联网企业、通信企业中广泛存在。例如:网上银行、手机银行、电商个人账户查询、互联网游戏账户等等。这类查询有三个共同点:
1、 数据总量非常大。用户数量本身就非常多,再加上多年的账户数据,数据量可以达到几千万甚至上亿条。
2、 访问人数众多。几百万甚至上千万人访问,属于高并发查询。
3、 不能让用户等待。手机、网页要达到秒级响应,否则严重影响用户体验。
下面以某银行账户活期明细查询为例,给出这类问题的解决办法。
某银行共一亿个活期账户,每个账户平均每月有7条数据,每年数据总量84亿条。每条数据中的机构字段,还要关联分支机构表(几千条)记录。在性能上,要求单台服务器支持一千个以上的查询,响应时间不能超过1秒。
有序行存
活期明细数据随着时间增长非常快,一年就有84亿条。如果放到内存中,需要大量内存空间,硬件投入成本太高,所以要放到硬盘上存储。分支机构表只有几千条数据,可以放在内存中存储。
在硬盘上存储,要考虑是行存还是列存。列存数据分块压缩,能减少遍历数据量。但由于账户查询是随机的,整块读取会有额外解压计算。而且每次取数都针对整个分块,复杂度较高,性能不如行存。因此,这个场景要选择行存存储,如下图:
图1:行存和列存
具体的实现可以采用Java、C++、SPL等高级语言。这里我们以代码量最少的SPL语言为例讲解。
|
A |
1 | =connect("db").cursor(“select * from detail order by id") |
2 | =file("detailR.ctx") |
3 | =A2.create@r(ID,CORPID,AMT).append(B1) |
代码示例1
A1:连接生产数据库,用游标读取活期存款数据,按照账户id排序。
A2:建立本地组表文件。
A3:建立组表,并从数据库游标读取活期存款明细数据,写入文件。
其中,A3中的@r选项,就是建立行存文件。一年84亿条数据都导出,时间会比较长。但是这是一次性的工作,后续就只需要追加增量数据即可。增量数据的追加方法,后面会有介绍。如果按照账户排序会对生产数据库造成较大压力,可以导出之后基于文件排序。排序使用SPL的sortx函数,具体用法参见函数参考。
利用索引
要利用索引提速,先要对明细文件建立索引。由于明细数据量大,建立的索引文件也会很大。很难全部加载到内存中。可以建立多级索引,如下图:
图2:多级缓存
还是以SPL为例,建立多级索引,只需要在“代码示例1”的基础上,增加一个网格即可:
|
A |
1 | =connect("db").cursor(“select * from detail order by id") |
2 | =file("detailR.ctx") |
3 | =A2.create@r(ID,CORPID,AMT).append(B1) |
4 | =A2.open().index(index_detailR_id;ID) |
代码示例2
A4:对行存文件建立索引文件。
加载的索引级别越多,占用存储空间越大。同时,账户id的跨度变小,加载到内存中后,索引效果也会变好。查询时可以根据内存大小,尽可能加载更多级别的索引,可以有效提高查询速度。
在查询之前,系统初始化或者数据变动时,要预先加载多级索引,以SPL代码为例:
|
A | B |
1 | if !ifv(detailR) | =file("detailR.ctx").open().index@3(index_detailR_id) |
2 |
|
=env(detailR,B1) |
代码示例3
A1:判断全局变量中是否存在detailR,如果存在,表示已经加载了索引。
B1:如果全局变量中没有detailR,那么打开组表加载三级索引。@2或者@3表示加载2或者3级索引。
B2:detailR存入全局变量。
这段预先加载的初始化代码(代码示例3),可以保存成init.dfx,放入集算器主目录(main path),在节点机(unitServer)启动的时候会自动被调用,如下图:
图3:节点机自动调用初始化代码
由于我们提前准备好的活期数据是对账户id物理有序的,查询时根据索引找到账户id后,可以在硬盘连续读取,显著减少磁盘IO,有效提速,如下图:
图4:索引和有序行存
查询账户10100,先利用内存中预先加载的多级索引和索引文件,快速定位到活期明细数据文件中的位置,再连续读取到账户id有变化为止。因为数据是按照账户id物理有序的,所以文件的其他位置不可能再有10100账户的数据了。
利用索引查询的示例代码如下:
|
A |
1 | =detailR.icursor (;ID=="10100",index_detailR_id).fetch() |
代码示例4
A1:全程变量detailR已经缓存了三级索引,现在可以基于它利用索引,按照条件取出账户为10100的记录。
每个账户的数据量并不大,所以可以全部读入内存。
活期明细前端应用(网页或者APP),要通过集算器的JDBC驱动调用SPL代码查询。调用的时候,需要将账户id作为参数传给SPL程序。例如:定义一个网格参数countid,传入账户id为10100。A1中的代码就要改为:=detailR.icursor (;ID==countid,index_detailR_id).fetch()。
关联查询
查到指定账户数据装入内存后,可以将机构数据也读入内存。在内存中关联计算,性能可以得到保障。示例代码如下:
|
A |
1 | =detailR.icursor (;ID==countid,index_detailR_id).fetch() |
2 | =file("corp.btx").import@b(corp_id,corp_name).keys(corpid) |
3 | =A1.switch(corp_id,A2:corp_id) |
4 | return A3.new(id,corp_id.corp_name:corp_name,amt) |
代码示例5
A2:读入机构数据。
A3:账号10100的活期明细数据关联机构数据。
A4:将账户id、分支机构名称和金额返回给前端调用者(网页或者APP)。
机构数据可以在应用系统初始化的时候加载入内存,不必每次读取,查询速度更快。在上面提到的init.dfx中增加代码如下:
|
A | B |
1 | if !ifv(detailR) | =file("detailR.ctx").open().index@3(index_detailR_id) |
2 |
|
=env(detailR,B1) |
3 | if !ifv(corp) | =file("corp.btx").import@b(corp_id,corp_name).keys(corpid) |
4 |
|
=env(corp,B3) |
代码示例6
预先加载机构数据之后,查询代码要在“代码示例5”的基础上去掉加载机构数据的部分,修改之后的查询代码如下:
|
A |
1 | =detailR.icursor (;ID==countid,index_detailR_id).fetch() |
2 | =A1.switch(corp_id,corp:corp_id) |
3 | return A3.new(id,corp_id.corp_name:corp_name,amt) |
代码示例7
A2:直接用预先加载的全程变量corp和活期明细数据关联计算,省去了每次查询加载机构数据的时间。
数据更新
活期明细存款是按账号有序的,并不是按日期有序。所以,不能在末尾追加当日新增数据。活期明细几十亿条,如果每天有序归并新数据的话,耗时太长。每月归并一次的方案比较理想。如果将活期明细数据看成是主文件,那么更新原理如下图:
图5:数据更新
数据更新的示例代码如下:
|
A | B | C |
1 | if day(now())==1 | =file("detailR.ctx").reset() | /每月重整 |
2 | =connect("db").cursor(“select * from detail where date=?",date(now())) | /读当日数据 | |
3 | =file("detailR.ctx").open().append@a(A2) | /每日归并 |
代码示例8
A1、B1:如果是每月1日,重整文件。
A2:从生产数据库中读入当日数据。
A3:将当日数据有序归并到集算器自动生成的补文件中。