文章目录
- 补签逻辑实现
-
- 配置service服务下的impl实现
- [mock 一下数据库记录,便于演示补签功能](#mock 一下数据库记录,便于演示补签功能)
- 启动前后端进行测试
- 签到提醒
-
- 每日签到提醒
- 运营策略提醒
- [Goframe 版本](#Goframe 版本)
-
- [使用 goframe 框架的 gcron 实现定时任务。](#使用 goframe 框架的 gcron 实现定时任务。)
- [Gin 版本](#Gin 版本)
-
- [使用 cron 定时任务(github.com/robfig/cron/v3)](#使用 cron 定时任务(github.com/robfig/cron/v3))
- [在impl下添加lua脚本(Lua:在 Redis 内部做"计算/判断/原子操作"(快、稳、少网络往返))](#在impl下添加lua脚本(Lua:在 Redis 内部做“计算/判断/原子操作”(快、稳、少网络往返)))
- 添加reminder.go(Go:负责"什么时候跑、对谁跑、跑完做什么、失败怎么处理"(业务编排))
- 在cmd中添加配置
- 启动前后端进行测试
- 简历辅导
- 把签到系统项目写到简历上的示例
- 面试可能会问的问题
-
- [0、MySQL 事务是一定会被问到的点](#0、MySQL 事务是一定会被问到的点)
- 1、如何保证MySQL和Redis的数据一致性
- 2、如果是海量用户该如何分表?
- 3、如何拓展项目(也可以直接写到你的项目描述里)
- 参考资料
补签逻辑实现
配置service服务下的impl实现
go
package impl
import (
"backend/internal/dao"
"backend/internal/model"
"backend/internal/model/entity"
"backend/utility/injection"
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/redis/go-redis/v9"
)
// 签到相关业务逻辑的具体实现
const (
yearSignKeyFormat = "user:checkins:daily:%d:%d" // user:checkins:daily:12131321421312:2025
monthRetroKeyFormat = "user:checkins:retro:%d:%d%02d" // user:checkins:retro:12131321421231202501
defaultDailyPoints int64 = 1 // 每日签到积分(注意:用 int64,和积分字段统一)
defaultRetroCostPoints = 100 // 补签消耗积分
maxRetroTimesPerMonth = 3 // 单月最多补签次数
)
type PointsTransactionType int
const (
PointsTransactionTypeDaily PointsTransactionType = iota + 1 // 每日签到 1
PointsTransactionTypeConsecutive // 连续签到 2
PointsTransactionTypeRetro // 补签 3
)
type ConsecutiveBonusType int32
const (
// 连续签到奖励规则
consecutiveBonus3 ConsecutiveBonusType = 1 // "连续签到3天奖励"
consecutiveBonus7 ConsecutiveBonusType = 2 // "连续签到7天奖励"
consecutiveBonus15 ConsecutiveBonusType = 3 // "连续签到15天奖励"
consecutiveBonus30 ConsecutiveBonusType = 4 // "月度满签奖励"
)
var consecutiveBonusNames = map[ConsecutiveBonusType]string{
consecutiveBonus3: "连续签到3天奖励",
consecutiveBonus7: "连续签到7天奖励",
consecutiveBonus15: "连续签到15天奖励",
consecutiveBonus30: "月度满签奖励",
}
var PointsTransactionTypeMsgMap = map[PointsTransactionType]string{
PointsTransactionTypeDaily: "每日签到奖励",
PointsTransactionTypeConsecutive: "连续签到奖励",
PointsTransactionTypeRetro: "补签%s消耗积分",
}
// consecutiveBonusRule 连续签到奖励规则
type ConsecutiveBonusRule struct {
TriggerDays int // 触发连续签到奖励的天数
Points int64 // 连续签到奖励的积分
BonusType ConsecutiveBonusType // 连续签到奖励类型
}
var consecutiveBonusRules = []ConsecutiveBonusRule{
{TriggerDays: 3, Points: 5, BonusType: consecutiveBonus3},
{TriggerDays: 7, Points: 10, BonusType: consecutiveBonus7},
{TriggerDays: 15, Points: 20, BonusType: consecutiveBonus15},
{TriggerDays: 30, Points: 100, BonusType: consecutiveBonus30},
}
var (
ErrInvalidRetroDate = errors.New("补签日期无效")
ErrChecked = errors.New("日期已签到")
ErrRetroNotimes = errors.New("本月补签次数已用完")
ErrNoEnoughPoints = gerror.New("积分不足")
)
type Service struct {
rc *redis.Client
}
func NewService() *Service {
return &Service{
rc: injection.MustInvoke[*redis.Client](), // 从注入器中获取 Redis 客户端实例
}
}
// // Daily 每日签到
// func (s *Service) Daily(ctx context.Context, userId uint64) error {
// // 采用服务器时间进行每日签到,不依赖客户端传递的时间
// // 1. Redis 中使用 bitmap setbit 执行签到逻辑
// // 拿到当天是一年中的第几天,然后使用 setbit 记录这一天是否签到
// now := time.Now()
// year := now.Year()
// dayOfYearOffset := now.YearDay() - 1 // 因为 Redis bitmap 从 0 开始,所以要减一
// key := fmt.Sprintf(yearSignKeyFormat, userId, year)
// g.Log().Debugf(ctx, "key: %s dayOfYearOffset:%d", key, dayOfYearOffset)
// ret := s.rc.SetBit(ctx, key, int64(dayOfYearOffset), 1).Val()
// if ret == 1 {
// return errors.New("今日已签到")
// }
// // 2. 发放每日签到的积分
// // 用户积分汇总表 user_points 增加积分
// // 2.1 先查询(新用户可能没有记录)
// var userPoint entity.UserPoints
// if err := dao.UserPoints.Ctx(ctx).
// Where(dao.UserPoints.Columns().UserId, userId).
// Scan(&userPoint); err != nil && !errors.Is(err, sql.ErrNoRows) {
// g.Log().Errorf(ctx, "查询用户积分汇总表失败: %v", err)
// return err
// }
// // 如果查不到,则插入一条记录
// if userPoint.Id == 0 {
// userPoint = entity.UserPoints{UserId: userId} // 创建新对象
// }
// userPoint.Points = userPoint.Points + defaultDailyPoints // 增加每日签到积分
// userPoint.PointsTotal = userPoint.PointsTotal + defaultDailyPoints // 累计积分
// // 2.2 事务更新 用户积分汇总表 和 用户积分明细表
// // 为什么要事务?
// // 因为要保证:流水插入成功, 汇总更新成功
// // 这两件事要么都成功,要么都失败,不然会出现"余额变了但没流水"或"有流水但余额没变"。
// err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// // 用户积分明细表 user_points_transactions 增加记录
// newRecord := entity.UserPointsTransactions{
// UserId: userId,
// PointsChange: defaultDailyPoints,
// CurrentBalance: userPoint.Points,
// TransactionType: int(PointsTransactionTypeDaily),
// Description: PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
// CreatedAt: gtime.NewFromTime(time.Now()),
// UpdatedAt: gtime.NewFromTime(time.Now()),
// }
// // return nil => 提交 commit
// // return err => 回滚 rollback
// // tx.Model(...):明确使用事务 tx 执行 SQL
// if _, err := tx.Model(&entity.UserPointsTransactions{}).Insert(&newRecord); err != nil {
// g.Log().Errorf(ctx, "插入用户积分明细表失败: %v", err)
// return err
// }
// if _, err := tx.Model(&entity.UserPoints{}).
// Where(dao.UserPoints.Columns().UserId, userId).
// Save(&userPoint); err != nil {
// g.Log().Errorf(ctx, "更新用户积分汇总表失败: %v", err)
// return err
// }
// return nil
// })
// if err != nil {
// g.Log().Errorf(ctx, "事务处理失败: %v", err)
// return err
// }
// // 3. 发送连续签到的奖励积分
// return nil
// }
// Daily 每日签到(先 DB 成功,再 setbit)
// ✅ 先 DB 加分成功
// ✅ 再 Redis 标记已签到
// 这样就不会出现"签了但没加分"
// 关键点:
// 1) 先 GetBit 判断是否已签到(只读)
// 2) 事务里:写明细 + 更新/插入汇总(先 DB)
// 3) DB 成功后:SetBit 标记已签到
// 4) 用 Redis SetNX 做锁,防止并发重复加分
func (s *Service) Daily(ctx context.Context, userId uint64) error {
// 采用服务器时间进行每日签到,不依赖客户端传递的时间
// 1. Redis 中使用 bitmap setbit 执行签到逻辑
// 拿到当天是一年中的第几天,然后使用 setbit 记录这一天是否签到
now := time.Now()
year := now.Year()
dayOfYearOffset := now.YearDay() - 1 // 因为 Redis bitmap 从 0 开始,所以要减1
signKey := fmt.Sprintf(yearSignKeyFormat, userId, year)
// 0) 防并发:同一用户同一天只允许一个请求进来(10s 超时防死锁)
lockKey := fmt.Sprintf("lock:checkins:daily:%d:%d:%d", userId, year, dayOfYearOffset)
locked, err := s.rc.SetNX(ctx, lockKey, 1, 10*time.Second).Result()
if err != nil {
return err
}
if !locked {
return errors.New("签到处理中,请稍后重试")
}
defer s.rc.Del(ctx, lockKey)
// 1) 只读判断:是否已签到
bit, err := s.rc.GetBit(ctx, signKey, int64(dayOfYearOffset)).Result()
if err != nil {
return err
}
if bit == 1 {
return errors.New("今日已签到")
}
// 2) 先 DB:事务里写明细 + 更新/插入汇总
// err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// // 2.1 查询积分汇总(新用户可能没有记录)
// var userPoint entity.UserPoints
// if err := tx.Model(&entity.UserPoints{}).
// Where(dao.UserPoints.Columns().UserId, userId).
// Scan(&userPoint); err != nil && !errors.Is(err, sql.ErrNoRows) {
// g.Log().Errorf(ctx, "查询用户积分汇总表失败: %v", err)
// return err
// }
// isNew := userPoint.Id == 0
// if isNew {
// userPoint = entity.UserPoints{
// UserId: userId,
// }
// }
// // 2.2 计算加分后的值
// userPoint.Points += defaultDailyPoints
// userPoint.PointsTotal += defaultDailyPoints
// // 2.3 插入积分明细
// newRecord := entity.UserPointsTransactions{
// UserId: userId,
// PointsChange: defaultDailyPoints,
// CurrentBalance: userPoint.Points,
// TransactionType: int(PointsTransactionTypeDaily), // ✅ entity 字段是 int,显式转换
// Description: PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
// CreatedAt: gtime.NewFromTime(now),
// UpdatedAt: gtime.NewFromTime(now),
// }
// if _, err := tx.Model(&entity.UserPointsTransactions{}).Insert(&newRecord); err != nil {
// g.Log().Errorf(ctx, "插入用户积分明细表失败: %v", err)
// return err
// }
// // 2.4 更新/插入积分汇总
// if isNew {
// userPoint.CreatedAt = gtime.NewFromTime(now)
// userPoint.UpdatedAt = gtime.NewFromTime(now)
// if _, err := tx.Model(&entity.UserPoints{}).Insert(&userPoint); err != nil {
// g.Log().Errorf(ctx, "插入用户积分汇总表失败: %v", err)
// return err
// }
// } else {
// if _, err := tx.Model(&entity.UserPoints{}).
// Where(dao.UserPoints.Columns().UserId, userId).
// Data(g.Map{
// dao.UserPoints.Columns().Points: userPoint.Points,
// dao.UserPoints.Columns().PointsTotal: userPoint.PointsTotal,
// dao.UserPoints.Columns().UpdatedAt: gtime.NewFromTime(now),
// }).
// Update(); err != nil {
// g.Log().Errorf(ctx, "更新用户积分汇总表失败: %v", err)
// return err
// }
// }
// return nil
// })
// 2. 发放每日签到的积分
// 先写 DB 加积分
if err := s.AddPoints(ctx, &model.PointsTransactionInput{
UserId: userId,
Points: defaultDailyPoints,
Desc: PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
Type: int(PointsTransactionTypeDaily),
}); err != nil {
g.Log().Errorf(ctx, "AddPoints 事务处理失败: %v", err)
return err
}
// 3) 再 Redis:DB 成功后再 setbit(避免"签到了但没加分")
old, err := s.rc.SetBit(ctx, signKey, int64(dayOfYearOffset), 1).Result()
if err != nil {
return err
}
// 理论上有锁不会发生,兜底
if old == 1 {
return errors.New("今日已签到")
}
// 3. 发送连续签到的奖励积分
return s.updateConsecutiveBonus(ctx, userId, year, int(now.Month()))
}
// updateConsecutiveBonus 更新连续签到奖励积分
func (s *Service) updateConsecutiveBonus(ctx context.Context, userId uint64, year, month int) error {
// 1. 获取当前本月连续签到天数
maxConsecutive, err := s.CalcMonthConsecutiveDays(ctx, userId, year, month)
if err != nil {
g.Log().Errorf(ctx, "计算连续签到天数失败: %v", err)
return err
}
// 2. 计算连续签到奖励积分
// 3. 更新用户积分汇总表和用户积分明细表
// 如何避免重复发放连续签到奖励? --> 使用 user_monthly_bonus_log 表记录用户指定月份已领取的奖励
// 2.1 查询用户本月已领取的奖励(避免重复发放)
var bonusLogs []*entity.UserMonthlyBonusLog
if err := dao.UserMonthlyBonusLog.Ctx(ctx).
Where(dao.UserMonthlyBonusLog.Columns().UserId, userId).
Where(dao.UserMonthlyBonusLog.Columns().YearMonth, fmt.Sprintf("%d%02d", year, month)). // 202505
Scan(&bonusLogs); err != nil && !errors.Is(err, sql.ErrNoRows) {
g.Log().Errorf(ctx, "查询用户已领取的奖励失败: %v", err)
return err
}
// 用 BonusType 做去重 key(比用 Description 更稳)
// 把领取的奖励塞到map中,方便后续判断是否已经发放过奖励
bonusLogsMap := make(map[ConsecutiveBonusType]bool)
for _, v := range bonusLogs {
bonusLogsMap[ConsecutiveBonusType(v.BonusType)] = true
}
// 遍历连续签到奖励配置,如果符合条件就发奖励
for _, rule := range consecutiveBonusRules {
if maxConsecutive >= rule.TriggerDays && !bonusLogsMap[rule.BonusType] {
// 发放连续签到奖励积分
// 更新 user_points 表和 user_points_transactions 表
if err := s.AddPoints(ctx, &model.PointsTransactionInput{
UserId: userId,
Points: rule.Points,
Desc: consecutiveBonusNames[rule.BonusType],
Type: int(PointsTransactionTypeConsecutive),
}); err != nil {
g.Log().Errorf(ctx, "发放连续签到奖励失败: %v", err)
continue
}
// 记录到 user_monthly_bonus_log 表(用于幂等)
now := time.Now()
newLog := &entity.UserMonthlyBonusLog{
UserId: userId,
YearMonth: fmt.Sprintf("%d%02d", year, month),
Description: consecutiveBonusNames[rule.BonusType],
BonusType: int(rule.BonusType),
CreatedAt: gtime.NewFromTime(now),
UpdatedAt: gtime.NewFromTime(now),
}
if _, err := dao.UserMonthlyBonusLog.Ctx(ctx).Insert(newLog); err != nil {
// 积分已加,连续签到奖励已发,但月度奖励记录插入失败,需要手动处理
g.Log().Errorf(ctx, "[NEED_HANDLE]插入用户月度奖励记录失败: %v", err)
continue
}
// 更新本地 map,避免本次循环中重复发
bonusLogsMap[rule.BonusType] = true
}
}
return nil
}
// CalcMonthConsecutiveDays 计算本月最大连续签到天数(含补签)
func (s *Service) CalcMonthConsecutiveDays(ctx context.Context, userId uint64, year, month int) (int, error) {
monthDays := getMonthDays(year, month)
checkinBitmap, retroBitmap, err := s.getMonthBitmap(ctx, userId, year, month)
if err != nil {
g.Log().Errorf(ctx, "获取用户签到记录失败: %v", err)
return 0, err
}
// 逻辑或
bitmap := checkinBitmap | retroBitmap // 合并本月签到和补签数据
return calcMaxConsecutiveDays(bitmap, monthDays), nil
}
// calcMaxConsecutiveDays 计算最大连续签到天数
func calcMaxConsecutiveDays(bitmap uint64, monthDays int) int {
// 逐位判断,计算出连续签到天数
maxCount := 0
currCount := 0
for i := 0; i < monthDays; i++ {
// 从右向左逐位判断
checked := (bitmap>>i)&1 == 1
if checked {
currCount++
} else {
if currCount > maxCount {
maxCount = currCount
}
currCount = 0
}
}
// 循环结束再最后比较一次
if currCount > maxCount {
maxCount = currCount
}
return maxCount
}
// getFirstOfMonthOffset 获取当月第一天在一年中的偏移量
// 假设 2025-12-01:
// time.Date(2025, 12, 1, ...) 得到 12 月 1 日
// firstOfMonth.YearDay() 得到它是一年中的第几天(2025 不是闰年,12/1 是第 335 天)
// 那么 offset = 335 - 1 = 334
// 注意:这里使用 time.Local,保证和 Daily() 的 time.Now() 同一时区口径
func getFirstOfMonthOffset(year, month int) int {
// 1. 获取当月第一天
firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local)
// 2. 计算偏移量
return firstOfMonth.YearDay() - 1 // offset 从 0 开始
}
// getMonhDays 获取当月天数
// 注意:使用 time.Local,避免时区差异导致月界限异常
func getMonthDays(year, month int) int {
// 1. 获取当月第一天
firstOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local)
// 2. 获取当月最后一天
lastOfMonth := firstOfMonth.AddDate(0, 1, -1)
return lastOfMonth.Day() // 返回当月天数
}
// AddPoints 封装"积分变更"的事务逻辑:写明细 + 更新/插入汇总
// input.Points 可正可负:正数=加分,负数=扣分(如需禁止扣分可在下面校验)
func (s *Service) AddPoints(ctx context.Context, input *model.PointsTransactionInput) error {
if input == nil {
return errors.New("input is nil")
}
if input.UserId == 0 {
return errors.New("invalid userId")
}
if input.Points == 0 {
return nil // 没有变更直接返回
}
now := time.Now()
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 1) 查询积分汇总(新用户可能没有记录)
var userPoint entity.UserPoints
if err := tx.Model(&entity.UserPoints{}).
Where(dao.UserPoints.Columns().UserId, input.UserId).
Scan(&userPoint); err != nil && !errors.Is(err, sql.ErrNoRows) {
g.Log().Errorf(ctx, "查询用户积分汇总表失败: %v", err)
return err
}
isNew := userPoint.Id == 0
if isNew {
userPoint = entity.UserPoints{
UserId: input.UserId,
}
}
// 2) 计算新余额(可选:不允许余额为负)
newBalance := userPoint.Points + input.Points
if newBalance < 0 {
return errors.New("积分不足")
}
// 3) 更新汇总
userPoint.Points = newBalance
// PointsTotal 通常表示"累计获得",一般只在加分时累加
if input.Points > 0 {
userPoint.PointsTotal += input.Points
}
// 4) 插入积分明细
newRecord := entity.UserPointsTransactions{
UserId: input.UserId,
PointsChange: input.Points,
CurrentBalance: newBalance,
TransactionType: input.Type,
Description: input.Desc,
CreatedAt: gtime.NewFromTime(now),
UpdatedAt: gtime.NewFromTime(now),
}
if _, err := tx.Model(&entity.UserPointsTransactions{}).Insert(&newRecord); err != nil {
g.Log().Errorf(ctx, "插入用户积分明细表失败: %v", err)
return err
}
// 5) 更新/插入积分汇总
if isNew {
userPoint.CreatedAt = gtime.NewFromTime(now)
userPoint.UpdatedAt = gtime.NewFromTime(now)
if _, err := tx.Model(&entity.UserPoints{}).Insert(&userPoint); err != nil {
g.Log().Errorf(ctx, "插入用户积分汇总表失败: %v", err)
return err
}
} else {
if _, err := tx.Model(&entity.UserPoints{}).
Where(dao.UserPoints.Columns().UserId, input.UserId).
Data(g.Map{
dao.UserPoints.Columns().Points: userPoint.Points,
dao.UserPoints.Columns().PointsTotal: userPoint.PointsTotal,
dao.UserPoints.Columns().UpdatedAt: gtime.NewFromTime(now),
}).
Update(); err != nil {
g.Log().Errorf(ctx, "更新用户积分汇总表失败: %v", err)
return err
}
}
return nil
})
}
// MonthDetail 签到详情
func (s *Service) MonthDetail(ctx context.Context, input *model.MonthDetailInput) (*model.MonthDetailOutput, error) {
// 1. 从redis中分别取出签到bitmap和补签bitmap,分别得到签到日期和补签日期
checkinBitmap, retroBitmap, err := s.getMonthBitmap(ctx, input.UserId, input.Year, input.Month)
if err != nil {
g.Log().Errorf(ctx, "获取年月bitmap失败: %v", err)
return nil, err
}
g.Log().Debugf(ctx, "--> checkinBitmap: %031b retroBitmap:%031b", checkinBitmap, retroBitmap)
monthDays := getMonthDays(input.Year, input.Month) // 当月天数
checkinDays := parseBitmap2Days(checkinBitmap, monthDays)
retroDays := parseBitmap2Days(retroBitmap, monthDays)
// 2. 计算连续签到天数
bitmap := checkinBitmap | retroBitmap
maxConsecutive := calcMaxConsecutiveDays(bitmap, monthDays)
// 3. 计算剩余补签次数
remainRetroTimes := maxRetroTimesPerMonth - len(retroDays) // 用月度补签次数减去已补签天数
// 4. 计算当天是否签到
isCheckedToday, err := s.IsCheckedToday(ctx, input.UserId)
if err != nil {
g.Log().Errorf(ctx, "查询当天是否签到失败: %v", err)
return nil, err
}
return &model.MonthDetailOutput{
CheckedInDays: checkinDays,
RetroCheckedInDays: retroDays,
ConsecutiveDays: maxConsecutive,
RemainRetroTimes: remainRetroTimes,
IsCheckedInToday: isCheckedToday,
}, nil
}
func (s *Service) IsCheckedToday(ctx context.Context, userId uint64) (bool, error) {
// 计算今天的年度索引,然后使用 getbit 判断这一天是否签到
now := time.Now()
year := now.Year()
key := fmt.Sprintf(yearSignKeyFormat, userId, year)
// 算出"今天是今年第几天"对应的 bit 下标
dayOffset := now.YearDay() - 1
value, err := s.rc.GetBit(ctx, key, int64(dayOffset)).Result()
if err != nil {
g.Log().Errorf(ctx, "GetBit 获取当天签到状态失败: %v", err)
return false, err
}
return value == 1, nil
}
// parseBitmap2Days 根据当月的天数和bitmap, 输出对应的签到/补签日期
func parseBitmap2Days(bitmap uint64, monthDays int) []int {
days := make([]int, 0)
for i := 0; i < monthDays; i++ {
// 0000000000000000000000000000110
if (bitmap & (1 << (monthDays - 1 - i))) != 0 {
days = append(days, i+1)
}
}
return days
}
// getMonthBitmap 获取当月 签到bitmap 和 补签的bitmap
func (s *Service) getMonthBitmap(ctx context.Context, userId uint64, year, month int) (uint64, uint64, error) {
// 从用户年度签到记录中取出当月签到 bitmap
key := fmt.Sprintf(yearSignKeyFormat, userId, year)
firstOfMonthOffset := getFirstOfMonthOffset(year, month)
monthDays := getMonthDays(year, month)
bitWidthType := fmt.Sprintf("u%d", monthDays) // u30/u31
values, err := s.rc.BitField(ctx, key, "GET", bitWidthType, firstOfMonthOffset).Result()
if err != nil {
g.Log().Errorf(ctx, "获取用户签到记录到失败: %v", err)
return 0, 0, err
}
if len(values) == 0 {
values = []int64{0} // 如果没有查询到,则默认为0
}
checkinBitmap := uint64(values[0])
g.Log().Debugf(ctx, "checkinBitmap: %0b", checkinBitmap)
// 获取当月补签 bitmap
retroKey := fmt.Sprintf(monthRetroKeyFormat, userId, year, month)
// 去 Redis 里 retroKey 这个补签位图,从第 0 位开始,读出 monthDays(比如 30/31)个 bit,打包成一个整数返回
retroValues, err := s.rc.BitField(ctx, retroKey, "GET", bitWidthType, "#0").Result()
if err != nil {
g.Log().Errorf(ctx, "获取用户补签记录失败: %v", err)
return 0, 0, err
}
if len(retroValues) == 0 {
retroValues = []int64{0} // 没有查询到,则默认为0
}
retroBitmap := uint64(retroValues[0])
return checkinBitmap, retroBitmap, nil
}
// Retro 根据输入的日期进行补签
func (s *Service) Retro(ctx context.Context, userId uint64, date time.Time) error {
// 1. 判断补签日期是否有效
if err := s.checkRetroDate(ctx, userId, date); err != nil {
return err
}
// 2. 执行补签逻辑
// 2.1 Redis里 补签的月度bitmap 中设置补签的标识
retroKey := fmt.Sprintf(monthRetroKeyFormat, userId, date.Year(), date.Month())
retroOffset := date.Day() - 1 // 索引是从0开始的,所以要减1
// 因为 IntCmd 嵌入了 baseCmd,所以 baseCmd 的方法会被"提升",表现得像是 IntCmd 自己的方法
// 写 cmd.Err()等价于 cmd.baseCmd.Err()
err := s.rc.SetBit(ctx, retroKey, int64(retroOffset), 1).Err()
if err != nil {
g.Log().Errorf(ctx, "SetBit 设置补签状态失败: %v", err)
return gerror.NewCode(gcode.CodeInternalError)
}
// 2.2 补签消耗积分、增加积分、增加积分记录
// 正常应该把签到服务和积分服务分开,通过消息队列的方式实现事件驱动。
// 签到服务负责签到/补签,发出消息;积分服务监听消息,处理积分的增加和扣减逻辑。
if err := s.retroWithTransaction(ctx, userId, date); err != nil {
// 如果数据库更新失败,则回滚 Redis 中的补签标识
err := s.rc.SetBit(ctx, retroKey, int64(retroOffset), 0).Err()
if err != nil {
g.Log().Errorf(ctx, "SetBit 回滚补签状态失败: %v", err)
return gerror.NewCode(gcode.CodeInternalError)
}
}
// 3. 计算连续签到日期发放连续签到奖励
return s.updateConsecutiveBonus(ctx, userId, date.Year(), int(date.Month()))
}
// retroWithTransaction 补签逻辑,使用事务保证原子性
func (s *Service) retroWithTransaction(ctx context.Context, userId uint64, date time.Time) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 1. 查询用户的当前积分,积分不够的不能补签
var userPoint entity.UserPoints
if err := tx.Model(dao.UserPoints.Table()).
Where(dao.UserPoints.Columns().UserId, userId).
Scan(&userPoint); err != nil {
if !errors.Is(err, sql.ErrNoRows) {
g.Log().Errorf(ctx, "查询用户积分失败: %v", err)
return err
}
// 如果是 ErrNoRows(没记录),就手动给一个默认值
userPoint = entity.UserPoints{
UserId: userId,
}
}
if userPoint.Points < defaultRetroCostPoints {
return ErrNoEnoughPoints
}
// 2. 计算积分变化(补签消耗的、每日签到奖励)
pointsChange := -defaultRetroCostPoints + defaultDailyPoints
nowPoints := userPoint.Points + int64(pointsChange)
nowTotalPoints := userPoint.PointsTotal + defaultDailyPoints // 只算得到的积分,不算消费的
// 3. 积分记录中新增一条补签消耗100积分的记录
retroCostRecord := entity.UserPointsTransactions{
UserId: userId,
PointsChange: -defaultRetroCostPoints,
TransactionType: int(PointsTransactionTypeRetro),
// Description: PointsTransactionTypeMsgMap[PointsTransactionTypeRetro],
Description: fmt.Sprintf(PointsTransactionTypeMsgMap[PointsTransactionTypeRetro], date.Format(time.DateOnly)),
CurrentBalance: userPoint.Points - defaultRetroCostPoints,
CreatedAt: gtime.NewFromTime(time.Now()),
UpdatedAt: gtime.NewFromTime(time.Now()),
}
if _, err := tx.Model(dao.UserPointsTransactions.Table()).Insert(&retroCostRecord); err != nil {
g.Log().Errorf(ctx, "插入补签消耗的积分记录失败: %v", err)
return err
}
// 4. 积分记录中新增每日签到固定得的奖励积分记录
checkinBonusRecord := entity.UserPointsTransactions{
UserId: userId,
PointsChange: defaultDailyPoints,
TransactionType: int(PointsTransactionTypeDaily),
Description: PointsTransactionTypeMsgMap[PointsTransactionTypeDaily],
CurrentBalance: nowPoints,
CreatedAt: gtime.NewFromTime(time.Now()),
UpdatedAt: gtime.NewFromTime(time.Now()),
}
if _, err := tx.Model(dao.UserPointsTransactions.Table()).Insert(&checkinBonusRecord); err != nil {
g.Log().Errorf(ctx, "插入补签奖励积分记录失败: %v", err)
return err
}
// 5. 更新用户积分
userPoint.Points = nowPoints
userPoint.PointsTotal = nowTotalPoints
if _, err := tx.Model(dao.UserPoints.Table()).
Where(dao.UserPoints.Columns().UserId, userId).
Update(&userPoint); err != nil {
g.Log().Errorf(ctx, "更新用户积分失败: %v", err)
return err
}
return nil
})
}
func (s *Service) checkRetroDate(ctx context.Context, userId uint64, date time.Time) error {
// 补签日期不能是今天或者未来的日期
now := time.Now()
if date.Year() > now.Year() ||
date.Month() != now.Month() ||
(date.Year() == now.Year() && date.YearDay() >= now.YearDay()) {
// return errors.New("补签日期无效")
return ErrInvalidRetroDate
}
// 补签的日期不能是本月之前的日期
// 补签的日期不能是已经签到的日期(签到或者补签过都算)
checkinKey := fmt.Sprintf(yearSignKeyFormat, userId, date.Year())
yearOffset := date.YearDay() - 1
checked, err := s.rc.GetBit(ctx, checkinKey, int64(yearOffset)).Result()
if err != nil {
g.Log().Errorf(ctx, "GetBit 获取当天签到状态失败: %v", err)
return err
}
if checked == 1 {
// return errors.New("该日期已签到")
return ErrInvalidRetroDate
}
retroKey := fmt.Sprintf(monthRetroKeyFormat, userId, date.Year(), date.Month())
retroOffset := date.Day() - 1
retroRet, err := s.rc.GetBit(ctx, retroKey, int64(retroOffset)).Result()
if err != nil {
g.Log().Errorf(ctx, "GetBit 获取当天补签状态失败: %v", err)
return err
}
if retroRet == 1 {
return ErrInvalidRetroDate
}
// 每个月补签不能超过三次
// nil:表示不指定范围(统计整个 key 的所有 bit)
retroCount, err := s.rc.BitCount(ctx, retroKey, nil).Result()
if err != nil {
g.Log().Errorf(ctx, "BitCount 获取补签次数失败: %v", err)
return err
}
if retroCount >= maxRetroTimesPerMonth {
return ErrRetroNotimes
}
return nil
}

