一、MySQL实现方法
假设我们要设计一款排名系统,那必然要涉及到两大类数据:武器数据和非武器的通用数据,它他通常有一个共用的属性:那就是主键唯一的,例如玩家的数字编号,通常在MySQL中是自增的无符号整数字段。
非武器的通用数据:可以理解为跟武器没有任何关联的数据,例如玩家ID,昵称,签名,注册时间,登录时间等等,在MySQL中就类似如下:
武器数据:这里每一个武器对应的种类就相当于MYSQL中的一个字段,大致类似如下:
这个表单数据通常会只会增加并不会出现减少的现象,如果用MYSQL来做排名系统的话,当玩家数量越来越多成几何增长的时候,它会暴露出两个问题:
1、效率过低:大量的select与update语句就可能会显得非常臃肿;
2、不易扩展:如果需要实时增加新的武器数据类型(字段),可能会不方面
这时候如果用redis来做数据存储就会显示格外合适,仿佛天生就是用来干这个事情的!
二、用Redis实现方法
1、存储前字符串编码转换
通常不建议在REDIS中直接存储明文字符串,建议采用网页编码来存储,转换函数源码如下:
// 十六进制字符表
const UCHAR g_szHexTable[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };
//
// 获取网页编码
//
int getUrlCode(const UCHAR *pszBuff, const int nSize, char *pszOutput, const int nMaxSize)
{
int i = 0;
char *pszTemp = NULL;
if (pszBuff == NULL || nSize == 0)
return 0;
if (pszOutput == NULL || nMaxSize == 0)
return 0;
memset(pszOutput, 0x00, nMaxSize);
pszTemp = pszOutput;
for (int i = 0; i < nSize; i++)
{
pszTemp[0] = '%';
pszTemp[1] = '%';
pszTemp[2] = g_szHexTable[(pszBuff[i] >> 4) & 0x0F];
pszTemp[3] = g_szHexTable[pszBuff[i] & 0x0F];
pszTemp += 4;
}
return nSize * 4;
}
// 测试函数
void test_url_code()
{
UCHAR szInput[] = "我要打10个!";
char szOutput[128] = { 0x00 };
getUrlCode(szInput, strlen((char *)szInput), szOutput, 128);
printf("%s\n", szOutput);
}
2、用户表操作
我们可以采用Redis Hset 命令用于为哈希表中的字段赋值;如果哈希表不存在,一个新的哈希表被创建并进行 HSET 操作;如果字段已经存在于哈希表中,旧值将被覆;
HSET KEY_NAME FIELD VALUE
KEY_NAME: 键值
FIELD: 字段
VALUE: 值(字符传、数字、浮点)
我们在KEY_NAME这里键值的时候,用一个组合方法来区分:
// KEY_NAME = type : id
#define STATS_ALL "a" // 总榜 a:1
#define STATS_YEAR "y" // 年榜 y2022:1
#define STATS_MONTH "m" // 月榜 m202205:1
#define STATS_DAY "d" // 日榜 d20220501:1
#define STATS_SESSION "s" // 季榜 s1_2022:1
#define STATS_USER "user" // 用户信息 user:1
那么SQL中修改的语句修改用户名的时候,像这样的:
UPDATE dbuser SET name = 'aa' WHERE index = 1;
UPDATE dbuser SET signature = '我要打10个!' WHERE index = 1;
UPDATE dbuser SET regdate = '2023/10/1' WHERE index = 1;
而在Redis中应该像这样:
// 注意这里的KEY是组合出来的
// 设置玩家ID=1的游戏昵称为"aa"
HSET user:1 name %%61%%61
// 设置玩家ID=1的游戏昵称为"我要打10个!"
HSET user:1 signature %%CE%%D2%%D2%%AA%%B4%%F2%%31%%30%%B8%%F6%%A3%%A1
// 设置玩家ID=1的注册时间为"2023/10/1"
HSET user:1 regdate %%32%%30%%32%%33%%2F%%31%%30%%2F%%31
// 设置玩家ID=1的经验值时间为100
HSET user:1 exp 100
// 上面redis命令也可以优化成下面的一句执行完
HMSET user:1 name %%61%%61 signature %%CE%%D2%%D2%%AA%%B4%%F2%%31%%30%%B8%%F6%%A3%%A1 regdate %%32%%30%%32%%33%%2F%%31%%30%%2F%%31 exp 100
C++代码片段如下:
wsprintf(szCommand, /*HSET %s:%u %s %s*/XorStr<0xAB, 17, 0x72ACBFF2>("\xE3\xFF\xE8\xFA\x8F\x95\xC2\x88\x96\xC1\x95\x93\xC4\x98\x9C\xC9" + 0x72ACBFF2).s, STATS_USER, pInfo->m_nDbUid, g_szUserInfo[nPos], szValue);
reply = (redisReply *)redisCommand(pRedis, szCommand);
if (NULL == reply) goto STEP_END;
if (REDIS_REPLY_ERROR == reply->type)goto STEP_END;
bRet = TRUE;
STEP_END:
if (NULL != reply)
{
freeReplyObject(reply);
reply = NULL;
}
3、武器数据操作
在这方面数据操作就比较容易实现了,而且能随时扩充字段个数,不像SQL那样固定的个数;另外武器的字段的数值操作都是自加来累计,而不会产生添加或删除操作,比如当网关服务器提交一条玩家ID=1,武器=2,累计杀敌总数自加3的命令时,通常对应的SQL语句如下:
// 修改总榜数据
UPDATE dbweapon_all SET kill = kill + 3 where index = 1 AND wid = 2;
// 修改年榜数据
UPDATE dbweapon_2023 SET kill = kill + 3 where index = 1 AND wid = 2;
// 修改赛季榜数据
UPDATE dbweapon_s1_2023 SET kill = kill + 3 where index = 1 AND wid = 2;
而在Redis中应该像这样:
// HINCRBY命令原型
// 参考地址:https://www.runoob.com/redis/hashes-hincrby.html
HINCRBY KEY_NAME FIELD_NAME INCR_BY_NUMBER
// redis执行3条命令
HINCRBY a:1 1:kill 3
HINCRBY y2023:1 1:kill 3
HINCRBY s1_2023:1 1:kill 3
键值KEY_NAME注释:
a:1 代表总榜
y2023:1 外表年榜
s1_2023:1 代表2023第一赛季榜
字段FIELD_NAME注释:
1:kill 武器编号为1,类型为kill
字段INCR_BY_NUMBER代表自加或自减数值
4、MySQL修改积分查询排名
通常来说需要写一个复杂的MySQL语句来计算玩家的积分,大致如下:
// 修改积分
UPDATE SET db_user exp = exp + 3.0 WHERE index = 1;
// 查询积分
SELECT exp FROM db_user WHERE index = 1;
// 查询排名
SELECT COUNT(*) + 1 AS rank
FROM db_user
WHERE exp >= (SELECT exp FROM db_user WHERE index = 1);
而在redis中本身就有了现成的,通常在网关中处理武器数据时,会同时修改该榜单的积分值
5、Redis Zincrby 修改积分
Redis Zincrby 命令对有序集合中指定成员的分数加上增量 increment,可以通过传递一个负数值 increment ,让分数减去相应的值,比如 ZINCRBY key -5 member ,就是让 member 的 score 值减去 5 ;当 key 不存在,或分数不是 key 的成员时, ZINCRBY key increment member 等同于 ZADD key increment member;当 key 不是有序集类型时,返回一个错误;分数值可以是整数值或双精度浮点数。
命令原型如下:
ZINCRBY key increment member
key 同上面的武器榜单
increment 自增或自减的积分
member 玩家id
// 假设1号武器杀敌一次累计3分,那对应命令如下:
ZINCRBY a:1 3.0 1
ZINCRBY y2023:1 3.0 1
ZINCRBY s1_2023:1 3.0 1
C++代码片段如下:
// 总榜记录
wsprintf(szCommand, /*ZINCRBY %s %s %u*/XorStr<0x09, 17, 0x5A9256F5>("\x53\x43\x45\x4F\x5F\x4C\x56\x30\x34\x61\x33\x31\x66\x36\x32\x6D" + 0x5A9256F5).s, STATS_ALL, szScore, nDbUid);
bRet = redisGetReply(m_pRedis, (void **)&reply);
if (bRet != REDIS_OK)
goto STEP_END;
if (reply)
{
freeReplyObject(reply);
reply = NULL;
}
6、Redis Zscore 查询积分
Redis 有序集合(sorted set),Redis Zscore 命令返回有序集中,成员的分数值。 如果成员元素不是有序集 key 的成员,或 key 不存在,返回 nil 。
命令原型如下:
ZSCORE key member
key 同上面的武器榜单
member 玩家id
// 查询不同榜单的积分命令:
ZSCORE a:1 1
ZSCORE y2023:1 1
ZSCORE s1_2023:1 1
C++操作代码片段如下:
// 获取玩家积分(插件中只获取总榜记录)
wsprintf(szCommand, /*ZSCORE %s %u*/XorStr<0x40, 13, 0xF87B9E21>("\x1A\x12\x01\x0C\x16\x00\x66\x62\x3B\x69\x6F\x3E" + 0xF87B9E21).s, STATS_ALL, nDbUid);
reply = (redisReply *)redisCommand(pRedis, szCommand);
if (NULL == reply) goto STEP_END;
if (REDIS_REPLY_ERROR == reply->type)goto STEP_END;
if (REDIS_REPLY_STRING == reply->type)
{
fScore = strtod(reply->str, NULL);
}
if (NULL != reply)
{
freeReplyObject(reply);
reply = NULL;
}
7、Redis Zrevrank 查询排名
Redis 有序集合(sorted set),Redis Zrevrank 命令返回有序集中成员的排名。其中有序集成员按分数值递减(从大到小)排序;排名以 0 为底,也就是说, 分数值最大的成员排名为 0 ;使用 ZRANK 命令可以获得成员按分数值递增(从小到大)排列的排名。
命令原型如下:
Zrevrank key member
key 同上面的武器榜单
member 玩家id
// 查询不同榜单的排名命令:
ZREVRANK a:1 1
ZREVRANK y2023:1 1
ZREVRANK s1_2023:1 1
C++操作代码片段如下:
// 获取在玩家排名(插件中只获取总榜记录)
wsprintf(szCommand, /*ZREVRANK %s %u*/XorStr<0x01, 15, 0x9CCD219A>("\x5B\x50\x46\x52\x57\x47\x49\x43\x29\x2F\x78\x2C\x28\x7B" + 0x9CCD219A).s, STATS_ALL, nDbUid);
reply = (redisReply *)redisCommand(pRedis, szCommand);
if (NULL == reply) goto STEP_END;
if (REDIS_REPLY_ERROR == reply->type)goto STEP_END;
if (REDIS_REPLY_NIL == reply->type)
{
nRank = pServer->m_nMaxPlayers;
bRet = TRUE;
}
if (REDIS_REPLY_INTEGER == reply->type)
{
nRank = (reply->integer & 0xFFFFFFFF) + 1;
bRet = TRUE;
}
if (NULL != reply)
{
freeReplyObject(reply);
reply = NULL;
}
三、总结避坑
1、尽量不要使用固定字符串做KEY
在编写排名的时候,一般武器数据的KEY中不会用到像"kill"这种字符类型,通常用一个数字代替方面扩展时,只需要修改enum宏即可,武器 + 数据类型 来拼接 %u:%u 的KEY_NAME,类似下面这种:
// push数据类别 // 命令 字符 积分
enum EnumPushType{
EPT_UNKNOW = '@', // 未知 64 @ +0
EPT_KILL, // 杀敌 65 A +2
EPT_SHOT, // 射击 66 B +0
EPT_HEADSHOT, // 爆头 67 C +1
EPT_HIT, // 击中 68 D +0
EPT_DAMAGE, // 伤害 69 E +0
EPT_DEATH, // 死亡 70 F -2
EPT_FIRSTKILL, // 首杀 71 G +1
EPT_FIRSTDEATH, // 首死 72 H +0
EPT_BOMB_DEFUSION, // 拆除C4 73 I +2
EPT_BOMB_PLANTING, // 安装C4 74 J +2
EPT_TIME_ONLINE, // 在线 75 K +0 每秒+0.002分(每小时7.2分)
EPT_KILL_WORLD, // 摔死 76 L +0
EPT_KILL_SELF, // 自杀次数 77 M +0
EPT_MAX_PLAYER, // 最大玩家 78 N +0
EPT_RANK, // 当前排名 79 O +0
EPT_SCORE, // 当前积分 80 P +0
// 身体伤害
EPT_DMAGE_NONE, // 击中空枪 81 Q +0
EPT_DMAGE_HEAD, // 击中头部 82 R +0
EPT_DMAGE_CHEST, // 击中胸部 83 S +0
EPT_DMAGE_STOMACH, // 击中胃部 84 T +0
EPT_DMAGE_LEFTARM, // 击中左臂 85 U +0
EPT_DMAGE_RIGHTARM, // 击中右臂 86 V +0
EPT_DMAGE_LEFTEG, // 击中左脚 87 W +0
EPT_DMAGE_RIGHTEG, // 击中右脚 88 X +0
EPT_DMAGE_SHIELD, // 击中盾牌 89 Y +0
// 武器+BKILL
EPT_BKILL, // 被击杀 90 Z +0
EPT_BHEAD, // 被爆头 91 [ +0
// 击中次数
EPT_HIT_NONE, // 击中空枪 92 \ +0
EPT_HIT_HEAD, // 击中头部 93 ] +0
EPT_HIT_CHEST, // 击中胸部 94 ^ +0
EPT_HIT_STOMACH, // 击中胃部 95 _ +0
EPT_HIT_LEFTARM, // 击中左臂 96 ` +0
EPT_HIT_RIGHTARM, // 击中右臂 97 a +0
EPT_HIT_LEFTEG, // 击中左脚 98 b +0
EPT_HIT_RIGHTEG, // 击中右脚 99 c +0
EPT_HIT_SHIELD, // 击中盾牌 100 d +0
// 混战参数 add by MT 2023-09-30
EPT_ROUND, // 总回合
EPT_RWIN_T, // 回合:T杀完胜利
EPT_RWIN_BOOM, // 回合:T爆炸胜利
EPT_RWIN_CT, // 回合:CT杀完胜利
EPT_RWIN_DEFUSE, // 回合:CT爆炸胜利
EPT_RWIN_SAVED, // 回合:CT时间结束胜利
EPT_RWIN_RESCUE, // 回合:CT解救人质胜利
EPT_RWIN_NOT_RESCUE, // 回合:CT未解救人质胜利
EPT_RWIN_TYPE1, // 回合:保留胜利1
EPT_RWIN_TYPE2, // 回合:保留胜利2
EPT_RLOSE, // 失败回合
EPT_REVEN, // 平局回合
// 比赛参数
EPT_SESSION, // 总场次
EPT_SWIN, // 胜利场次
EPT_SLOSE, // 失败场次
EPT_SEVEN, // 平局场次
EPT_MVP, // 最佳次数
EPT_RWS, // 每局贡献评分(伤害占比)
// 每回合杀敌统计
EPT_KILL_0, // 0K 酱油局
EPT_KILL_1, // 1K
EPT_KILL_2, // 2K
EPT_KILL_3, // 3K
EPT_KILL_4, // 3K
EPT_KILL_5, // 5K
EPT_KILL_6, // 6K
EPT_KILL_7, // 7K
EPT_KILL_8, // 8K
EPT_KILL_9, // 9K
EPT_KILL_10, // 10K
EPT_KILL_11, // 11K
EPT_KILL_12, // 12K
EPT_KILL_13, // 13K
EPT_KILL_14, // 14K
EPT_KILL_15, // 15K
EPT_KILL_16, // 16K
// 残局统计
EPT_1V1, // 1v1
EPT_1V2, // 1v2
EPT_1V3, // 1v3
EPT_1V4, // 1v4
EPT_1V5, // 1v5
EPT_1V6, // 1v6
EPT_1V7, // 1v7
EPT_1V8, // 1v8
EPT_1V9, // 1v9
EPT_1V10, // 1v10
EPT_1V11, // 1v11
EPT_1V12, // 1v12
EPT_1V13, // 1v13
EPT_1V14, // 1v14
EPT_1V15, // 1v15
EPT_1V16, // 1v16
// 残局信息
EPT_1ROUND, // 残局场次
EPT_1RWIN, // 残局胜利
EPT_ASSIST, // 助攻次数
EPT_ADR, // 场均实际伤害占比累计
// 穿墙信息
EPT_WALL_HIT, // 穿墙累计命中次数
EPT_WALL_DAMAGE, // 穿墙累计射击伤害
EPT_WALL_HEAD, // 穿墙累计爆头次数
EPT_WALL_KILL, // 穿墙累计击杀次数
EPT_BWALL_HIT, // 被穿墙累计命中次数
EPT_BWALL_DAMAGE, // 被穿墙累计射击伤害
EPT_BWALL_HEAD, // 被穿墙累计爆头次数
EPT_BWALL_KILL, // 被穿墙累计击杀次数
};
这一个宏再对应一个double数组来计算得分,如此只需要一个这样的函数作为网关总接口即可:
// 提交排名数据
UINT redis_PushCommand(REDIS_SERVER *pServer, const UINT nDataType, const UINT nWeapon, UINT nDbUid, UINT nValue);
2、尽量用事务方式读写
最后注意一下用事务处理的方法操作,可以加快读写效率,普通的10倍以上,示例代码如下:
// 新版采用事务方式读取,加快速度
for (i = 0; i < MAX_WEAPONS; i++)
{
nWeapon = i + EPT_UNKNOW;
for (j = 0; j < MAX_OPT_COUNT; j++)
{
wsprintf(szCommand, /*HGET %s:%u %u:%u*/XorStr<0x18, 17, 0xF6DE906E>("\x50\x5E\x5F\x4F\x3C\x38\x6D\x25\x05\x54\x02\x06\x51\x1F\x03\x52" + 0xF6DE906E).s, STATS_ALL, nDbUid, , EPT_UNKNOW + j, nWeapon);
redisAppendCommand(pRedis, szCommand); // 事务读取
}
}
// 遍历所有武器数据
for (i = 0; i < MAX_WEAPONS; i++)
{
// 遍历所有参数
for (j = 0; j < MAX_OPT_COUNT; j++)
{
nRet = redisGetReply(pRedis, (void **)&reply);
if (nRet != REDIS_OK)
continue;
if (reply->type == REDIS_REPLY_STRING)
{
sData.m_nValue[i][j] = atoi(reply->str);
}
if (reply->type == REDIS_REPLY_INTEGER)
{
sData.m_nValue[i][j] = reply->integer & 0xFFFFFFFF;
}
}
}