MINESWEEPER 系统设计
原型文档 · 非正式发布返回游戏 ↗
工程契约设计 v0.8.1

每日榜单缓存与录像存储 v0.8.1#

2026-09-13 · 设计与可验证的Redis原语参考,尚未部署后端。按最新要求启用Redis,范围仅为每日四榜数据缓存、官方录像及播放块存储。录像文件/压缩正文/播放块都不写SQLite,个人收藏与未提交草稿仍仅本机。直播与PVP不在本版范围。

本轮实现修正见验收修复记录 v0.8.2:普通通关不保存官方正文,符合候选并同意公开后由受信任命令重演生成,等待持久化确认才发榜。MS_REPLAY_DAYS 在建榜时固定 public_until,正文和元数据期限一致。

1. 数据归属#

数据SQLiteRedis本机
账号、会话、经验、私人进度、授权与幂等回执持久化权威不存私人副本/待同步操作
每日成绩、四榜排名快照、generation、冻结与复核注记持久化权威四榜快照缓存,可由DB重建展示状态
上传/录像元数据user/game/格式/双hash/字节数/Redis key/逻辑截止/验证状态仅随数据必要结构官方草稿清单
候选上传正文不存gzip二进制String,TTL原设备保留可重试文件
已验证官方录像正文不存按game/hash唯一String,TTL可能保留原草稿
播放投影块只存索引/hash/时间范围/key/截止二进制String,TTL;可从原录像重建短期内存缓冲
个人收藏录像不存不存独立本地档案

Redis不是经验、身份、观看资格或比赛裁决的权威。服务端当前棋局、操作摘要仍在SQLite,但不能夹带可完整回放的录制历史。现有legacy_import BLOB只用于旧私人进度迁移,白名单必须拒绝个人录像/官方录像及base64伪装正文;本次没有要求移除所有非录像DB文件能力。

2. Redis键设计#

首期一个专用Redis实例,命名空间按环境隔离;通过MS_REDIS_PREFIX配置项目与环境,默认minesweeper:dev,生产使用minesweeper:prod;代码统一追加:v1版本段。以下以开发环境前缀minesweeper:dev:v1示意,连接使用DB 6。API不返回内部key,不允许客户端直连Redis。value使用二进制,不转base64。

Key类型/内容到期
minesweeper:dev:v1:lb:{date}:g:{generation}String;按SQLite排序的四榜同一代JSON,最多40行当天min(now+30秒, ends_at);历史缓存24小时
minesweeper:dev:v1:replay:{date:game_id}:upload:{upload_id}String;完整收到且校验hash的gzip上传包上传授权expires_at(不晚于日切)
minesweeper:dev:v1:replay:{date:game_id}:body:{compressed_sha256}String;已验证gzip过程,同段跨榜共用未公开候补到ends_at;准备正式入榜时提升到public_until
minesweeper:dev:v1:replay:{date:game_id}:chunk:{compressed_sha256}:{projection_version}:{index}String;授权播放器使用的可见投影块min(now+5分钟, 原正文当前绝对到期)

缓存30秒/24小时、播放块5分钟为初始假设 [待测试]:限制旧代内存与可重建数据占用。原始公榜录像public_until = 原题日ends_at + 30天沿用建议 [待确认][待测试]。所有时间从服务器规则/Redis时钟计算,不用客户端时间,不因观看、复用、重试或同时上两榜滑动续期。

生成快照只含稳定排名事实:user_id、rank、各档用时、形成时刻、game/segment引用。昵称、账号展示状态、复核注记、当前播放可用性不塞进长缓存,响应时在DB一致性读中投影;当天观看资格和曝光锁始终在SQLite实时核验。这样删号/撤公开不需遍历删除历史缓存才能生效。

选择整份四榜JSON,不用ZSET重新排名:每榜仅10行,DB已按完整破同分规则排序;用一个浮点score编码总用时/形成时间/序号会引入精度和顺序风险。

3. 榜单缓存读写与一致性#

写入顺序:在SQLite事务中发布四榜完整新generation并切日期指针→commit→缓存新generation并通知客户端。Redis缓存失败不回滚已提交名次;下一次GET从DB重建。旧generation键不强制删,按TTL自然退出。