mock 一下数据库记录,便于演示补签功能
bash
mysql> select * from user_points;
+----+------------------+--------+--------------+---------------------+---------------------+------------+
| id | user_id | points | points_total | created_at | updated_at | deleted_at |
+----+------------------+--------+--------------+---------------------+---------------------+------------+
| 1 | 7848412598790395 | 1 | 1 | 2025-12-30 10:48:18 | 2025-12-30 10:48:18 | NULL |
| 2 | 7848643939821819 | 3 | 3 | 2025-12-30 10:50:50 | 2026-01-05 10:57:54 | NULL |
| 3 | 7740084346448123 | 3 | 3 | 2025-12-30 22:31:58 | 2026-01-05 10:58:31 | NULL |
+----+------------------+--------+--------------+---------------------+---------------------+------------+
3 rows in set (0.00 sec)
mysql> update user_points set points=300 where user_id=7740084346448123;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> select * from user_points;
+----+------------------+--------+--------------+---------------------+---------------------+------------+
| id | user_id | points | points_total | created_at | updated_at | deleted_at |
+----+------------------+--------+--------------+---------------------+---------------------+------------+
| 1 | 7848412598790395 | 1 | 1 | 2025-12-30 10:48:18 | 2025-12-30 10:48:18 | NULL |
| 2 | 7848643939821819 | 3 | 3 | 2025-12-30 10:50:50 | 2026-01-05 10:57:54 | NULL |
| 3 | 7740084346448123 | 300 | 3 | 2025-12-30 22:31:58 | 2026-01-05 10:59:10 | NULL |
+----+------------------+--------+--------------+---------------------+---------------------+------------+
3 rows in set (0.00 sec)
启动前后端进行测试







