背景:
一个uid最多允许N台设备同时在线,token有2小时的有效期。
如果有新设备登录,则删除最近将要过期的key,来替换它。
实现:
默认token的key为: fmt.Sprintf(jwt_%s_%s, uid, deviceId)
。这里deviceId我们是使用 fingerprintjs 获取的。
当我们调用登录接口后,通过了用户名密码等验证,首先从数据源获取这个uid最多允许几台设备同时登录。
// 从MySQL检查最多允许几台设备
maxCount, err := db.GetMaxDeviceCount(ctx, uid)
if err != nil {
return err
}
接着我们从redis获取这个用户目前有几个有效可用的token。注意使用 SCAN
命令(KEYS
命令我们在生产环境禁用,原因可见 redis.io/commands/ke… 中的 Warning 段落)。
这里为了简单还是用KEYS
为例:
keys, err := rdb.Keys(ctx, fmt.Sprintf("jwt_%s_*", uid).Result()
if err != nil {
return err
}
我们对比现在redis中的token数量和最大允许的数量,会发现有以下两种情况:
- 如果
len(keys) < maxCount
, 那我们直接set新的token,带上ttl就行。 - 否则我们要从redis中删除
len(keys) - maxCount + 1
条,然后再保存新的token。
第一种情况:
len(keys) < maxCount:
return rdb.Set(ctx, fmt.Sprintf("jwt_%s_%s", uid, deviceId), token, 2*time.Hour).Err()
第二种情况更加复杂,我们可能会遇到现有的token中包含当前deviceId的,所以可以不用删除直接更新它。
更加极端的情况下,我们可能之前允许这个用户最多登录5台,redis中有5个这个uid的token,现在我们只允许最多登录3台了。
len(keys) == maxCount:
if len(keys) == maxCount {
for idx := range keys {
if keys[idx] == fmt.Sprintf("jwt_%s_%s", uid, deviceId) {
return rdb.Set(ctx, fmt.Sprintfjwt_%s_%s", uid, deviceId), token, 2*time.Hour).Err()
}
}
}
len(keys) > maxCount:
// 需要删除ttl最小的
toBeDeletedCount := len(keys) - maxCount + 1
keysTTLmap := make(map[string]float64)
for idx := range keys {
ttl, err := rdb.TTL(ctx, keys[idx]).Result()
if err != nil {
return err
}
keysTTLmap[keys[idx]] = ttl.Seconds()
}
ranked := rankKeyByTTLAsc(keysTTLmap)
for idx := range ranked {
err = rdb.Del(ctx, ranked[idx]).Err()
if err != nil {
return err
}
toBeDeletedCount--
if toBeDeletedCount == 0 {
break
}
}
return rdb.Set(ctx, fmt.Sprintf("jwt_%s_%s", uid, deviceId), token, 2*time.Hour).Err()
辅助函数: 将key按照过期时间排序,最近要过期的在最前面。
func rankKeyByTTLAsc(values map[string]float64) []string {
type kv struct {
Key string
Value float64
}
var ss []kv
for k, v := range values {
ss = append(ss, kv{k, v})
}
sort.Slice(ss, func(i, j int) bool {
return ss[i].Value < ss[j].Value
})
ranked := make([]string, len(values))
for i, kv := range ss {
ranked[i] = kv.Key
}
return ranked
}