背景:玩家的游戏战绩存储于 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 请求成本 |
---|---|---|---|
纯Redis | 10万 * 200B = 20MB → $0.01/月 | $0.3/月 | 无 |
直接写S3 | 20MB → $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生命周期策略,还能进一步降低成本。