签到提醒
每日签到提醒
可以在页面设置一个签到提醒开关,用户打开后,支持每天发 Push 或短信提醒用户签到。需要考虑短信的成本。
运营策略提醒
可以根据特定的运营策略实现签到提醒功能。
比如在每天 18:00 点对那些前两天签到过,但是今天还没有签到的用户发送即将断签提醒,引导用户及时完成签到,获得连续签到奖励。
当用户量级比较大的时候,需要编写 Lua 脚本,并使用 script load 功能,提高执行效率。
lua
-- KEYS[1]: 用户签到key
-- ARGV[1]: 当前日偏移量(从年初开始的天数)
-- ARGV[2]: 提醒阈值
local offset = tonumber(ARGV[1])
local threshold = tonumber(ARGV[2])
-- 检查今天是否已签到(最新位)
local today = redis.call('GETBIT', KEYS[1], offset)
if today == 1 then return 0 end -- 已签到无需提醒
-- 检查最近threshold天的签到情况
local continuous = true
for i = 1, threshold do
local bit = redis.call('GETBIT', KEYS[1], offset - i)
if bit ~= 1 then
continuous = false
break
end
end
-- 返回结果:1需要提醒 0不需要
return continuous and 1 or 0
在 Go 语言中使用 go:embed 将 Lua 脚本编译到可执行文件中。
Goframe 版本
go
//go:embed remind.lua
var remindScript string
// CheckAndNotify 检查签到并发送通知
func CheckAndNotify(ctx context.Context, remindThreshold int) error {
// 1. 获取所有符合条件的用户
// 可以通过 MYSQL 用户表获取近几天有签到的(如果规模大的用户表时间复杂度会较高)
// 或者可以使用签到key做记录一个 Set,例如 userID(签到过的用户更新该 Key)
userIDs := []uint64{25016147980058993}
// 2. 加载 lua 脚本
sha, err := redisClient.ScriptLoad(ctx, remindScript).Result()
if err != nil {
fmt.Printf("ScriptLoad err: %v\n", err)
return err
}
// 3. 遍历用户调用
for _, userID := range userIDs {
key := fmt.Sprintf(yearSignKeyFormat, userID, time.Now().Year())
// 计算当前日偏移量(当年天)
dayOfYearOffset := time.Now().YearDay() - 1
fmt.Printf("key: %s, dayOfYearOffset: %d\n", key, dayOfYearOffset)
result, err := redisClient.EvalSha(
ctx,
sha,
[]string{key},
dayOfYearOffset,
2,
).Int()
if err != nil {
fmt.Printf("result: %d, err: %v\n", result, err)
return err
}
if result == 1 {
fmt.Printf("用户需要发送签到提醒\n", userID)
// 发送对应通知,如对应的推送渠道(APP Push 或短信)
}
}
return nil
}
使用 goframe 框架的 gcron 实现定时任务。
go
// 开启定时任务
_, err := gcron.Add(ctx, "# 0 18 * * *", func(ctx context.Context) {
g.Log().Print(ctx, "每天18点跑定时任务")
err := impl.CheckAndNotify(ctx, 2)
fmt.Printf("CheckAndNotify err: %v\n", err)
})
if err != nil {
panic(err)
}
Gin 版本
go
package task
import (
"context"
"fmt"
"signflower-gin/internal/dao"
"time"
_ "embed"
)
const (
yearSignKeyFormat = "user:checkin:daily:%d:%d"
userCheckinsdaily1213132142131212025
)
//go:embed remind.lua
var remindScript string
// CheckAndNotify 检查签到并发送通知
func CheckAndNotify(ctx context.Context, remindThreshold int) error {
fmt.Println("start check and notify...")
// 1. 获取所有符合条件的用户
// 可以通过扫 MySQL 用户表找到最近几天有登录过的(如果有现成的用户登录时间记录可以直接拿来用)
// 或者可以在用户签到的时候记录一个 ZSet, userID:签到时间(如果用户量需要拆分 Key)
userIDs := []uint64{25016147980058993}
sha, err := dao.RedisClient.ScriptLoad(ctx, remindScript).Result()
if err != nil {
fmt.Printf("ScriptLoad err: %v\n", err)
return err
}
now := time.Now()
for _, userID := range userIDs {
key := fmt.Sprintf(yearSignKeyFormat, userID, time.Now().Year())
// 计算当前日偏移量(当年天)
dayOfYearOffset := now.YearDay() - 1
fmt.Printf("key: %s, dayOfYearOffset: %d\n", key, dayOfYearOffset)
result, err := dao.RedisClient.EvalSha(
ctx,
sha,
[]string{key},
dayOfYearOffset,
remindThreshold,
).Int()
fmt.Printf("result: %d, err: %v\n", result, err)
if err != nil {
return err
}
if result == 1 {
fmt.Printf("用户需要发送签到提醒\n", userID)
// 发送对应通知,如对应的推送渠道(APP Push 或短信)
}
}
return nil
}
使用 cron 定时任务(github.com/robfig/cron/v3)
go
// task.go
package task
import (
"context"
"time"
"github.com/robfig/cron/v3"
)
// 定时任务
/*
github.com/robfig/cron/v3 CRON 表达式格式
Field | Mandatory? | Allowed values | Allowed special characters
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
*/
func MustInit(ctx context.Context) *cron.Cron {
tz, err := time.LoadLocation("Local")
if err != nil {
panic(err)
}
c := cron.New(cron.WithLocation(tz))
c.AddFunc("25 20 * * *", func() {
CheckAndNotify(ctx, 2)
})
c.Start()
return c
}
在impl下添加lua脚本(Lua:在 Redis 内部做"计算/判断/原子操作"(快、稳、少网络往返))
lua
-- KEYS[1]: 用户签到key
-- ARGV[1]: 当前日偏移量(从年初开始的天数), 即从年初开始的今天是第几天(例如:1 月 1 日为 1,1 月 2 日为 2,以此类推)。
-- ARGV[2]: 提醒阈值, 即最多检查多少天之前的签到状态来决定是否给用户提醒。
-- offset 是当前日期的偏移量(从年初开始的第几天)
local offset = tonumber(ARGV[1])
-- threshold 是提醒阈值(多少天内连续签到才不提醒)
local threshold = tonumber(ARGV[2])
-- 检查今天是否已签到(最新位)
local today = redis.call('GETBIT', KEYS[1], offset)
if today == 1 then return 0 end -- 已签到无需提醒
-- 检查最近threshold天的签到情况
local continuous = true
for i = 1, threshold do
local bit = redis.call('GETBIT', KEYS[1], offset - i)
-- 如果某一天的签到状态不是 1(即 bit ~= 1),表示用户某天没有签到,将 continuous 设为 false,并退出循环(break)
if bit ~= 1 then
continuous = false
break
end
end
-- 返回结果:1需要提醒 0不需要
return continuous and 1 or 0
-- 如果 continuous 为 true:continuous and 1 会返回 1,然后 or 0 不会影响,最终返回 1。
-- 如果 continuous 为 false:continuous and 1 会返回 false,然后 or 0 会使整个表达式的结果变成 0

