Administrator
Published on 2025-03-28 / 249 Visits
8
0

德州扑克玩家战绩的存储空间优化

背景:玩家的游戏战绩存储于 Redis,然受限于战绩数据量的增长导致的 Redis 最多只能存储七天
需求:增加可查看历史战绩为最近30天
优化后:采用 Redis + S3 存储,将热数据存储在高性能、高成本的存储设备上,以保证快速的数据访问和处理。而对于那些冷数据,可以存储在成本较低、访问频率较低的存储介质上


优化数据存储结构,减少冗余字段

优化ISO 8601 时间中 value 的不必要的格式

"create_at": "2020-02-01T00:00:01" ==> "create_at": "20200201T000001Z"

import sys

# 时间字符串
time_str1 = "2020-02-01T00:00:01"
time_str2 = "20200201T000001Z"

# 计算每个字符串的大小(以字节为单位)
size_str1 = sys.getsizeof(time_str1)
size_str2 = sys.getsizeof(time_str2)

# 将字节转换为 KB(1 KB = 1024 字节)
size_str1_kb = size_str1 / 1024
size_str2_kb = size_str2 / 1024

# 输出结果
print(f"时间字符串 '{time_str1}' 占用的空间为: {size_str1} 字节 ({size_str1_kb:.6f} KB)")
print(f"时间字符串 '{time_str2}' 占用的空间为: {size_str2} 字节 ({size_str2_kb:.6f} KB)")
print(f"优化百分比: {((size_str1 - size_str2) / size_str1) * 100}%")
时间字符串 '2020-02-01T00:00:01' 占用的空间为: 68 字节 (0.066406 KB)
时间字符串 '20200201T000001Z' 占用的空间为: 65 字节 (0.063477 KB)
优化百分比: 4.411764705882353%

优化重复字段

优化前

"stake_tracker": [
      {
        "create_at": "20220519T153017Z",
        "fund_in": 100
      }
    ]

优化后

"stake_tracker": [
      {
        "at": "20220519T153017Z",
        "fund_in": 100
      }
    ]

这里 fund_in 能很好的表达是带入的意思,优化 create_at 为 at 可节省空间

import sys

# 定义字段名
field1 = "create_at"
field2 = "at"

# 计算每个字段名的大小(以字节为单位)
size_field1 = sys.getsizeof(field1)
size_field2 = sys.getsizeof(field2)

# 将字节转换为 KB(1 KB = 1024 字节)
size_field1_kb = size_field1 / 1024
size_field2_kb = size_field2 / 1024

# 输出结果
print(f"字段 '{field1}' 占用的空间为: {size_field1} 字节 ({size_field1_kb:.6f} KB)")
print(f"字段 '{field2}' 占用的空间为: {size_field2} 字节 ({size_field2_kb:.6f} KB)")
print(f"优化百分比: {((size_field1 - size_field2) / size_field1) * 100}%")
字段 'create_at' 占用的空间为: 58 字节 (0.056641 KB)
字段 'at' 占用的空间为: 51 字节 (0.049805 KB)
优化百分比: 12.068965517241379%

数据压缩技术,使用数据压缩算法

Zstandard 是 Facebook 开发的高效实时压缩算法,具有高压缩比和快速压缩/解压速度的特点。
和其他主流压缩类库相比:

算法压缩速度解压速度压缩比内存占用
Zstandard⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡中等
GZIP⚡⚡⚡⚡⚡⚡⚡
BZIP2⚡⚡⚡
LZMA⚡⚡⚡⚡
LZ4⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
Snappy⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
Brotli⚡⚡⚡⚡⚡⚡⚡⚡⚡中等
  • Zstandard:在压缩速度和压缩比之间取得最佳平衡,支持多线程加速。
  • LZ4/Snappy:牺牲压缩率换取极速,适合实时数据处理(如 Kafka、Redis)。
  • LZMA/Brotli:追求极致压缩率,适用于长期存储或网络传输(如 HTTP 内容压缩)。
类库多线程支持流式处理字典训练数据校验
zstandard
gzip
bz2
lzma
lz4
snappy
brotli
  • zstandard 功能最全面,支持自定义字典(提升重复数据压缩率)和多线程。
    Zstd 通过预训练字典(如针对 JSON/日志格式),提升重复数据结构的压缩率(最高提升 10 倍压缩率),对于战绩这种重复性字段较高的数据很实用,同时 Zstd 也支持多线程压缩,对于多核的生产环境更能发挥出优势。
import json
import zstandard as zstd