读取顺序

  1. 短只读事务读取DB日期generation、ends_at/冻结状态及展示所需账号/注记信息,固定本次快照。
  2. GET该generation的缓存。命中验证date/generation/schema;未命中从同一已发布不可变DB快照取四榜。不给缓存保存会话或资格结果。
  3. 缓存写回用SET NX及有限TTL,同一代只写一致内容;旧请求只能填自己的旧代key,不能覆盖新代数据。是否仍为当前代影响新缓存是否值得写,不改变本次一致快照。
  4. 请求在取得DB快照时线性化,允许它与之后发布竞争;同一次四榜响应不能混代。日切状态依据服务器时间,不能因Redis仍有open字段就继续参榜。

API仍有一次轻量DB权威读取;缓存省掉重复构造/序列化排名数据,不承诺完全不查DB。Redis超时/故障时四榜GET直接读SQLite;写入、截止、退榜、奖励不能靠缓存判定。小范围缓存击穿用进程内singleflight,不把Redis扩成锁/任务队列。

历史冻结后缓存过期仅触发重新缓存同一排名,不是重新计算榜单,不产生新名次。SQL被冻结的generation和注记继续可查,即使录像已经到期。

4. 录像上传、核验与发布#

4.1 上传暂存#

维持先报成绩、候选才上传:SQLite写授权元数据后,客户端PUT二进制。服务器鉴权,受限接收与hash检查后,用SET key bytes NX PXAT expires_at一次原子写入正文和绝对TTL,禁止先SET再单独EXPIRE。原始文件可在有界内存/受控临时缓冲中校验,不能先塞DB再“转存Redis”。

成功后SQLite将upload标received并记录redis_key,回执只含元数据。Redis写成功、DB确认失败时,正文成为未引用暂存,TTL自行回收;同id同hash可在授权期内重试并完成DB确认,同id不同文件拒绝。命中旧对象不自动续期。未成功写入Redis不得向客户端谎报“文件已保存”。

4.2 验证与候补#

验证worker读取暂存正文;有限解压、事件上限、重演、header及服务器哈希锚点一致后,写确定性body key,初始PXAT=ends_at,并记录restricted_segments元数据。并发单项/综合验证同一game时使用唯一game_id和确定性key复用,不产生四份文件。过期或丢失重新检查来源,不把SQLite中的valid标志当作文件还在。

工作队列仍使用现有SQLite background_jobs,仅负责可靠核验和发布,不新增Redis队列。开始/提交验证前均查日界线;跨日任务不能通过延长TTL获得补榜资格。只有Redis写入对象成功才更新元数据record_complete,之后每次发布仍查对象存在和有效期。

4.3 正式发布#

进入单项/综合榜前,将相关body的绝对到期提升为该日期固定public_until;不能以“从现在再加30天”滚动续命。提升通过原子脚本检查存在、当前TTL有效及截止尚未到达。之后执行持久化确认策略,再在SQLite短事务重新核对日期/资格/版本/榜单阈值,发布四榜完整generation。段已公开过则不重复续期;读取与普通幂等重试也不续期。

Redis操作与等待确认均在SQLite写事务之外;如预检后竞争导致榜单/权限变化,事务拒绝或重新比较。若Redis成功但DB回滚,较长TTL的未引用对象也会自然到期,不安排扫描job查孤儿。DB提交后缓存写失败,名次已成立;不能重新执行一遍造成重复结算。

SQLite和Redis没有共同原子事务。上述顺序减少“排名已发但还没写录像”的窗口,不能保证预检后Redis立刻故障时文件仍可用。DB已提交名次保留,播放器显示暂不可用;不能把存储故障自动判为作弊或重排历史榜。需要两库故障注入测试,不把Lua原子性说成跨库原子性。

4.4 播放块#

完整gzip正文不直接提供下载链接。服务器按已验证记录生成有界投影块,块正文只写Redis;SQLite的restricted_chunks只含索引/key/字节数/hash/时间范围。播放时先重新鉴权和核对指定榜引用,再取body存在/当前过期信息与块。块丢失而body仍在,可按冻结引擎版本重建并写有限TTL;body丢失则不能靠不完整块拼装成已验证原始录像。

