ClickHouse:博彩分析完整数据类型参考(我踩过的坑)
一个字节让我浪费了500 GB磁盘空间
刚开始使用ClickHouse时,我对所有字段都用String:user_id、event_time、投注金额。一个月后,一个20亿行的表重达4 TB。同事看了表结构说:“为什么把数字存成字符串?”结果,user_id用String比用UInt64多占8倍空间。我改了——表缩小到800 GB。
ClickHouse提供了几十种数据类型。选对类型不只是节省GB——还关乎查询速度(减少磁盘读取)和稳定性(用Decimal代替Float不会因舍入错误让你意外)。
下面是我从真实项目(博彩分析、欺诈检测、LTV)中学到的一切。最后——一个现成的博彩平台表结构。
1. 整数类型:统计用户和投注
ClickHouse支持有符号(Int)和无符号(UInt)整数,从8位到256位。
| 类型 | 范围 | 大小 | 使用场景 |
|---|---|---|---|
UInt8 |
0..255 | 1字节 | 状态(0/1)、错误码 |
UInt16 |
0..65535 | 2字节 | 端口号、小计数器 |
UInt32 |
0..42亿 | 4字节 | 国家ID、事件类型 |
UInt64 |
0..1800亿亿 | 8字节 | user_id、event_id、金额(分) |
Int128/256 |
极大 | 16/32字节 | 加密哈希、超大计数器 |
生产实践:
user_id UInt64, -- 80亿用户足够
age UInt8, -- 没人活过255岁
country_code UInt16, -- 全球197个国家,但UInt16更友好
is_fraud UInt8, -- 0或1,何必更多
常见错误: 所有字段都用UInt64。如果字段只取0或1(标志位),UInt8紧凑8倍。十亿行就是8 GB vs 1 GB。
我踩过的坑: 我把timestamp存为UInt64(Unix时间)。能用,但无法使用toDate()、toHour()等日期函数。要用DateTime。
2. Float32/Float64:钱包还是窟窿?
odds Float64, -- 赔率可以是2.5、1.85、100.0
probability Float32, -- 概率0.1..1.0,32位精度足够
为什么Float对金融危险:
SELECT 0.1 + 0.2 AS float_sum;
-- 结果:0.30000000000000004(经典IEEE 754)
假设你有100万笔0.01分的投注。舍入误差就成了真金白银。投注金额和赔付用Decimal。
何时Float没问题: 赔率(2.15、1.85)、概率、百分比、机器学习指标。
3. Decimal(P, S):金钱需要精度
bet_amount Decimal(18, 2), -- 最多10^16卢布,2位小数
payout Decimal(20, 2), -- 赔付可能大于投注
balance Decimal(32, 2) -- 玩家终身余额
P(精度)——总位数(最多38)S(小数位数)——小数点后位数
我总结的规则: 卢布和美元——Decimal(18,2)绰绰有余(万亿级)。加密货币——Decimal(38,8)。
Decimal运算:
SELECT
bet_amount * odds AS potential_payout, -- Decimal * Float64 → Decimal
bet_amount + 0.01 AS rounded_up -- 可行,但小心
FROM bets;
我踩过的坑: ClickHouse处理不同小数位数的Decimal * Decimal时,会提升到较大者。我们第四位小数有分,从未舍入。修复:用toDecimal32()显式转换。
4. String vs FixedString vs LowCardinality(String)
String——用于长文本
session_id String, -- 无连字符UUID,可变长度
user_agent String, -- 长字符串,唯一值
raw_json String -- JSON日志
FixedString(N)——固定长度(很少用)
country_code FixedString(2), -- 'RU', 'US', 'DE'正好2字节
md5_hash FixedString(32) -- 总是32字符
我几乎不用: 如果插入更短的字符串,ClickHouse会用空字节填充,导致比较时出问题。
LowCardinality(String)——重复值的魔法
sport LowCardinality(String), -- '足球', '篮球', '网球'(重复)
device LowCardinality(String), -- 'ios', 'android', 'web'(10-20个唯一值)
outcome LowCardinality(String) -- '赢', '输', '无效'
原理: ClickHouse构建唯一值字典,只存储索引。对于只有10个唯一值的列,节省100倍空间。
真实案例: 在投注表中,sport字段重复数十亿次。将String换成LowCardinality(String)后,列大小从40 GB降到400 MB。
何时不用: 如果唯一值超过1万(如user_agent)。字典膨胀,性能下降。
5. DateTime vs DateTime64 vs Date:时间就是金钱
| 类型 | 精度 | 大小 | 使用场景 |
|---|---|---|---|
Date |
天 | 2字节 | 分区、日报 |
Date32 |
天(到2106年) | 4字节 | 如果需要年份>2149 |
DateTime |
秒 | 4字节 | 大多数事件 |
DateTime64(3) |
毫秒 | 8字节 | 实时投注、事件排序 |
DateTime64(6) |
微秒 | 8字节 | 日志、指标 |
我在生产项目中的用法:
event_time DateTime64(3), -- 毫秒用于实时分析
registration_date Date, -- 按天分区
last_update DateTime -- 秒精度足够
常见错误: 将时间存为Unix时间戳(UInt64)。会丢失所有日期/时间函数:
-- 这样不行:
SELECT toHour(event_time_uint) ... -- 错误
-- 需要:
SELECT toHour(toDateTime(event_time_uint)) ... -- 额外转换
我踩过的坑: 我用DateTime存实时投注。当需要区分时,同一秒内的10个事件无法区分。换成DateTime64(3)后,顺序恢复了。
6. UUID:当标准比速度更重要
session_id UUID,
bet_uuid UUID DEFAULT generateUUIDv4()
UUID占16字节(相当于两个UInt64)。比较比数字慢。
何时仍用: 需要在客户端生成ID而不访问数据库、与外部系统集成、分布式系统没有单一生成器。
替代方案: UInt128作为两个64位数,但会丢失toUUID()等函数。
7. Array(T):无需范式化存储列表
tags Array(String), -- ['足球', '直播', '赛前']
coeff_history Array(Float64), -- [1.5, 1.8, 2.1] 赔率变化
bet_bundle Array(UInt64) -- 串关中的投注ID
实际帮助: 我们将单个事件的赔率变化历史存储在数组中。在关系型数据库中,需要单独的表。ClickHouse配合arrayMap、arrayFilter、arrayJoin很好用。
真实查询: 找出赔率下降超过30%的事件:
SELECT event_id, coeff_history
FROM events
WHERE arrayExists((x, i) -> i > 1 AND x / coeff_history[i-1] < 0.7, coeff_history);
限制: 嵌套数组(Array(Array(String)))几乎不支持。反范式化为扁平结构。
8. Nullable(T):要避免的邪恶
bonus_amount Nullable(Decimal(10,2)),
refund_reason Nullable(String)
Nullable为每个值增加一个额外标志(位掩码)。这意味着:
- 每行多一个字节
- 聚合更慢(SUM、AVG必须检查NULL)
- 某些引擎不支持(如ORDER BY键)
我的立场: 在ClickHouse中,我避免NULL。替代方案:
- 数字:用
0代替NULL - 字符串:用
''(空字符串) - 日期:用
'1970-01-01'
例外: 当0是合法值时。例如,0卢布的奖金与未发放奖金不同。此时用Nullable。
9. Enum8/Enum16:用于有限值列表
outcome Enum8('win' = 1, 'loss' = 2, 'void' = 3),
bet_type Enum8('single' = 1, 'express' = 2, 'system' = 3),
event_status Enum8('scheduled' = 1, 'live' = 2, 'finished' = 3, 'cancelled' = 4)
优点: 存储为1字节(Enum8)或2字节(Enum16),比较快,输出可读。
内部: ClickHouse存储数字,但SELECT输出字符串。
INSERT INTO bets (outcome) VALUES ('win'); -- 作为字符串
INSERT INTO bets (outcome) VALUES (1); -- 或作为数字
常见错误: 尝试用ALTER TABLE ... MODIFY COLUMN向Enum添加新值。ClickHouse不允许不重建表就修改Enum。所有可能值必须提前规划。
如果不确定,用LowCardinality(String)。牺牲一个字节换取灵活性。
10. IPv4/IPv6:检测多账户
ip_address IPv4,
client_ip IPv6 -- 移动运营商使用IPv6
存储为二进制(4或16字节),支持快速子网操作。
真实用例: 找出来自同一IP的用户:
SELECT user_id, count() AS bets
FROM bets
WHERE ip_address = IPv4StringToNum('192.168.1.1')
AND created_at >= today() - 7
GROUP BY user_id
HAVING bets > 50; -- 潜在机器人
救命函数: IPv4NumToString()、IPv4CIDRToRange()、isIPv4String()。
博彩平台完整表结构(生产验证)
CREATE TABLE betting.bets_full
(
-- 标识符
bet_id UInt64 DEFAULT generateUUIDv4() (materialized) ???
-- 不,UUID单独
bet_uuid UUID DEFAULT generateUUIDv4(),
user_id UInt64,
event_id UInt64,
session_id String, -- 不是UUID,来自nginx日志
-- 时间戳
created_at DateTime64(3), -- 毫秒用于实时
updated_at DateTime,
bet_date Date DEFAULT toDate(created_at), -- 物化列
-- 货币字段(仅Decimal!)
bet_amount Decimal(18, 2),
odds Float64, -- 赔率——Float没问题
potential_payout Decimal(20, 2) ALIAS bet_amount * odds,
real_payout Decimal(20, 2),
-- 重复类别
sport LowCardinality(String),
bet_type Enum8('single' = 1, 'express' = 2, 'system' = 3),
outcome Enum8('win' = 1, 'loss' = 2, 'void' = 3),
device_type LowCardinality(String),
-- 列表(变更历史)
odds_history Array(Float64), -- 赔率随时间变化
cashout_attempts Array(DateTime64(3)), -- 提现尝试
-- 欺诈检测
ip_address IPv4,
fingerprint FixedString(32), -- 浏览器哈希
-- 仅在真正需要时使用Nullable
refund_amount Nullable(Decimal(18, 2)), -- 无退款时为NULL
cancellation_reason LowCardinality(String)
)
ENGINE = MergeTree()
PARTITION BY bet_date
ORDER BY (created_at, user_id)
SETTINGS index_granularity = 8192;
为什么这个表结构在生产中存活:
bet_date从created_at物化——基于日期的分区无需额外计算LowCardinality用于sport和device_type——节省80%空间ALIAS用于potential_payout——不存储,查询时计算- 没有
Nullable,除非0或空字符串不够用
下一步
选对类型是基础。在后续文章中,我们将介绍基于这些数据的聚合、窗口函数和物化视图。
本文中的表已在我们生产集群中运行一年,包含3万亿条记录。重12 TB(使用ZSTD压缩)。如果全部用String,将是40 TB。明智地选择类型。
← 上一篇: ClickHouse 客户端:我是如何在博彩项目中与控制台和 HTTP API 交上朋友的
→ 下一篇: ClickHouse中的MergeTree:引擎如何将分析数据切分为颗粒并合并分区
— Editorial Team
暂无评论。