# 原始数据
data = {
  "ok": True,
  "result": {
    "#settings": {
      "big_blind": 2,
      "small_blind": 1
    },
    "actions": [
      {
        "bet": 1,
        "delay_seconds": 0.803,
        "name": "small_blind",
        "pay_amount": 1,
        "pots": [
          1
        ],
        "seat": "2",
        "stack": 99,
        "street_bet": 1
      },
      {
        "bet": 2,
        "delay_seconds": 1.204,
        "name": "big_blind",
        "pay_amount": 2,
        "pots": [
          3
        ],
        "seat": "1",
        "stack": 98,
        "street_bet": 2
      },
      {
        "bet": 1,
        "name": "call",
        "pay_amount": 2,
        "pots": [
          4
        ],
        "seat": "2",
        "stack": 98,
        "street_bet": 2
      },
      {
        "name": "check",
        "seat": "1",
        "stack": 98
      },
      {
        "community_card_1_to_3": "8h,3h,ah",
        "name": "flop"
      },
      {
        "bet": 10,
        "name": "raise",
        "pay_amount": 12,
        "pots": [
          14
        ],
        "seat": "1",
        "stack": 88,
        "street_bet": 10
      },
      {
        "bet": 10,
        "name": "call",
        "pay_amount": 12,
        "pots": [
          24
        ],
        "seat": "2",
        "stack": 88,
        "street_bet": 10
      },
      {
        "community_card_4": "9h",
        "name": "turn"
      },
      {
        "name": "check",
        "seat": "1",
        "stack": 88
      },
      {
        "name": "check",
        "seat": "2",
        "stack": 88
      },
      {
        "community_card_5": "5s",
        "name": "river"
      },
      {
        "bet": 88,
        "name": "all_in",
        "pay_amount": 100,
        "pots": [
          112
        ],
        "seat": "1",
        "street_bet": 88
      },
      {
        "name": "fold",
        "seat": "2",
        "stack": 88
      },
      {
        "delay_seconds": 0.5,
        "name": "award",
        "net": 12,
        "prize": 112,
        "seat": "1",
        "stack": 112
      },
      {
        "delay_seconds": 0.5,
        "name": "award",
        "net": -12,
        "prize": None,
        "seat": "2",
        "stack": 88
      }
    ],
    "all_seat_public": {
      "1": {
        "initial_stack": 100,
        "position": "BB"
      },
      "2": {
        "initial_stack": 100,
        "position": "BTN",
        "public_hole_card": "8d,kh"
      }
    },
    "game_type": "TexasHoldem:Classic",
    "round_id": "11111111-2f5a-48ca-b4ff-55870b5ff2b9",
    "round_number": 1,
    "seats": {
      "1": "00000000-0000-0000-0000-000000000001",
      "2": "00000000-0000-0000-0000-000000000002"
    },
    "start_at": "20220519T153018Z",
    "user_secret": {
      "hole_card": "3c,ks",
      "seat": "1"
    }
  }
}

# 序列化为 JSON 字节数据
json_bytes = json.dumps(data, ensure_ascii=False).encode("utf-8")

# 使用 Zstd 压缩(默认级别 level=3)
compressor = zstd.ZstdCompressor()
compressed = compressor.compress(json_bytes)

# 计算节省空间
original_size = len(json_bytes)
compressed_size = len(compressed)
saved_bytes = original_size - compressed_size
saved_percent = (saved_bytes / original_size) * 100
compression_ratio = original_size / compressed_size if compressed_size != 0 else 0

# 输出结果
print(f"原始大小: {original_size} bytes")
print(f"压缩后大小: {compressed_size} bytes")
print(f"节省空间: {saved_bytes} bytes ({saved_percent:.1f}%)")
print(f"压缩率: {compression_ratio:.2f}")
原始大小: 1703 bytes
压缩后大小: 600 bytes
节省空间: 1103 bytes (64.8%)
压缩率: 2.84

冷热数据分离策略

热数据(近期战绩)

Redis 内只存储7天内数据,

冷数据(历史战绩)

使用 Scheduler 脚本在合适时间(比如凌晨)将当日或者最近几天的战绩存储到S3或者OSS。

sequenceDiagram
  participant Client
  participant Redis
  participant S3
  participant Scheduler

  Client->>Redis: 实时写入对局数据(热数据)。将产生战绩的 user_id 和 round_id 存到待写入库中
  Scheduler->>Redis: 每日凌晨扫描当天数据,根据待写入库获取用户战绩,写入成功后删除
  Scheduler->>S3: 批量上传文件
  S3-->>Scheduler: 上传成功
  Client->>Redis: 查询七天内的热数据(直接返回)
  Client->>S3: 查询七天后的冷数据(通过S3返回)

成本对比示例

假设每日新增 10万局 数据,每局原始数据 200B

存储方案每日存储成本每月成本PUT 请求成本
纯Redis10万 * 200B = 20MB → $0.01/月$0.3/月
直接写S320MB → $0.00046/月$0.014/月10万次PUT → 0.5/天 → 15/月
批量归档S3合并为1个10MB文件 → $0.00023/月$0.007/月1次PUT → 0.000005/天 → 0.00015/月

结论:批量归档方案相比直接写 S3,月度成本从 15 降至 0.007

对冷数据启用 S3生命周期策略,还能进一步降低成本。


Comment