body提升到公开保留期时,旧播放块可以先按原短TTL过期,随后按需重建,不需要循环给所有块续期。每块的到期不能超过body实际到期或DB逻辑到期。鉴权和过期检查在释放可解码内容前再执行;不把Redis命中视为观看许可。

5. 自动到期,不设录像清理job#

  • 上传暂存、未公开候补、正文与播放块都写入绝对TTL,禁止持久无TTL键。Redis到期后读取视为不存在;物理内存由Redis主动/惰性过期机制逐步回收,不承诺在同一毫秒立即释放全部内存或归还OS。
  • 不依赖keyspace过期通知,也不依赖定时SCAN、每日遍历、录像清理队列。DB的expires_at不需要在到期秒钟被job改成expired:读时计算逻辑状态,元数据/历史排名可长期保留。
  • 为取消清理job,接受“曾经正式入榜”的旧录像保留到原public_until。掉榜不立刻删;未公开候补到日切自然到期。冻结不需要遍历Redis续期,因为所有曾公开段在发布前已设长期绝对截止。
  • 因此最终四榜最多60段是“最终引用上限”,不是Redis留存上限;一天内曾经入榜、发布失败却已提升TTL的记录也可能保留30天。不能同时承诺“只保留最终60段”和“完全不做日切筛选清理”。
  • 主动撤公开、举报处置和账号删除属于业务命令:先在DB撤访问权限,再UNLINK已知的body/upload keys;播放块因先查DB/body立即失去访问,到自身TTL物理回收。Redis失败时仍不能访问原内容,正文至原TTL到期;未到期重试可由原业务流程执行,不另加按到期扫描的录像job。
  • 现有地图生成、验证、同步日志/备份和账号删除任务继续存在。此次取消的是录像到期清理任务,不是取消所有后台作业。

恢复旧Redis持久化/备份时先对照DB撤公开/删除记录停止访问,并重放业务删除,不能将备份恢复视为重新授予公开许可。所有绝对截止保留原值,不因恢复变成新TTL。

6. Redis部署与故障语义#

建议Redis 7.2+(使用PXAT及WAITAOF);首期单实例,配置仅提供参考,不在本任务部署:

conf
appendonly yes
appendfsync everysec
maxmemory-policy noeviction

Redis承载录像正文的唯一服务端副本,不能视为可随意flush/清空的纯缓存。使用持久卷存AOF,缓存与录像虽同实例但只有两个受控命名空间;maxmemory按实际记录体积/峰值配置,为AOF重写、进程、缓冲和内存碎片预留空间。AOF/RDB不是SQLite,录像正文仍完全不进DB;如要求录像也绝不写Redis磁盘,则必须接受Redis重启后丢失,当前建议是开启持久化。

appendfsync everysec单独使用存在约1秒未落盘窗口。关键录像写入/首次公开TTL提升后,同一连接调用WAITAOF 1 0 timeout_ms并检查返回本地确认数;当前实现初值2000ms [待测试](真实everysec集成测试中1000ms出现确认超时,仍按不确定结果重试),缓存不等待。超时属于结果不确定,保留原key/hash/授权幂等查询或重试,不盲删、不宣称未写;未确认前不给公开成功。单机WAITAOF不等于有副本高可用,也不能防磁盘损坏/管理员误删。复制与恢复方案在扩容时独立评审。

情形榜单/账号上传/回放
榜单缓存miss从DB按同generation重建无影响
Redis暂不可达榜单回源DB,账号和私人同步继续上传/发布503;回放503暂不可用,不伪称已过期
内存达到maxmemory榜单缓存可跳过写回noeviction拒绝新正文,503容量不足;不淘汰既有未到期录像
元数据截止已过名次与注记保留410 REPLAY_EXPIRED,即使Redis异常残留键也不放行
Redis正常但原正文在截止前消失名次不因此变化REPLAY_MISSING,不可用;DB没有文件可自动恢复,不能只用hash还原
只有播放块miss名次不变body还在则有界重建,否则按原正文缺失处理
SQLite不可用不把缓存当权威返回新资格或发布拒绝敏感上传/播放,防绕过撤权

