zlyuancn / score

一个积分系统, 可用于会员积分/系统内货币等

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool


什么是 score

score 是一个积分系统, 可用于会员积分/系统内货币等. 这个库是实现积分系统的lib库, 多个不同业务/分布式系统也能直接引用这个lib库且可以使用相同的底层储存组件(redis/mysql), 其业务隔离性由积分类型来区分.

  • 多积分类型

  • 同积分类型支持多域

  • 积分生效/失效时间(允许操作开始结束时间)

  • 积分过期后自动删除数据(redis数据自动过期或自动删除) (后续也不考虑支持, 参考 积分数据 说明)

  • 余额查询

  • 增加积分

  • 扣除积分

  • 重置积分

  • 获取订单状态

  • 同用户不同积分类型兑换. 暂不支持, 可以通过 order 配合实现

  • 不同用户同积分类型转账. 暂不支持, 可以通过 order 配合实现

  • 自定义订单id (用于支持特色业务, 比如领取积分防重发) 很难实现, 可以通过 order 配合实现

  • 强校验参数(操作类型/操作数值/积分类型/域/uid)

  • 流水记录

  • 流水记录自动删除 (后续也不考虑支持, 参考 流水记录 说明)

  • 并发支持

  • 操作可重入

  • metrics上报


前置准备

底层组件要求

  • redis 储存积分数据/订单状态, 也可以使用 kvrocks (兼容redis的硬盘储存nosql)
  • mysql(可选) 储存积分类型/积分流水, 可以使用 mysql/mariadb/pgsql 等

sql文件导入(可选)

  1. 首先准备一个库名为 score 的mysql库. 这个库名可以根据sqlx组件配置的连接db库修改
  2. 创建积分类型表, 积分类型的表文件在这里. 如果配置从redis加载可以不用操作这一步.
  3. 创建积分流水的分表, 默认为2个分表, 分表索引从0开始. 一开始应该设计好分表数量, 确认好后暂不支持修改分表数量, 如果你不知道设置为多少就设为1000. 注意, 配置文件中keyScoreFlowTableShardNums必须与这里设置的分片数量相同.
    1. 构建分表的工具为 stf
    2. 积分流水的分表文件在这里
    3. 这里可以看到已经生成好了2个分表的sql文件, 可以直接导入.

调整积分key格式化字符串

如果你使用了分布式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系统不会删除历史流水记录, 如果有这个需求, 需要业务层自行删除. 对于一般业务来说流水数据是重要的资产, 如果真的是储存满了且不想扩容, 可以写脚本删除历史数据, 没必要做定时删除任务.

积分数据

积分数据存放在 redisstring类型中, 每个用户在每个积分类型的每一个域下都有一个key, 其value为积分的值.

参考调整积分key格式化字符串

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: 订单状态
Loading

重置积分

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: 订单状态
Loading

About

一个积分系统, 可用于会员积分/系统内货币等


Languages

Language:Go 100.0%