PHP项目签到与连续天数:从零搭建高并发积分系统的完整实战指南
📚 目录导读
- 签到功能的核心价值与业务场景
- 数据库表设计与连续天数算法解析
- PHP实现签到API的完整代码(含缓存优化)
- 连续天数统计的高效查询策略
- 常见问题与踩坑实录(FAQ)
- SEO与用户体验优化的最佳实践
签到功能的核心价值与业务场景
在电商、社区、游戏等Web项目中,每日签到+连续天数奖励机制是提升用户黏性的黄金手段,某知识付费平台测试发现,引入连续签到30天赠送VIP后,用户7日留存率提升42%,作为PHP开发者,你需要掌握的不只是写个INSERT语句,而是如何在高并发下保证数据一致性,同时计算用户“历史最长连续天数”和“当前连续天数”。

业务场景示例:
- 用户每天只能签到1次,点击后获得积分/虚拟币
- 连续签到第3、7、30天有额外奖励
- 中断后连续天数重置为1(从新签到日起算)
数据库表设计与连续天数算法解析
基础表结构(MySQL)
-- 用户签到记录表 CREATE TABLE `user_sign` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '用户ID', `sign_date` date NOT NULL COMMENT '签到日期(索引)', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `reward_points` int(11) DEFAULT 1 COMMENT '本次获得积分', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_date` (`user_id`,`sign_date`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用户签到统计表(预计算连续天数) CREATE TABLE `user_sign_stats` ( `user_id` int(11) NOT NULL PRIMARY KEY, `continuous_days` int(11) NOT NULL DEFAULT 0 COMMENT '当前连续天数', `max_continuous_days` int(11) NOT NULL DEFAULT 0 COMMENT '历史最高连续天数', `last_sign_date` date DEFAULT NULL COMMENT '上次签到日期', `total_days` int(11) NOT NULL DEFAULT 0 COMMENT '累计签到总天数' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计关键点:
- 使用唯一索引
uk_user_date防止重复签到(比程序逻辑判断更可靠) - 建立
user_sign_stats统计表,避免每次查询都扫描全表计算连续天数 last_sign_date用于判断是否中断
连续天数算法逻辑(伪代码)
IF 今天已签到 THEN 返回“已签到”
ELSE IF 昨日已签到(last_sign_date = 昨天) THEN
continuous_days += 1
ELSE
continuous_days = 1 // 中断了,重置
END IF
更新 last_sign_date =
IF continuous_days > max_continuous_days THEN 更新max
签到记录写入user_sign表
PHP实现签到API的完整代码(含Redis缓存优化)
场景:日均10万用户签到,需支持高并发
<?php
class SignService {
private $redis;
private $db;
public function sign($userId) {
$today = date('Y-m-d');
$redisKey = "sign:{$userId}:{$today}";
// 第一步:Redis缓存检测是否已签到(避免数据库查询)
if ($this->redis->exists($redisKey)) {
return ['code' => -1, 'msg' => '今日已签到'];
}
// 第二步:数据库事务 + 行锁
$this->db->beginTransaction();
try {
// 使用SELECT ... FOR UPDATE锁住用户统计行
$stats = $this->db->query(
"SELECT * FROM user_sign_stats WHERE user_id = ? FOR UPDATE",
[$userId]
);
if (empty($stats)) {
// 首次签到初始化
$continuousDays = 1;
$this->db->execute(
"INSERT INTO user_sign_stats SET user_id=?, continuous_days=1,
max_continuous_days=1, last_sign_date=?, total_days=1",
[$userId, $today]
);
} else {
$lastDate = $stats[0]['last_sign_date'];
$yesterday = date('Y-m-d', strtotime('-1 day'));
if ($lastDate == $today) {
// 理论上已被Redis拦截,但数据库层面再次防御
$this->db->rollback();
return ['code' => -1, 'msg' => '重复签到'];
}
// 核心连续天数计算
$continuousDays = ($lastDate == $yesterday)
? $stats[0]['continuous_days'] + 1
: 1;
$newMax = max($stats[0]['max_continuous_days'], $continuousDays);
$this->db->execute(
"UPDATE user_sign_stats SET continuous_days=?, max_continuous_days=?,
last_sign_date=?, total_days=total_days+1 WHERE user_id=?",
[$continuousDays, $newMax, $today, $userId]
);
}
// 写入签到记录
$reward = $this->calculateReward($continuousDays);
$this->db->execute(
"INSERT INTO user_sign (user_id, sign_date, reward_points) VALUES (?,?,?)",
[$userId, $today, $reward]
);
$this->db->commit();
// 第三步:写入Redis缓存(TTL=86400秒)
$this->redis->setEx($redisKey, 86400, 1);
// 清理其他缓存...
return ['code' => 0, 'msg' => '签到成功', 'data' => [
'continuous_days' => $continuousDays,
'reward' => $reward
]];
} catch (Exception $e) {
$this->db->rollback();
return ['code' => -2, 'msg' => '系统繁忙'];
}
}
private function calculateReward($days) {
if ($days >= 30) return 100;
if ($days >= 7) return 30;
if ($days >= 3) return 10;
return 1;
}
}
高并发优化要点:
- 先查Redis再查数据库,避免大量请求穿透到MySQL
- 事务中使用
FOR UPDATE行锁,保证同一用户并发不冲突(注意死锁风险) - 积分奖励计算放在数据库事务外,降低事务执行时间
连续天数统计的高效查询策略
获取用户当前连续天数(直接从统计表查)
SELECT continuous_days, max_continuous_days, total_days FROM user_sign_stats WHERE user_id = 123;
获取连续签到排行榜(前100名)
SELECT u.nickname, s.continuous_days FROM user_sign_stats s JOIN users u ON s.user_id = u.id ORDER BY s.continuous_days DESC LIMIT 100;
单个用户历史最长连续天数(数据库直接记录max字段,无需计算)
注意:如果用户量超过100万,请对user_sign_stats表按user_id做分区,或使用Redis Sorted Set维护排行榜。
常见问题与踩坑实录(FAQ)
Q1:用户签到后立即取消订单/封号,怎么处理?
A:签到成功后积分已发放,通常不进行“扣回”操作,若必须回滚,建议在user_sign表增加status字段,封号时标记为-1,并手动调整user_sign_stats的total_days等字段。
Q2:用户跨时区怎么办?
A:统一使用服务器UTC时间,前端由JS转换为用户当地时间,数据库存储sign_date使用date类型(不含时区),后端判断“按服务器时区计算(建议项目统一Asia/Shanghai)。
Q3:Redis挂了会不会导致重复签到?
A:会,但数据库层有唯一索引和FOR UPDATE兜底,最多返回“重复签到”错误,不会产生脏数据,建议Redis作为缓存,核心数据以MySQL为准。
Q4:连续天数统计在跨年/闰年时会不会出错?
A:只要使用date('Y-m-d')和strtotime('-1 day'),PHP会自动处理闰年、跨月、跨年场景,无需额外判断。
Q5:如何防止用户通过修改客户端时间签到?
A:所有签到逻辑必须在服务端完成,前端只能发请求,不能提交sign_date参数,服务端以数据库时间为准。
SEO与用户体验优化的最佳实践
前端展示优化
- 在页面显著位置显示“已连续签到X天”,使用CSS动画+金色图标吸引点击
- 使用
localStorage记录上次签到时间,用户无需刷新即可看到今日状态 - 对未签到用户显示“签到得积分”按钮,已签到显示灰色“已打卡”
后端SEO友好
- 签到API使用
POST方法,配合CSRF Token防止盗链 - 页面URL结构:
/user/sign使用语义化短链 - 签到成功后的跳转页面包含
rel="canonical"标签,避免百度收录重复页面
用户体验细节
- 每次签到成功后弹窗显示获得的积分和连续天数,附带进度条
- 在“我的积分”页面用图表展示连续天数趋势(使用Chart.js)
- 增加补签卡功能(允许用户用积分补签昨日),进一步提升留存率
延伸阅读:若你的项目需要支持千万级用户签到,请参考以下架构调整:
- 使用RabbitMQ异步写入签到记录
- 用ClickHouse替代MySQL的
user_sign明细表 - 连续天数改用ElasticSearch或Redis Bitmap存储
希望这篇指南能帮你构建一个稳定、高性能的PHP签到系统,如果你在实现过程中遇到问题,欢迎在评论区交流。