介绍
Field Day 计分台收到的尝试成绩并不按发生顺序到达。Alex 的三次成绩分别为 80、90 和 85 分。最佳成绩是 90:这些是每次尝试的完整结果,不是要累加的分数。另一位选手也叫 Alex,因此姓名不能标识参赛者。当第三名分数出现并列时,所有达到该分数的选手都晋级。
你将保存可复用的 Redis 成绩记录和晋级筛选函数,处理实际成绩表,并公布真实晋级选手。之后,你会完成一个成绩越低越好的小型计时赛。两项练习都使用准备好的合成身份和整数成绩。前置知识为基本终端导航,以及 Redis 连接、GET/SET;有序集合和提供的 Python 函数模板会在使用前讲解。Redis 和 Terminal 已准备好。
在 /home/labex/project/score-desk 中工作。创建 main.py 和 practice.py;只更新 score:main:best、score:practice:best 和以 score:demo: 开头的临时键。保留原始 CSV 成绩表、名册哈希、上一轮数据和所有提供的工具。较早选手的主赛成绩 88 分和较早跑者的练习成绩 72 秒必须保留,尽管新成绩表中没有他们的尝试记录。
即使与另一份提交重叠,单次尝试也必须安全更新该选手的最佳成绩。晋级筛选在提交结束后进行;其两次读取并不是变化中比赛的快照。成绩为 1 到 1000000 的整数。本练习不实现成绩申诉、删除错误最佳成绩、作弊检测、小数计时或持久化及故障转移。
保留每次完整成绩中的最佳值
本步骤中,你将保留每位选手最高的一次完整成绩,防止迟到的较低成绩将其替换。
使用 Terminal,从普通 shell 开始。提供的 desk 程序读取真实成绩表和数据库,原样调用你保存的函数,不会替你计算或修复正确的最佳成绩。./ 表示运行当前目录中的文件。切换目录并检查成绩表和名册:
cd /home/labex/project/score-desk
./desk attempts main
./redis HGETALL score:main:names
./desk data main
player_id 是稳定标识;score 是一次尝试的完整分数。ID p01 和 p02 属于两位都叫 Alex 的选手。准备好的主赛有序集合目前只有成绩为 88 的 p90,必须保留。Redis 在单独的哈希中存储姓名,即从选手 ID 到姓名的映射。HGETALL 读取该映射;你无需编辑它。
有序集合为每个成员保存一个数值成绩,同一个成员只出现一次。这里成员是选手 ID。Redis 按成绩排列成员。普通 ZADD key score member 会添加新成员或替换其已有成绩。使用可丢弃的演示键,观察为什么最后到达的成绩不一定最好:
./redis ZADD score:demo:last 80 p01
./redis ZADD score:demo:last 90 p01
./redis ZADD score:demo:last 85 p01
./redis --raw ZSCORE score:demo:last p01
最终成绩为 85,因为最后一条命令覆盖了 90。ZSCORE 读取一个成员的成绩;--raw 要求 CLI 输出普通值。ZINCRBY 则会累加尝试成绩,从这三个值算出 255。该操作适用于增量,但这些尝试不是增量。
带 GT 的 ZADD 会添加新成员,或只在新值更大时修改已有成绩。比较和更新发生在同一条 Redis 命令内部。如果在客户端分别读取后再无条件写入,可能与另一个调用者发生竞态。尝试这个原子条件:
./redis ZADD score:demo:best GT 80 p01
./redis ZADD score:demo:best GT 90 p01
./redis ZADD score:demo:best GT 85 p01
./redis ZADD score:demo:best GT 90 p01
./redis --raw ZSCORE score:demo:best p01
成绩保持为 90,重复提交后也如此。默认情况下,ZADD 回复统计的是新增成员数,所以 0 既可能表示成功更新已有成员,也可能表示忽略了此次尝试。它不是错误。读取实际成绩才能区分这些情况。GT 不会阻止插入新选手;添加 XX 会阻止首次成绩写入,在这里是不正确的。
保存可复用函数,让每条提交行遵循相同规则。提供的使用方会传入 Redis 客户端、实际键、选手 ID 和成绩。Python def 定义函数;括号中的名称接收这些值。缩进的 return 发送命令并返回实际回复。带引号的单词是字面的 Redis 命令参数;不带引号的名称使用调用者提供的值。无需安装 Python Redis 包:提供的客户端发送原生 Redis 命令。
下面的 here-document 写出完整文件。cat > main.py 替换该文件。带引号的 PY 标记防止 shell 解释正文;结尾 PY 必须独占一行。保留 return 前的四个空格。将整个代码块粘贴到 Terminal:
cat > main.py <<'PY'
def record(client, key, player, score):
return client.command("ZADD", key, "GT", score, player)
PY
通过你的函数重放准备好的成绩表。随后再重放一次,并再次提交迟到的较低成绩:
./desk replay main
./desk data main
./desk replay main
./desk submit main p01 85
./redis --raw ZSCORE score:main:best p01
./redis ZREVRANK score:main:best p01
p01 保持为 90;另一位 Alex(p02)作为独立成员也保持为 90。完整集合还包含 p05=100、p03=95、p06=90、p04=70 和较早的 p90=88。data 按 Redis 升序交替显示 ID 和成绩字符串。
ZREVRANK 给出成员在降序中的从零开始的位置:第一位的位置为 0。这里 p01 的位置为 4,因为相同成绩按 ID 字节序逆序排列。该位置不会使达到晋级截止分数的并列选手失去资格。我们会用分数筛选晋级者,而不是只取固定数量的 ID。
文件名或命令输错时,读取错误,用 ls 检查实际文件,再重试修正后的命令。Score desk error 与成功的空结果不同。如果不小心应用了错误计分规则,先修正 main.py,检查原始成绩表,再用普通 ZADD 恢复每位受影响选手真正的最佳成绩;例如 ./redis ZADD score:main:best 90 p01。不要编辑成绩表或名册、清空本轮数据,或丢弃较早选手。检查实际结果后,再运行步骤检查。
纳入所有截止成绩并列者
本步骤中,你将把名次截止转换为包含边界的分数条件,让所有并列选手晋级。
继续使用 Terminal 和 score-desk 目录。本部分的提交已经结束。规则为前三个位置,加上所有与第三个位置分数相同的选手。这里有两个独立判断:找到该分数,再纳入所有达到它的选手。
ZRANGE 读取有序集合。不带分数选项时,其边界是包含端点、从零开始的位置。REV 反转分数顺序;WITHSCORES 包含每个成员的分数。第三个位置的索引为 2:
./redis --raw ZRANGE score:main:best 0 2 REV WITHSCORES
./redis --raw ZRANGE score:main:best 2 2 REV WITHSCORES
第一条命令只返回 p05、p03 和 p06。第二条返回 p06 和 90:截止分数为 90。如果只取三位选手,就会漏掉 p02 和 p01,而按已说明的并列规则,两者都应晋级。
使用 BYSCORE 时,边界是分数,不是位置。逆序时先给上界。+inf 表示无上限。普通分数边界包含相等值;以 ( 开头的边界会排除相等值,错误地丢掉所有截止分数并列者。读取完整晋级集合:
./redis --raw ZRANGE score:main:best +inf 90 BYSCORE REV WITHSCORES
五个 ID 全部出现,顺序为 p05、p03、p06、p02、p01。同分 ID 的排序是 Redis 提供的展示约定,不是淘汰并列选手或按姓名排序的规则。
让它适用于任意正整数 k。客户端返回交替包含字符串的 Python 列表:对于单个边界选手,结果为 ["p06", "90"]。列表索引 0 是 ID,索引 1 是分数。因此 boundary[1] 使用 Redis 返回的实际分数。k - 1 将请求的位置转换为从零开始的索引。赋值 = 将回复保存到局部变量 boundary。
if not boundary 检查列表是否为空。当 k 大于选手人数时,所有人都晋级;读取位置 0 到 -1,其中 -1 表示最后一个成员。对于空轮次,这也会如实返回空列表。该后备处理使用实际 Redis 集合,而非样例答案。
将下面的函数追加到 main.py。这里 >> 表示追加,而不是替换已有记录函数。Python 使用 if 下方额外的缩进来标识条件返回:
cat >> main.py <<'PY'
def qualify(client, key, k):
boundary = client.command("ZRANGE", key, k - 1, k - 1, "REV", "WITHSCORES")
if not boundary:
return client.command("ZRANGE", key, 0, -1, "REV", "WITHSCORES")
return client.command("ZRANGE", key, "+inf", boundary[1], "BYSCORE", "REV", "WITHSCORES")
PY
提供的 board 操作调用 qualify,再从真实名册附加姓名。它保留你返回的成员、数值和顺序,不会重新筛选、补齐缺失并列者或替换成正确截止分数。试用不同请求:
./desk board main 3
./desk board main 1
./desk board main 100
第一个请求返回五个选手对象。两条 Alex 记录都有各自不同的 player_id 值。第二个请求只返回成绩为 100 的 p05。最后一个返回全部七名选手,包括 88 分的 p90 和 70 分的 p04。读取榜单不会提交新成绩。
此函数的两次读取面向已结束提交的轮次,不保证在持续提交时得到同一个一致快照。单条条件记录命令具有原子性;这是两个不同的结论。保持记录函数可用,并运行步骤检查。
将相同规则应用于计时赛
本步骤中,你将在独立练习轮次中构建并使用完整的、成绩越低越好的记录和晋级方法。
继续使用 Terminal 和相同目录。短距离练习赛以正整数记录用时秒数,因此成绩越低越好。独立成绩表使用新 ID;较早跑者 t90 已有 72 秒成绩。写代码前先检查:
./desk attempts practice
./redis HGETALL score:practice:names
./desk data practice
t01 的三次成绩为 70、60 和 65 秒。最佳是 60,而非 65。LT 只在新值更低时修改已有成绩;它仍会添加新成员。在临时键上比较相同的三次尝试:
./redis ZADD score:demo:time LT 70 t01
./redis ZADD score:demo:time LT 60 t01
./redis ZADD score:demo:time LT 65 t01
./redis --raw ZSCORE score:demo:time t01
结果保持为 60。原子更新的道理和重复提交安全性与 GT 相同;只有更好成绩的方向发生变化。
对于成绩越低越好的榜单,省略 REV,使用升序。在截止位置 k - 1,纳入所有成绩小于或等于该值的选手。正向分数范围以 -inf 开始,表示无下限。将完整独立模板保存到 practice.py;其函数接口与主赛完全相同:
cat > practice.py <<'PY'
def record(client, key, player, score):
return client.command("ZADD", key, "LT", score, player)
def qualify(client, key, k):
boundary = client.command("ZRANGE", key, k - 1, k - 1, "WITHSCORES")
if not boundary:
return client.command("ZRANGE", key, 0, -1, "WITHSCORES")
return client.command("ZRANGE", key, "-inf", boundary[1], "BYSCORE", "WITHSCORES")
PY
提交练习成绩表,检查原生边界和包含端点的范围,再让真实榜单使用方给出结果:
./desk replay practice
./redis --raw ZRANGE score:practice:best 1 1 WITHSCORES
./redis --raw ZRANGE score:practice:best -inf 60 BYSCORE WITHSCORES
./desk board practice 2
./desk replay practice
./desk submit practice t01 65
./desk board practice 2
./desk board practice 100
./desk board main 3
边界为成绩 60 的 t01。包含并列者的前两位榜单包含 t03=50、t01=60、t02=60 和 t04=60。同名的 Sam 属于不同 ID。迟到的 65 不能替换 60,重放整张成绩表也不会改变任何最佳成绩。较大截止位置纳入全部六名跑者,包括 t90=72。主赛的五人晋级结果仍然可用。
计时相同时,Redis 按 ID 字节序升序排列;高分主赛榜单由于使用 REV,按字节序降序排列。两种约定都不会排除同成绩选手。对于这些准备好的 ASCII ID,上述顺序是稳定的。
现在你已拥有适用于两个方向的完整记录和晋级方法。保留两个文件、两个轮次、原始成绩表、名册和较早轮次的结果。检查会用其他 ID、数值、到达顺序和截止位置测试你原样保存的函数。检查真实结果后,再运行最终检查。
总结
你保留了稳定的选手标识,通过原子的 Redis 条件更新存储每次完整成绩中的最佳值,并区分了成绩优劣方向和展示顺序。你使用实际第 k 位的成绩,纳入所有截止成绩并列者,包括同名选手,同时保留较早的结果。保存的函数既适用于高分赛事,也适用于整数秒计时赛。晋级读取假定提交已结束;持久化、申诉和实时快照属于其他工作。



