亚马逊AWS官方博客

Amazon DynamoDB 在金融交易日志和行情的应用

金融行业中,有很多高性能数据读写的场景,例如在线交易、交易日志、行情等。对于 ACID(Atomicity, Consistency, Isolation, and Durability)事务性严格要求的在线交易,一般采用 SQL 关系型数据库,保证金融交易事务的完整性和安全性。但在面对高并发查询和大规模数据存储时,往往难以满足实时性要求。因此,金融企业在设计数据库架构时,需要在数据一致性、查询性能和扩展能力之间做出权衡,选择适合高吞吐交易日志存储的解决方案。

交易日志

除了交易处理之外,还需要记录每笔交易的相关数据。例如,用户需要查询近一个月之内金额大于 10000 的交易。此类查询如果在线上交易数据库去做,在用户并发量大的情况下,会对在线交易产生严重影响。如果要进一步提高查询性能,需要为相关字段创建索引,而每次写入数据时索引更新反过来也会影响写入性能。因此,对于交易的查询,可以单独存储在交易日志数据库。交易数据除了写入在线交易数据库,还可以通过各种组件写入交易日志。查询交易情况可以在交易日志进行。交易日志的特点是,大量 Insert 写入,几乎很少 Update 更新,大量并发且低延迟的查询。

交易日志一般需要记录的数据有:交易 id、时间、买方,卖方、交易种类、币种、交易量。与传统关系型数据库经常需要跨表 join 不同,一般单个表查询即可满足需求。这种场景下,对数据库的读写性能提出更高的要求。在交易频繁的市场中,每秒可以达到十万或者更多的交易,一个月至少 100 亿条数据,十几 TB 存储容量。如此大的数据量下,单个 SQL 表受限于 B+ 树索引的层级架构,查询性能受到严重影响。单纯的扩大机型,对性能提升帮助不大。分库分表可以增加扩展性,但是会引入 proxy 中间层,由此带来额外的运维管理问题,而且对表的设计也有限制,非分片键的查询,或者跨分片查询受限,业务不能灵活修改。

NoSQL 数据库可以解决以上问题。Amazon NoSQL 数据库包括 Amazon DynamoDB, Amazon DocumentDB, Amazon Elasticache, Amazon MemoryDB。其中,Amazon DynamoDB 是基于 Key Value 的,表设计灵活,结合二级索引可以实现多种高效查询;纯 Serverless 架构,无需管理底层基础设施,根据流量在后台进行自动分区,也无需补丁维护或者升级数据库版本;在性能方面,多个客户实践证明,在几十几百 TB 大规模数据量下,在读写请求达到几十万/秒时,仍然能保持毫秒级别的读写延迟。相对于传统的 SQL 数据库,Amazon DynamoDB 巨大的性能优势在此类场景下表现得淋漓尽致。

下面介绍一个真实的金融客户案例。

这个金融公司需要存储海量交易日志,此前使用 MySQL,在高并发和大数据量的情况下,压力巨大,但是分库分表工作量也巨大。金融行情变幻莫测,突发行情时,数据库不能快速扩容,查询响应时间不稳定,延迟达到秒级别。运维需要监控多个指标,有时候必须的安全补丁和版本升级会导致数据库中断,而金融交易难以容忍中断。为了应对变化的业务高峰,不得不配置高峰时期所需要的资源,这样又会导致业务低峰期资源浪费。数据库还要打开 binlog,以供下游分析组件采集变化数据,也会影响性能。

为了解决这些问题,该公司采用了 Amazon DynamoDB。自动快速扩容可以满足业务经常变化的需求,而无需提前配置高峰期所需资源。在使用按需模式下,扩容是无感的,可以瞬间从 0 达到几十万请求。即使在高频交易的情况下,仍然达到了 10ms 以内的稳定延迟。对于数据库管理者,无需运维、无需拆分、无需升级,只需要关注读写容量指标即可。Amazon DynamoDB Stream 可以采集变化数据,不影响性能。结合 Zero ETL,可以把 DynamoDB 的数据近乎实时加载到 OpenSearch,以供搜索使用。

要使用好 Amazon DynamoDB,最关键的是以业务需求来设计表。DynamoDB 需要设置主键,其中包含必须的分区键(Partition Key)和可选的排序键(Sort Key),其他的数据作为属性,可灵活设计。分区键决定了数据存储在哪个分区,为了避免热点产生,尽量需要设置随机分散。排序键在数据分布式按照顺序存储,可以结合分区键,按照范围快速获取这部分数据。如果主键不能完全满足业务需求,可以设置二级索引,加速主键之外的业务查询。

以下是 Amazon DynamoDB 在交易日志场景下的表设计例子。

  • 分区键 Partition Key:tx, 交易 Transaction ID。合适的 tx id 可支持高频写入海量请求下毫秒级低延迟。
  • 排序键 Sort Key:ts,时间戳 timestamp,可按范围查询并排序。

主键(分区键+排序键)要求唯一且分散,每次交易的交易 ID 唯一。考虑别的查询需要使用时间,以此为排序键最合适。

  • Amount:交易金额
  • Currency:币种
  • In_hash: 买方 hash id
  • Out_hash:卖方 hash id
  • spent:交易费