添加reminder.go(Go:负责"什么时候跑、对谁跑、跑完做什么、失败怎么处理"(业务编排))
go
package impl
import (
"backend/utility/injection"
"context"
"fmt"
"time"
_ "embed"
"github.com/redis/go-redis/v9"
)
//go:embed remind.lua
var remindScript string
// CheckAndNotify 检查签到并发送通知
func CheckAndNotify(ctx context.Context, remindThreshold int) error {
// 1. 获取所有符合条件的用户(避免遍历全量用户)
// 可以通过扫 MySQL 用户表找到最近一天有登录过的
// 或者可以在用户签到的时候记录一个 ZSet, userID:签到时间,如果用户量多需要拆分 Key
// TODO: 不要把用户Id写死,要获取所有符合条件的用户,即连续签到了今天之前的两天只是今天没签的用户
userIDs := []uint64{7740084346448123}
// 拿 Redis 客户端
rc := injection.MustInvoke[*redis.Client]()
// 2. 加载 lua script
sha, err := rc.ScriptLoad(ctx, remindScript).Result()
if err != nil {
fmt.Printf("ScriptLoad err: %v\n", err)
return err
}
// 3. 遍历判断每个用户
for _, userID := range userIDs {
key := fmt.Sprintf(yearSignKeyFormat, userID, time.Now().Year())
// 计算当前日偏移量(当年第几天)
dayOfYearOffset := time.Now().YearDay() - 1
fmt.Printf("key: %s, dayOfYearOffset: %d\n", key, dayOfYearOffset)
// 执行LUA脚本
result, err := rc.EvalSha(ctx, sha, []string{key}, dayOfYearOffset, remindThreshold).Int()
fmt.Printf("result: %d, err: %v\n", result, err)
if err != nil {
return err
}
if result == 1 {
fmt.Printf("用户%d需要发送断签提醒\n", userID)
// 发送到消息队列,执行后续的推送逻辑(APP Push 或 短信等)
}
}
return nil
}

