score 是一个积分系统, 可用于会员积分/系统内货币等. 这个库是实现积分系统的lib库, 多个不同业务/分布式系统也能直接引用这个lib库且可以使用相同的底层储存组件(redis/mysql), 其业务隔离性由积分类型来区分.
-
多积分类型
-
同积分类型支持多域
-
积分生效/失效时间(允许操作开始结束时间)
-
积分过期后自动删除数据(redis数据自动过期或自动删除)(后续也不考虑支持, 参考 积分数据 说明) -
余额查询
-
增加积分
-
扣除积分
-
重置积分
-
获取订单状态
-
同用户不同积分类型兑换. 暂不支持, 可以通过 order 配合实现
-
不同用户同积分类型转账. 暂不支持, 可以通过 order 配合实现
-
自定义订单id (用于支持特色业务, 比如领取积分防重发)很难实现, 可以通过 order 配合实现 -
强校验参数(操作类型/操作数值/积分类型/域/uid)
-
流水记录
-
流水记录自动删除(后续也不考虑支持, 参考 流水记录 说明) -
并发支持
-
操作可重入
-
metrics上报
- redis 储存积分数据/订单状态, 也可以使用 kvrocks (兼容redis的硬盘储存nosql)
- mysql(可选) 储存积分类型/积分流水, 可以使用 mysql/mariadb/pgsql 等
- 首先准备一个库名为
score
的mysql库. 这个库名可以根据sqlx组件配置的连接db库修改 - 创建积分类型表, 积分类型的表文件在这里. 如果配置从redis加载可以不用操作这一步.
- 创建积分流水的分表, 默认为2个分表, 分表索引从0开始. 一开始应该设计好分表数量, 确认好后暂不支持修改分表数量, 如果你不知道设置为多少就设为1000. 注意, 配置文件中key
ScoreFlowTableShardNums
必须与这里设置的分片数量相同.
如果你使用了分布式redis系统, 请根据你使用的分布式redis系统的hashtag来调整key算法以将同一个用户id的数据分配到同一个分片中, 否则导致功能异常. 由于底层对同用户的操作均采用lua脚本, 要求操作的多个key必须在同一个节点.
描述 | 配置key | 默认key格式化字符串 | 数据类型 | 有效期 | 支持替换的字符 |
---|---|---|---|---|---|
积分数据 | ScoreDataKeyFormat | score:<score_type_id>:<domain>:{<uid>} | string | 永久 | <uid> /<domain> /<score_type_id> |
订单状态 | OrderStatusKeyFormat | score_os:<order_id>:{<uid>} | string | 30天(可配置) | <uid> /<order_id> |
订单号生成器 | GenOrderSeqNoKeyFormat | score_sn:<score_type_id>:<score_type_id_shard> | string | 永久 | <score_type_id> /<score_type_id_shard> |
其中订单状态key中加上{<uid>}
的原因是在分布式redis系统中lua脚本要操作的这些key(积分数据/订单状态等)都要在同一个节点中, 而用户id的区分度较大, 能方便分散到不同节点避免单节点负载过高, 相同用户的数据放在同一个节点中对节点负载影响不大.
key中的字符替换说明如下
字符 | 说明 |
---|---|
<uid> | 用户唯一id |
<domain> | 域 |
<score_type_id> | 积分类型id |
<order_id> | 订单id |
<score_type_id_shard> | 积分类型id分片 |
积分类型只有注册之后才会使用, 这是为了防止多业务的积分类型冲突. 注册积分类型后大约1分钟生效(常驻内存每隔1分钟刷新以实现高性能).
如果配置文件keyScoreTypeSqlxName
指定了sqlx组件名, 需要将积分类型加入到mysql的score_type
表.
如果配置文件keyScoreTypeRedisName
指定了在redis组件名, 则需要在配置文件keyScoreTypeRedisKey
指定的 redis hash map 中增加数据, 其 field 为积分类型(正整数), 值为以下结构
{
"score_name": "积分名", // 积分名, 与代码无关, 用于告诉配置人员这个积分类型是什么业务
"start_time": 1723017306, // 生效时间, 秒级时间戳, 0 表示不限制
"end_time": 1723017306, // 失效时间, 秒级时间戳, 0 表示不限制
"order_status_expire_day": 30, // 订单状态保留多少天
"verify_order_create_less_than": 7, // 操作时验证订单id创建时间小于多少天, 不要超过积分状态储存时间, 否则可能导致在重入时由于查不到积分状态重新操作了用户积分
"remark": "备注"
}
配置内容参考:
# score配置
score:
ScoreRedisName: "score" # 积分数据redis组件名
ScoreDataKeyFormat: "score:<score_type_id>:<domain>:{<uid>}" # 积分数据key格式化字符串
TryEvalShaScoreOP: true # 尝试通过 redis EVALSHA 命令操作积分
OrderStatusKeyFormat: "score_os:<order_id>:{<uid>}" # 订单状态key格式化字符串
GenOrderSeqNoKeyFormat: "score_sn:<score_type_id>:<score_type_id_shard>" # 订单号生成器key格式化字符串
GenOrderSeqNoKeyShardNum: 1000 # 生成订单序列号key的分片数
ScoreTypeRedisName: "score" # 积分类型redis组件名
ScoreTypeRedisKey: "score:score_type" # 积分类型从redis加载的 hash map key名
ScoreTypeSqlxName: "" # 积分类型sqlx组件名, 如果配置了 ScoreTypeRedisName 则仅从redis加载积分类型
ReloadScoreTypeIntervalSec: 60 # 重新加载积分类型间隔秒数
ScoreFlowSqlxName: "score" # 积分流水记录sqlx组件名
WriteScoreFlow: false # 是否写入积分流水
ScoreFlowTableShardNums: 2 # 积分流水记录表分片数量
# 依赖组件
components:
sqlx: # 参考 https://github.com/zly-app/component/tree/master/sqlx
score:
# ...
redis: # 参考 https://github.com/zly-app/component/tree/master/redis
score:
# ...
app := zapp.NewApp("zapp.test.score",
score.WithService(),
)
defer app.Exit()
const (
scoreTypeID = 1
domain = "test_domain"
uid = "test_uid"
)
sdk := score.NewSdk(scoreTypeID, domain, uid)
// 生成订单id
orderID, err := sdk.GenOrderSeqNo(ctx)
// 增加score
addOrderData, err := sdk.AddScore(ctx, orderID, 100, "add score")
// 扣除score
deductOrderData, err := sdk.DeductScore(ctx, orderID, 30, "deduct score")
// 获取score
score, err := sdk.GetScore(ctx)
// 重设score
resetOrderData, err := sdk.ResetScore(ctx, orderID, 66, "reset score")
// 获取订单状态
orderData, orderStatus, err := sdk.GetOrderStatus(ctx, orderID)
不同的业务可能会使用完全隔离的积分, 比如用户在商城系统有一个商城货币, 在会员系统有个会员积分, 在bbs系统还有个签到积分等等, 这些隔离计算的积分就是不同的积分类型.
相同积分类型也可能在不同的状态下采用不同的域, 比如签到积分每一年分开计算, 其积分类型相同, 而域就是以年为变量计算出来的.
域不需要注册, 它是由具体业务控制的, 如果你的业务不需要支持域, 在调用积分系统时对代码的域变量传入空字符串即可.
对用户的积分写操作都需要一个订单号来承载这个操作, 订单号是一个全局不重复的字符串, 其生成方式为使用一个key(score_sn:<积分类型id>
)调用incr
命令加1, 订单号为<时间戳>_<incr结果值>_<crc32(uid)>_<积分类型id>_<域>
, 由于将积分类型id
也写入到了订单号中, 保证了全局不会重复.
当然这样就造成了热key, 所以需要对这个key进行分片, 比如分1000片, 其key为score_sn:<积分类型id>:<分片号>
. 这里对分片的选择没有要求, 可以直接随机或者轮询.
而由于加了分片key, 不同分片incr
后的值会有重复, 所以订单号需要带上分片号, 如<时间戳>_<分片号>_<incr结果值>_<crc32(uid)>_<积分类型id>_<域>
.
流水记录数据存放在n个分表中, 同一个用户的流水会存放在同一个分表.
score系统不会删除历史流水记录, 如果有这个需求, 需要业务层自行删除. 对于一般业务来说流水数据是重要的资产, 如果真的是储存满了且不想扩容, 可以写脚本删除历史数据, 没必要做定时删除任务.
积分数据存放在 redis
的string
类型中, 每个用户在每个积分类型的每一个域下都有一个key, 其value为积分的值.
score系统不会在积分类型到期后删除用户的积分数据, 如果有这个需求, 需要业务层自行删除, 对于一般业务来说积分数据是重要的资产, 如果真的是储存满了且不想扩容, 可以写脚本删除历史数据, 没必要做定时删除任务.
注: 以下图中黄色块为lua脚本
sequenceDiagram
participant a as 业务层
participant b as score系统
participant c as redis
participant d as mysql
a->>b: 增减
b-->>b: 前置检查
b->>c: 执行lua脚本 (可重入)
rect rgb(255, 245, 173)
c->>c: 获取订单状态
alt 订单未完成
c->>c: 通过incr增减后获取结果
alt 结果为负数
c->>c: 回退积分
c->>c: 写入订单状态为: 余额不足
else
c->>c: 写入订单状态为: ok
end
end
end
c-->>b: 订单状态
alt 订单操作完成(包括余额不足)
b->>d: 写入流水 (通过订单id可重入)
end
b->>a: 订单状态
sequenceDiagram
participant a as 业务层
participant b as score系统
participant c as redis
participant d as mysql
a->>b: 重置
b-->>b: 前置检查
b->>c: 执行lua脚本 (可重入)
rect rgb(255, 245, 173)
c->>c: 获取订单状态
alt 订单未完成
c->>c: 通过set设置值
c->>c: 写入订单状态为: ok
end
end
c-->>b: 订单状态
alt 订单操作完成
b->>d: 写入流水 (通过订单id可重入)
end
b->>a: 订单状态