缓存实例和录像实例未来可以拆开,但这是部署隔离,不新增用途。当前不使用Redis存会话、经验、限流额度、锁、每日隐藏地图或一般私人数据。

7. 安全与容量#

7.1 上传硬限制与拒收#

以下为首版服务端硬限制的设计初值 [待测试]:单段完整gzip文件最多1MiB(1,048,576字节),流式解压输出最多8MiB(8,388,608字节),规范事件最多20,000条;任意一项超限即拒绝,不截断、不删除必要动作来凑入榜证据。综合三段分别上传,每段独立受限,最多3MiB;跨榜复用不复制文件。1MiB相对于前述100KiB均值假设留约10倍余量,但须采集高级长局及无障碍操作录像验证,不能宣称已经覆盖所有正常记录。

入口按顺序执行:请求头大小限制→鉴权/候选授权/去重预检→并发和字节配额→有界流式接收→双hash/受限解压/解析→Redis暂存。代理须关闭此路由的整包预缓冲,禁止在鉴权前无限缓存正文;Content-Length超过上限直接413,缺失或伪造长度仍按实际接收累计,检测超限立即停止接收并关闭或重置该请求流。禁HTTP Content-Encoding自动展开;应用只接受单一gzip成员,拒绝拼接成员、尾随数据、嵌套压缩及未知字段,拒绝前不把超限文件写入Redis或SQLite。

解压必须边产出边计数,超过8MiB立即中止;不能先完整解压到内存再检查。解析限制事件数、字段长度/类型与分配大小,不能按恶意声明预分配大数组。核验CPU预算沿用5秒、2并发、队列20的待测初值,解析/重演超预算不能发布。压缩比只作观测,绝对输出上限才是强制防线。

每账号所有设备/四榜共享1路上传、3MiB突发与3MiB/分钟恢复;全局4路上传、4MiB突发与16MiB/分钟恢复。全部为 [待测试],依据是允许一套最多3MiB的综合证据并限制入口总资源;重试、失败和无效正文的实际接收字节同样消耗额度,不能只算成功存储量。无效鉴权请求在边缘按IP及全局连接/请求限流,不能只靠账号额度;共享网络不凭单个IP封号。桶和并发计数沿用单实例进程内实现,不扩展Redis用途,重启可能恢复一次受限突发;不是严格持久化每日流量配额。

上传空闲10秒、总请求60秒或授权截止中较早者中止 [待测试];超并发/额度429并给Retry-After,拒收请求不排队持有正文。客户端先本地检查并明确“录像超出参榜限制”;服务器重做全部检查,恶意客户端不能绕过。超限/解析错误本次证据不能入榜,但保留已确认通关、经验与旧合法名次;不把一次容量超限自动判作弊。边缘全局限流与带宽防护仍有必要,应用文件上限不能阻止网络层洪泛。

7.2 双hash与上传前去重#

  • content_sha256:对版本化规范二进制录像原文计算SHA-256,包含账号、game/attempt、日期、地图/规则、完整事件;不是随意JSON序列化的hash。compressed_sha256:对实际传输的完整gzip文件计算SHA-256。两者都由客户端在manifest登记,服务器首次接收时重新计算,不能信任申报值;规范化不能丢弃服务器锚定的事件。
  • 逻辑去重范围为user_id + game_id + format_version + content_sha256,利用现有每game唯一录像约束、上传清单及段元数据查询;不跨账号复用,也不向其他账号泄露hash是否存在。不同gzip参数可能使压缩hash变化而内容hash不变,客户端采用固定gzip封装并保留原上传包重试;既有manifest不可用新压缩hash覆盖。
  • 成绩预选/上传授权之前先查本人已验证段、内容hash、撤权状态、Redis对象及实际TTL。正文仍在且有效则返回reused_segments,不发新的上传授权,不传文件、不重复核验、不续TTL。仅在日内、原授权策略允许的缺失情形补传,历史冻结或逻辑过期不能借hash补榜。
  • 相同请求正在上传/验证时,返回既有upload_id/state,不新建上传、不重复入验证队列;接收并发占用按game/upload原子获取,另外的PUT在读正文前返回409 UPLOAD_IN_PROGRESS。账号切设备、换Idempotency-Key都不能绕开业务身份去重。
  • PUT回执丢失后先GET本人pending/state:已received/verifying且Redis暂存或body实际存在就等待原流程,已valid则复用。收到重复PUT也先做状态/文件预检,已保存且hash相同返回原回执并终止请求体接收;客户端可用Expect: 100-continue,但服务端不能依赖客户端支持。已经到达网络的字节无法追回,仍计额度。
  • SQLite元数据存在而Redis对象缺失不视为上传完成;恢复用原upload_id与固定hash,并重新检查截止、权限及大小。相同game/manifest不同内容返回409 REPLAY_HASH_CONFLICT,实际文件与声明hash不符返回422 REPLAY_HASH_MISMATCH;hash匹配只是内容一致性,仍须重演核验服务器锚点。最终正文继续按既有game/压缩hash key只存一份,暂存与body可能短期同时存在,TTL照旧。

