返回首页

ClickHouse 数据类型:分析完整参考

所有 ClickHouse 数据类型的详细参考,包含来自赌博分析的实际示例。涵盖 Integer(UInt8–UInt256)、Float32/64、用于金融的 Decimal、String 与 FixedString 与 LowCardinality、用于实时投注的 DateTime64、UUID、用于存储赔率历史的 Array、Nullable(以及为什么避免使用)、用于状态的 Enum、用于欺诈检测的 IPv4/IPv6。包含完整的投注生产表模式,并解释每个选择和典型错误。

ClickHouse:能节省磁盘预算的数据类型
Advertisement 728x90

ClickHouse:博彩分析完整数据类型参考(我踩过的坑)

一个字节让我浪费了500 GB磁盘空间

刚开始使用ClickHouse时,我对所有字段都用String:user_id、event_time、投注金额。一个月后,一个20亿行的表重达4 TB。同事看了表结构说:“为什么把数字存成字符串?”结果,user_id用String比用UInt64多占8倍空间。我改了——表缩小到800 GB。

ClickHouse提供了几十种数据类型。选对类型不只是节省GB——还关乎查询速度(减少磁盘读取)和稳定性(用Decimal代替Float不会因舍入错误让你意外)。

下面是我从真实项目(博彩分析、欺诈检测、LTV)中学到的一切。最后——一个现成的博彩平台表结构。

Google AdInline article slot

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。

Google AdInline article slot

我踩过的坑: 我把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

Google AdInline article slot

何时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配合arrayMaparrayFilterarrayJoin很好用。

真实查询: 找出赔率下降超过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_datecreated_at物化——基于日期的分区无需额外计算
  • LowCardinality用于sport和device_type——节省80%空间
  • ALIAS用于potential_payout——不存储,查询时计算
  • 没有Nullable,除非0或空字符串不够用

下一步

选对类型是基础。在后续文章中,我们将介绍基于这些数据的聚合、窗口函数和物化视图。

本文中的表已在我们生产集群中运行一年,包含3万亿条记录。重12 TB(使用ZSTD压缩)。如果全部用String,将是40 TB。明智地选择类型。


上一篇:
下一篇: ClickHouse中的MergeTree:引擎如何将分析数据切分为颗粒并合并分区

— Editorial Team

Advertisement 728x90

继续阅读