前不久在分享中有人介绍了一个范畴论框架,提到了新旧词 monad ,异步访问 QPS 过万,还曾布置了一个检测系统的作业。天下武功,唯快不破,不局限于某个模式。我觉得设计的最大瓶颈不在逻辑计算,在数据库上,于是也有了使用 go 语言和时序数据库做一个全员检测系统作业的想法。时序数据库性能极高,性能测试中每秒 30 万条以上的写入,每秒 100 万条以上的统计。

需求

任务

开发高性能的全员检测服务平台。

用户及主要功能

  • 监督单位
    • 审核员:审核采样点,审核共享单位
    • 分析员:统计异常结果
  • 检测单位
    • 管理员:注册采样点,统计检测结果
    • 采样员:登记采样试管号,登记采样人员
    • 检测员:上传检测结果
  • 共享单位
    • 管理员:上传人员信息,读取检测结果
  • 检测对象
    • 居民:信息注册、检测登记,扫码登记,查询结果

服务的主要技术特点

  • 数据一次写入多,修改少
  • 数据统计跨越范围大 以上两个特点都匹配时序数据库

服务规模

用户规模

  • 人口支持数量:北京 2188.6万,上海 2487.1 万,按照 2500 万设计
  • 每组检测能力估算:按照每组 1 分钟采样 6 人计算,每天连续工作 10 小时,最多可检测 3600 人
  • 检测点数量估算:按照每组每天检测 3600 人,需要设置 6945 个检测组,由于人口分布不均,按照 20000 个检测组设计

TPS

  • 用户注册 TPS:如果 12 小时内注册完成,注册 API 最低达到 579 TPS,设计需满足 2000 TPS
  • 采样服务 TPS:按照 20000 检测组 10 秒 一次登记和采样计算,设计需满足 2000 TPS
  • 结果上传 TPS:全部按照十人混检计算,需要上传 250 万条数据,按照 300 万条计算,假设 2 小时内上传完毕,设计需满足 417 TPS
  • 数据共享 TPS:按照 50% 的就业人口计算,每个单位平均 100 人,大约 125000 个单位,假如每小时允许查询一次,设计需满足 35 TPS
  • 扫码登记 TPS:按照 30% 的人口在早高峰一小时扫码计算,设计需满足 2084 TPS

数据量

  • 用户注册数据量:2500 万 * 70 字节 = 1.63 GB
  • 检测点数据量:2 万 * 100 字节 = 1.91 GB
  • 共享单位数据量:12.5 万 * 100 字节 = 11.93 MB
  • 每日全采数据量:2500 万 * 30 字节 = 715.26 MB

工具选型

网络

  • 网关:apisix,高性能,可定制

数据存储

  • 持久化数据库:TDEngine,时序数据库,性能高,扩展性强
  • 缓存:redis,高性能缓存
  • 文件存储:cos,普通文件存储

编程语言

  • 后端:golang,高性能网络编程语言
  • 网关:lua
  • 缓存:redis 脚本
  • 数据库:TDengine SQL

架构设计

基本架构

采用的最简单的结构如下: Screenshot 2022-12-03 at 15.41.55.png

客户端鉴权

交给网关

客户端认证

实名认证、手机号认证

客户端缓存

数据长期有效,请求结果根据检测记录可刷新

客户端访问

读取数据:结果单日有效,其他长期有效 写改数据:单次访问控制,防止重复提交

数据模型及建模

参考数据标准

数据库 inspection

数据库规格:

  • 保存时间:5 年
  • 时间戳精度:纳秒
  • 副本:1个

创建脚本如下:

CREATE DATABASE IF NOT EXISTS inspection REPLICA 1 KEEP 1827 PRECISION 'ns';

行政区划表 ar_code

规格:

  • 数据列包含区县代码(districtCode)、街道代码(streetCode)、居委会代码(nhcCode)、名称(name)
  • 标签包含市代码(cityCode)
CREATE STABLE IF NOT EXISTS ar_code(ts TIMESTAMP, 
districtCode BINARY(6), streetCode BINARY(3), nhcCode BINARY(3), name NCHAR(30)) 
TAGS (cityCode BINARY(3));

监督人员超级表 ar_user

规格:

  • 数据列包含手机号码(phone)、姓名(name)、类型(type)
  • 标签包含区代码(districtCode)、街道代码(streetCode)、居委会代码(nhcCode)
CREATE STABLE IF NOT EXISTS ar_user(ts TIMESTAMP, 
phone BINARY(11), name NCHAR(20), type int) 
TAGS (districtCode BINARY(6), streetCode BINARY(3), nhcCode BINARY(3));

居民信息超级表 citizen

规格:

  • 数据列包含身份证号码(cardid)、手机号码(phone)、姓名(name)
  • 标签包含区代码(districtCode)、街道代码(streetCode)、居委会代码(nhcCode)
  • 分表可以根据出生年月,也可以根据区域
CREATE STABLE IF NOT EXISTS citizen(ts TIMESTAMP, 
cardid BINARY(18), phone BINARY(11), name NCHAR(20)) 
TAGS (districtCode BINARY(6), streetCode BINARY(3), nhcCode BINARY(3));

检测点信息超级表 check_point

