前不久在分享中有人介绍了一个范畴论框架,提到了新旧词 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
架构设计
基本架构
采用的最简单的结构如下:
客户端鉴权
交给网关
客户端认证
实名认证、手机号认证
客户端缓存
数据长期有效,请求结果根据检测记录可刷新
客户端访问
读取数据:结果单日有效,其他长期有效 写改数据:单次访问控制,防止重复提交
数据模型及建模
参考数据标准
- 行政区划编码:国家统计局
数据库 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_tube (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 check_tube (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_user (ts TIMESTAMP,
cardid BINARY(18), name NCHAR(20), type NCHAR(10), expiredAt int)
TAGS (shareCode BINARY(20));
设计难点
单采集点
TDengine 倡导“采用一个数据采集点一张表的方式”,超级表建立结构,子表对应设备。由于单个设备的数据是时序的,因此可以保证全部数据是有序的。
预期功能中,居民注册使用小程序方式,可以保证每人是时序的,但子表将会达到2500万个。
解决方式: 网关设置信息触发点。网关设置 requestId 时使用 snowflake 算法,每秒 26 万个ID,微秒级精度。根据snowflake 使用的数据中心分组,理论上可以保证数据时序性。
删改数据
时序数据库倡导的是追加模式,连续记录。
解决方式: 数据的修改可以通过追加记录,查询最新状态。 数据的删除可以通过增设状态字段,查询最新状态。