在cmd中添加配置
go
package cmd
import (
"backend/internal/controller/checkin"
"backend/internal/controller/hello"
"backend/internal/controller/points"
"backend/internal/controller/userinfo"
"backend/internal/logic/middleware"
"backend/internal/service/checkin/impl"
"backend/utility/injection"
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcmd"
"github.com/gogf/gf/v2/os/gcron"
)
var (
// 运行 main 命令时,程序会进入 gcmd.Command 的 Func 函数,这是你定义的 main 命令的核心部分。
Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start http server",
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
// 在 Func 函数内部,首先初始化了一个 GoFrame 服务器
s := g.Server()
// 服务注入
injection.SetupDefaultInjector(ctx)
defer injection.ShutdownDefaultInjector()
// 定义了一个路由组,所有的路由都会以 /api/v1 为前缀。
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
// 注册通用响应中间件和CORS跨域中间件
// ghttp.MiddlewareHandlerResponse 是 GoFrame 的默认响应中间件,负责处理 HTTP 响应的通用逻辑。
group.Middleware(ghttp.MiddlewareHandlerResponse, middleware.CORS)
// 不需要登录也能访问的接口
group.POST("/auth/login", userinfo.NewV1(), "Login") // 登录
group.POST("/users", userinfo.NewV1(), "Create") // 创建用户
group.POST("/auth/refresh", userinfo.NewV1(), "RefreshToken") // 刷新token
// 需要登录才能访问的接口
group.Middleware(middleware.Auth)
group.GET("/users/me", userinfo.NewV1(), "Me") // 我的信息
group.Bind(
hello.NewV1(),
// userinfo.NewV1(), // 用户模块相关接口
checkin.NewV1(),
points.NewV1(),
)
})
// 开启定时任务
_, err = gcron.Add(ctx, "0 30 8 * * *", func(ctx context.Context) {
g.Log().Print(ctx, "每天8点30跑定时任务")
err := impl.CheckAndNotify(ctx, 2)
fmt.Printf("CheckAndNotify err: %v\n", err)
})
if err != nil {
panic(err)
}
s.Run()
return nil
},
}
)