规格:

  • 数据列包含公司名称(corpName)、营业执照号码(corpNo)、检测点编号(checkCode)、开放时间(startAt)、关闭时间(closeAt)
  • 标签包含区代码(districtCode)、街道代码(streetCode)、居委会代码(nhcCode)
CREATE STABLE IF NOT EXISTS check_point (ts TIMESTAMP, 
corpName NCHAR(50), corpNo BINARY(15), checkCode NCHAR(20), startAt int, closeAt int) 
TAGS (districtCode BINARY(6), streetCode BINARY(3), nhcCode BINARY(3));

检测用户超级表 check_user

规格:

  • 数据列包含姓名(name)、手机号(phone)、过期日期(expiredAt)、角色类型(type)
  • 标签包含检测点编号(checkCode) 、企业营业执照号码(corpNo)
CREATE STABLE IF NOT EXISTS check_user (ts TIMESTAMP, 
name NCHAR(20), phone BINARY(20), expiredAt int, type int) 
TAGS (checkCode BINARY(20), corpNo BINARY(15));

出入点信息超级表 entry_point

规格:

  • 数据列包含公司名称(corpName)、营业执照号码(corpNo)、出入口编号(code)
  • 标签包含区代码(district_code)、街道代码(street_code)、居委会代码(nhc_code)
CREATE STABLE IF NOT EXISTS entry_point (ts TIMESTAMP, 
corpName NCHAR(50), corpNo BINARY(15), entryCode NCHAR(20), startAt int, closeAt int) 
TAGS (districtCode BINARY(6), streetCode BINARY(3), nhcCode BINARY(3));

扫描登记信息超级表 entry_record

规格:

  • 数据列包含身份证号码(cardid)、手机号码(phone)、姓名(name)
  • 标签包含出入口编号(entryCode)
CREATE STABLE IF NOT EXISTS entry_record (ts TIMESTAMP, 
cardid BINARY(18), phone BINARY(11), name NCHAR(20)) 
TAGS (entryCode BINARY(20));

试管超级表 tube

规格:

  • 数据列包含试管编号(tubeCode)、操作员姓名(createdName)
  • 标签包含检测点编号(checkCode)
CREATE STABLE IF NOT EXISTS tube (ts TIMESTAMP, 
tubeCode BINARY(20), createdName NCHAR(20)) 
TAGS (checkCode BINARY(20));

检测对象超级表 tube_user

规格:

  • 数据列包含试管编号(tubeCode)、操作员姓名(createdName)、身份证号码(cardid)、姓名(name)
  • 标签包含检测点编号(checkCode) 、试管编号(tubeCode)
CREATE STABLE IF NOT EXISTS tube_user(ts TIMESTAMP, 
tubeCode BINARY(18), createdName NCHAR(20), cardid BINARY(18), name NCHAR(20)) 
TAGS (checkCode BINARY(20));

检测结果超级表 tube_result

规格:

  • 数据列包含试管编号(tubeCode)、操作员姓名(createdName)
  • 标签包含检测点编号(checkCode)
CREATE STABLE IF NOT EXISTS tube_result (ts TIMESTAMP, 
tubeCode BINARY(20), createdName NCHAR(20), result int) 
TAGS (checkCode BINARY(20));

共享单位超级表 share_point

规格:

  • 数据列包含公司名称(corpName)、营业执照号码(corpNo)、检测点编号(code)
  • 标签包含区代码(district_code)、街道代码(street_code)、居委会代码(nhc_code)
CREATE STABLE IF NOT EXISTS share_point (ts TIMESTAMP, 
corpName NCHAR(50), corpNo BINARY(15), shareCode NCHAR(20)) 
TAGS (districtCode BINARY(6), streetCode BINARY(3), nhcCode BINARY(3));

共享单位管理员超级表 share_user

规格:

  • 数据列包手机号(phone)、姓名(name)、人员类型(type)
  • 标签包含共享单位编号(shareCode)
CREATE STABLE IF NOT EXISTS share_user (ts TIMESTAMP, 
phone BINARY(11), name NCHAR(20), type NCHAR(10)) 
TAGS (shareCode BINARY(20));

共享对象超级表 share_citizen

规格:

  • 数据列包身份证号码(cardid)、姓名(name)、人员类型(type)、过期日期(expiredAt)
  • 标签包含共享单位编号(shareCode)
CREATE STABLE IF NOT EXISTS share_citizen (ts TIMESTAMP, 
cardid BINARY(18), name NCHAR(20), type NCHAR(10), expiredAt int) 
TAGS (shareCode BINARY(20));

设计难点

单采集点

TDengine 倡导“采用一个数据采集点一张表的方式”,超级表建立结构,子表对应设备。由于单个设备的数据是时序的,因此可以保证全部数据是有序的。

预期功能中,居民注册使用小程序方式,可以保证每人是时序的,但子表将会达到2500万个。

解决方式: 网关设置信息触发点。网关设置 requestId 时使用 snowflake 算法,每秒 26 万个ID,微秒级精度。根据snowflake 使用的数据中心分组,理论上可以保证数据时序性。

删改数据

时序数据库倡导的是追加模式,连续记录。

解决方式: 数据的修改可以通过追加记录,查询最新状态。 数据的删除可以通过增设状态字段,查询最新状态。