示例代码:https://github.com/milan9527/dynamodb-fsi/blob/main/ddb-txlog1.py

以下是示例数据:

查询时,按照交易查询(主键),可以迅速查询到某个交易下的数据。此外,用户需要查询自己的交易,可以根据买卖方 id 创建二级索引,以加速查询。

索引 1:in_hash+ts,买方 hash+ 时间范围查询交易记录

索引 2:out_hash+ts,卖方 hash+ 时间范围查询交易记录

以下查询获取某个买方从 2024-07-12 18:11:45 到 2024-07-12 18:12:00 的交易记录,时间已经转换为 Unix 时间。

除了以上查询,还可以只根据买方或者卖方 id,不限制时间范围,查询所有交易。如果此用户交易很多,可能会消耗更多 DynamoDB 读容量,返回结果过大还需要分页。索引根据业务需要,选择合适的属性投影,减少容量消耗。

另外,还可以根据某个字段过滤查询。例如,查询用户在某个时间范围内,金额大于 50 的交易。此查询使用全局二级索引,查询主键:买入用户+时间范围,加上过滤:amount > 50

实际测试中,即使在请求量很高的情况下,仍然可以达到很低的查询延迟(通常<100ms)。

以下是写入延迟的指标,稳定在 3ms 左右。

行情 K 线

金融行业的另外一个典型场景是行情数据。证券交易所对外提供的历史行情数据使用 JSON 接口。实时行情根据交易,更新 TICK 数据逐笔行情,根据每次交易的价格和成交量,更新时间范围内所有变化数据。TICK 数据为高频数据,经常用于量化交易回溯测试。投资者需要的是直观的 K 线图,以及日线/周线/月线,KDJ/MACD/均线等指标,作为投资依据。K 线数据计算,在每个时间窗口内,根据实时 TICK 数据计算 K 线数据的各个价格点。此外,在每个时间窗口结束时,根据新的 TICK 数据更新 K 线数据,以此计算出成交量、均线等其他指标,此类指标根据用户选择,在某个时间范围内做聚合分组查询。

行情数据场景下,不需要复杂的跨表 Join,但是需要高频读写数据库,并且具有高并发,海量数据的特点。与交易日志类似,Amazon DynamoDB 也非常适合此类场景。

以下是 DynamoDB 在 K 线行情数据的表设计示例:

示例数据:

K 线数据包括:

  • 开盘价(O)
  • 收盘价(C)
  • 最高价(H)
  • 最低价(L)
  • 成交量

Partition Key:code,证券/货币代码

Sort Key:ts,时间戳 timestamp,可按范围查询并排序

证券代码可以重复,加上不重复的时间戳,构成具有唯一性的组合主键,

真实的金融市场中,交易品种一般较为分散。在特定的市场中,可能某个交易品种特别频繁,占据了整个市场的很大部分交易,容易造成热点分区,可以考虑进一步把此交易品种的分区键拆分。

查询可以根据 Code+ 时间范围,获取 TICK 数据,并且以 TICK 数据计算 K 线数据,时间窗口可以变化。

  • 查询:证券在某个时刻(秒)的行情
  • 查询:某个证券在 1 分钟内的 K 线行情

DynamoDB 获取出时间范围内的数据后,在应用程序使用聚合函数(min, max, sum)计算出 K 线图所需要的数据。

        # Query the DynamoDB table for the stock data within the one-minute interval
        response = table.query(
            KeyConditionExpression=boto3.dynamodb.conditions.Key('code').eq(stock_code + '.US') & boto3.dynamodb.conditions.Key('ts').between(current_time, next_time - 1),
            ProjectionExpression='open_price, close_price, high_price, low_price, volume, turnover',
            ScanIndexForward=True
        )
        if response['Items']:
            open_price = response['Items'][0]['open_price']
            close_price = response['Items'][-1]['close_price']
            high_price = max(item['high_price'] for item in response['Items'])
            low_price = min(item['low_price'] for item in response['Items'])
            volume = sum(item['volume'] for item in response['Items'])
            turnover = sum(item['turnover'] for item in response['Items'])

输出 K 线数据结果,包含这段时间的开盘价、收盘价、最高价、最低价、成交量、换手:

{'timestamp': 1720797732, 'open': 115.62, 'close': 243.48, 'high': 294.84, 'low': 100.63, 'volume': Decimal('139436419'), 'turnover': 26287578456.22}

参考:https://github.com/milan9527/dynamodb-fsi/blob/main/ddb-kchart2.py

总结

在金融交易日志和行情数据的场景下,Amazon DynamoDB 可以实现:

  • 海量数据和高并发下,稳定的毫秒级延迟响应;
  • 自动快速扩展;
  • 无需分库分表;
  • 无需版本升级维护。

在设计 DynamoDB 表时,需要根据业务需求设置合适的主键(分区键+排序键)和创建二级索引,以提高查询性能并满足业务需求的动态变化。

本篇作者

章平

亚马逊云科技数据库架构师。2014 年起就职于亚马逊云科技,先后加入技术支持和解决方案团队,致力于客户业务在云上高效落地。对于各类云计算产品和技术,特别是在数据库和大数据方面,拥有丰富的技术实践和行业解决方案经验。此前曾就职于 Sun,Oracle,Intel 等 IT 企业。