启动前后端进行测试

简历辅导
黄金法则:清晰!亮点!匹配度!让别人看了你的简历就想约你面试!
项目介绍:说清楚你做了什么项目,能干啥,有啥用,有数据支持最好!
个人职务:写清楚你在项目里做什么角色,做了哪些事,抓住重点写!写到别人心坎里。项目收获:这个项目给公司带来了啥收益,你从这个项目学到了啥。
你写到简历上的项目要有一个项目背景(无论是你编的还是你实际工作过过的都要事先准备好一个项目背景);项目背景就是你在一个什么样的公司写了一个什么样的项目,你在其中做了什么。(要尽量真实一点,没有真实的也得真实起来。)
这个项目的优势是可以在任意公司的任意系统中存在(只要有用户登录,就可以说有签到这么个功能,不违和),缺点是如果只写个签到功能会比较单薄,重点是突出【签到功能】的底层设计和【积分兑换】的事务操作,积分兑换可以支持道具、优惠券、实物等,当然你也可以把这个签到中心和账号中心、会员服务、任务中心等关联起来。
比如: 我之前实习/工作的公司是一个xxx的公司,有一个网站/APP/小程序,我进公司之后在用户中心或者会员中心,部门主要是做一些用户运营和用户增长相关的业务,提供基本的用户注册、登录等信息管理,通过一些运营活动来提升用户留存。
组里大概有多少个人(小公司一个组三五个人差不多),当时我作为/跟着组长/leader从零做了一个签到系统,通过引入积分系统吸引用户每天来签到,后续会出兑换礼品的功能。
把签到系统项目写到简历上的示例
项目介绍: 作为公司 xx 小程序用户增长的核心抓手,签到中心通过每日签到、积分体系构建用户活跃闭环,目标提升用户留存率与会员转化效率。项目上线后,带动小程序 MAU 从 500 万增至 630 万(提升 26%),为会员服务日均引流 1.2 万新用户,占会员新增量的 18%,成为用户活跃与会员体系的关键连接节点。
核心架构: 打通账号系统、会员系统及任务系统,构建"签到行为 - 积分积累 - 权益兑换"全链路服务,支撑日均 10 万 + 签到请求与 2 万 + 积分兑换操作。
个人职责:
- 基于 goframe 框架搭建高可用服务,利用其模块化设计与Go并发性能优势,支撑峰值 QPS 800 + 的签到请求,服务可用性达 99.95%。
- 针对海量签到数据,设计 Redis Bitmap+MySQL 分层存储方案 ------ 用 Bitmap 高效存储当日 / 近期签到状态(内存占用降低 60%),支持毫秒级连续签到天数查询;历史数据按周批量持久化至 MySQL ,兼顾查询性能与存储成本。
- 积分兑换支持虚拟道具(如补签卡、优惠券)与实物礼品的差异化兑换流程;
- 基于 MySQL 事务 + 分布式锁实现积分扣减与礼品发放的强一致性,保障兑换过程零资损,兑换成功率稳定在 99.9% 以上。
- 根据运营策略编写 lua 脚本,对即将断签的用户发送签到提醒,使连续签到用户占比提升 11%;
项目收获
- 主导签到打卡服务向服务化、平台化发展,抽象出独立的签到中心能力。
- 熟练掌握了 goframe 框架的主要特性,能够熟练使用 goframe 框架进行业务开发。
- 能够灵活使用 MySQL、Redis 解决业务中的存储痛点问题。
- 锻炼了自己的自主学习能力,积累了大型项目设计和开发经验。
面试可能会问的问题
0、MySQL 事务是一定会被问到的点
直接点链接查看👉 牛客网MySQL事务相关的面试真题
1、如何保证MySQL和Redis的数据一致性
在典型的互联网应用架构中,MySQL 作为关系型数据库,提供 ACID 事务保证;而 Redis 通常用作缓存或高性能键值存储,其事务(MULTI/EXEC)是原子性的,但不支持跨多个 Redis 命令的回滚,更不支持与外部系统(如 MySQL)的分布式事务(XA/2PC)。
因此,我们无法实现一个能同时原子性地覆盖 MySQL 和 Redis 操作的"真"分布式事务。我们需要采用最终一致性的策略,并优先保证核心数据存储(MySQL)的一致性
使用消息队列实现最终一致性(更复杂,适用于高并发或解耦场景)
- 启动 MySQL 事务。
- 执行核心数据库操作(如上述)。
- 在同一个 MySQL 事务中,向一个"本地消息表"或"事务性发件箱表"(Transactional Outbox Pattern)插入一条消息。这条消息包含了需要对 Redis 进行的操作信息(例如,用户ID,新的总积分等)。
- 提交 MySQL 事务。
- 如果事务成功,数据库操作和"待发送消息"都已持久化。
- 如果事务失败,所有更改(包括消息)都会回滚。
- 一个独立的后台轮询进程或消息中继服务:
- 定期扫描"本地消息表"。
- 将消息发送到真正的消息队列(如 Kafka, RabbitMQ)。
- 发送成功后,标记或删除本地消息表中的消息。
- 另一个独立的消费者服务:
- 从消息队列消费消息。
- 根据消息内容更新 Redis 缓存。
- 处理消费失败的情况(重试、死信队列等)。
优点:
- 主业务流程与缓存更新完全解耦。
- 对 Redis 的更新具有更高的容错性(通过消息队列的重试机制)。
- MySQL 事务非常快,因为它不直接等待 Redis 操作。
缺点:
- 架构更复杂,引入了消息队列和额外的服务。
- 数据在 Redis 中的一致性是最终的,延迟取决于消息处理速度。
2、如果是海量用户该如何分表?
数据库按 user_id %100 分表。
比如:user_id = 1926199058553114624,则 user_id%100 = 24
该用户的数据该存入 userinfo_24、user_points_24。
3、如何拓展项目(也可以直接写到你的项目描述里)
1、增加签到提醒功能 ,用户开启后,每日系统定时给用户发送消息提醒。
2、增加运营玩法,连续签到奖励不是积分,而是可以灵活配置的其他奖励。
将奖励内容抽象为道具,运营可灵活配置。
3、增加与任务中心的联动,用户可以做任务得积分。
- 任务中心简单版本:可以定义唯一任务ID,接入的服务方通知哪个用户完成了哪个任务。
- 任务中心高级版本:基于消息队列 + FACT 行为事实实现。
参考资料
产品设计相关参考:
https://www.woshipm.com/pd/4421789.html
https://www.woshipm.com/pd/771177.html
https://www.woshipm.com/pd/5699161.html
https://www.woshipm.com/operate/5889290.html
https://www.woshipm.com/pd/2268594.html
https://www.woshipm.com/it/5610304.html
https://www.woshipm.com/pd/4298167.html
https://www.woshipm.com/user-research/4268902.html
https://www.woshipm.com/operate/5285842.html
技术实现相关参考:
https://juejin.cn/post/6881928046031568903
https://juejin.cn/post/7274847103728844840
https://planeswalker23.github.io/2022/03/10/我所理解的Redis系列列-第篇-如何设计签到系列统(基础级篇)/
之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!