签到表必须设置user_id与sign_date的唯一约束以防止重复签到,使用DATE类型而非DATETIME,通过INSERT...ON DUPLICATE KEY UPDATE实现幂等,结合事务更新用户表的last_sign_date和continuous_days字段,并统一MySQL时区为+08:00。

签到表设计必须包含用户ID、日期和唯一约束
签到功能的核心是防止重复签到,不是简单插入一条记录就完事。如果只用 user_id 和 sign_date 两个字段却不加约束,同一用户同一天可能插入多条记录——这在并发请求下尤其容易发生。
正确做法是建一张表,主键或唯一索引组合覆盖 user_id 和 sign_date:
CREATE TABLE user_sign ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, sign_date DATE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_date (user_id, sign_date) );
-
sign_date用DATE类型,不是DATETIME,避免因时间精度导致同日多次签到成功 - 不要依赖应用层判断“今天是否已签”,MySQL 的
INSERT ... ON DUPLICATE KEY UPDATE才是可靠防线 - 如果业务需要记录连续签到天数,不要实时计算,而是每次签到后更新
last_sign_date和continuous_days字段
用 INSERT ... ON DUPLICATE KEY UPDATE 实现幂等签到
这是最常用也最稳妥的写法,利用唯一索引冲突触发更新,而不是先查再插(查+插有竞态漏洞)。
示例:用户 123 今日签到,若已存在则不修改,若不存在则插入:
INSERT INTO user_sign (user_id, sign_date) VALUES (123, CURDATE()) ON DUPLICATE KEY UPDATE id = id;
-
ON DUPLICATE KEY UPDATE id = id是空更新,仅用于触发影响行数判断;执行后可用ROW_COUNT()返回 1(新插入)或 0(已存在) - 如果要记录首次签到时间,可改成
ON DUPLICATE KEY UPDATE first_sign_at = IFNULL(first_sign_at, NOW()) - 避免在
UPDATE子句里调用NOW()或CURDATE()多次——MySQL 可能多次求值,导致时间不一致
查询连续签到天数时别用子查询硬算
每次查连续天数都用变量或窗口函数倒推,性能差且难维护。实际项目中应把状态“固化”在用户表里。
推荐方案:在用户表加两个字段 last_sign_date 和 continuous_days,签到时原子更新:
UPDATE users SET continuous_days = IF(DATEDIFF(CURDATE(), last_sign_date) = 1, continuous_days + 1, 1), last_sign_date = CURDATE() WHERE user_id = 123;
- 必须用
DATEDIFF(CURDATE(), last_sign_date) = 1判断是否连续,不能用last_sign_date = DATE_SUB(CURDATE(), INTERVAL 1 DAY)——后者在跨月时可能失效 - 这个
UPDATE要和INSERT INTO user_sign放在同一事务里,否则状态不一致 - 如果用户某天漏签,
continuous_days会重置为 1,符合常规逻辑
注意时区和 CURDATE() 的行为差异
CURDATE() 返回的是 MySQL 服务器当前时区的日期,不是 UTC,也不是客户端时区。如果你的应用部署在多个时区,或者前端传的是带时区的时间戳,这里极易出错。
- 确认 MySQL 的
time_zone设置:执行SELECT @@time_zone;,生产环境建议统一设为+08:00(而非SYSTEM) - 不要用
NOW()截取日期:DATE(NOW())依赖当前时间,而CURDATE()更语义清晰且性能略优 - 如果客户端按北京时间发起请求,但 MySQL 时区是 UTC,
CURDATE()会比预期晚一天——这种问题上线后很难排查
签到逻辑看着简单,真正踩坑的点都在日期边界、并发控制和时区对齐上。表结构没约束、SQL 没用唯一键兜底、时区没对齐,任何一个都可能导致数据错乱,而且线上很难复现。


