7.3 部署与容量#

只允许应用私网访问,认证/ACL按minesweeper:dev:v1:lb:*minesweeper:dev:v1:replay:*最小权限分配;禁公网暴露,传输按部署网络启用TLS,密钥不写仓库。禁止业务调用FLUSHDB/FLUSHALL/KEYS,全局扫描不参与正常读写;Redis key不是给用户的下载凭证。

录像存储前仍校验已登录同一账号、候选授权、固定hash/格式、实际压缩/展开字节、事件数及CPU预算;gzip外壳、请求/回执/导出/日志/审计白名单防止正文绕道SQLite。读取只经过受控API,不缓存观看授权。

容量公式:M ≈ R × P × S × overhead + staging_peak + chunk_peak + leaderboard_cache + runtime_reserve

R为保留天数,P为每天曾被提升到公开TTL的不同段数(含未最终入榜/跨库回滚孤儿),S为平均压缩字节,overhead为实测Redis对象/allocator成本。例P=300、S=100KiB、R=30时仅正文约879MiB [待测试示例],远大于最终60段估算。必须统计上传/提升次数、used_memory/RSS、键TTL、写拒绝、AOF状态、缓存命中与缺失正文比例;监测不创建录像清理job。

8. DDL与接口变更#

  • daily_replay_uploads删除compressed_content,改redis_key;格式/hash/字节数/state/expires_at保留。
  • restricted_segments删除compressed_content,增加redis_key、compressed_bytes、storage_expires_at;expires_at仍是public_until,storage_expires_at是最后确认的Redis绝对到期记录,不证明key必然仍在。
  • restricted_chunks删除projected_content,改redis_key、byte_length、expires_at。三张表没有录像BLOB或base64载体;JSON schema仍要拒绝录像正文绕道。
  • PUT uploadsPOST complete、watch/chunks URL不变;服务端存储实现转Redis。加入REPLAY_STORAGE_UNAVAILABLE/REPLAY_STORAGE_FULL(503)、REPLAY_MISSING(410)错误;榜单正常回源不会把Redis缓存异常暴露为整个页面失败。
  • SQL检查能验证元数据和无BLOB字段,不能代替实际文件字节/hash校验、TTL或跨库一致性测试。

9. 验证与变更#

已有首阶段服务端代码:上传大小/hash/去重、Redis TTL与榜单缓存读取;received不等于重演通过,不会自动发榜。23项模块检查和8项真实Redis/HTTP场景通过,完整账号/每日/播放接入仍未实现。

Redis写入脚本验证写入即带TTL和同key不同正文拒绝;公开TTL提升脚本验证固定截止、缺失拒绝和日切后不能提升;验证脚本使用临时目录、Unix socket和独立Redis进程,不连接已有实例。

2026-09-13验证:SQLite 3.40.1的50项DDL检查、Redis 8.0.3的8项原语检查通过。Redis原语通过也不代表两库发布、HTTP权限、真实录像引擎和AOF故障恢复已实现;这些列入验收矩阵,70项端到端场景尚未运行。

版本日期变化
0.8.12026-09-13收紧录像大小/事件上限;双hash上传前去重;补流式拒收、配额、慢连接及验证资源保护
0.8.02026-09-13Redis仅缓存每日四榜和存官方录像;全部录像正文移出SQLite;绝对TTL自动回收、无录像到期清理job,明确故障和容量边界