用bitmap而非datetime字段存签到,因高频查询如连续签到天数、月签到率、今日是否签到时,bitmap将365天压缩至46字节,单次读写仅操作一个字段,位运算即可判断;但需固定日期范围、用户id映射为整型,且mysql原生bit类型存在隐式转换等问题,故推荐用bigint unsigned分6组存储,每组64天,以2024-01-01为基准日计算偏移,通过&和1实现位提取。

为什么用 BITMAP 而不是 datetime 字段存签到
因为高频查询「某用户连续签到天数」「某月签到率」「是否今日已签」时,datetime 字段要扫全量记录或建复合索引,IO 和 CPU 成本高;而 BITMAP 把 365 天压缩成 46 字节(BIT(365)),单次读写只操作一个字段,查今天是否签到就是一次位运算。
但 BITMAP 不是万能的:它要求签到日期必须相对固定(比如只统计最近一年),且用户 ID 需映射为整型主键——否则无法对齐位偏移。MySQL 原生不支持动态长度 BIT 类型,所以实际用 BIGINT 数组或 VARCHAR 存二进制字符串更可控。
- 别直接用
BIT(365):MySQL 的BIT类型在 ORM、备份、复制中容易出隐式转换问题,SELECT返回的是二进制流,PHP/Python 拿到的是乱码字节 - 推荐用
BIGINT UNSIGNED分段存储:每 64 天一组,共 6 个字段(bit0~bit5),位运算清晰、兼容性好 - 起始日期必须固化:比如统一以 2024-01-01 为 offset 0,所有计算基于此偏移,避免时区或闰年错位
如何用 MySQL 函数做「今天是否已签到」判断
核心是把「当前日期」转成相对于基准日的天数偏移,再定位到对应 BIGINT 字段和位序,用 BIN() 或位运算提取。MySQL 没有直接的 GET_BIT(),得靠 & 和 1 。
假设用户表 user_sign 有字段 uid、bit0~bit5(每个是 BIGINT UNSIGNED),基准日为 '2024-01-01':
SELECT
uid,
CASE
WHEN FLOOR(DATEDIFF(CURDATE(), '2024-01-01') / 64) = 0
THEN (bit0 & (1
-
DATEDIFF(CURDATE(), '2024-01-01')必须和写入时用的基准日完全一致,否则位偏移错一位,整个逻辑就全错 -
MOD(..., 64)算的是字段内第几位,FLOOR(... / 64)算的是用哪个字段(bit0还是bit1) - 注意
1 在 MySQL 中最大支持 <code>n=63,超了会溢出为 0,所以每段严格控在 64 位内
签到更新时怎么避免并发覆盖
用户同时在两个设备点签到,两条 UPDATE 可能读到同一个旧值,各自算完再写回去,导致只生效一次。不能依赖应用层锁,得靠 MySQL 原子操作。
正确做法是用 UPDATE ... SET bitN = bitN | (1 ,利用位或的幂等性:
UPDATE user_sign SET bit0 = bit0 | (1
- WHERE 条件里二次校验位未设置,确保只更新「首次签到」,避免重复累加积分等副作用
- 不要用
SELECT + UPDATE两步:即使加FOR UPDATE,在高并发下仍可能因间隙锁范围过大拖慢整体性能 - 如果需要返回「本次是否新签到」,用
ROW_COUNT()判断影响行数即可,不用额外查
历史数据迁移和过期清理怎么做
上线 BITMAP 后,老的 sign_log 表不能扔,得把过去一年数据聚合进新字段。难点不在转换,而在「怎么不影响线上写入」。
- 迁移脚本必须按
uid分批处理,每次最多 1000 用户,避免长事务锁表;用SELECT ... INTO OUTFILE导出原始数据比直接 JOIN 更稳 - 过期策略不是删字段,而是「移动基准日」:比如半年后把基准日从
'2024-01-01'改成'2024-07-01',旧数据自然被移出位图范围,新签到只写后半段 - 千万别用
ALTER TABLE ... DROP COLUMN动态删bitN字段——DDL 会锁全表,且字段顺序变化会让所有位偏移计算失效
位图真正的复杂点不在实现,而在于「所有业务方必须共享同一套偏移规则」。哪怕一个定时任务用错基准日,或者前端传错时间戳格式,都会让某天的位变成随机值,而且这种错误很难被日志捕获。











