积分系统完美实现方案:让每一分都清清楚楚!💰

105 阅读13分钟

标题: 积分系统难做?看我如何保证不丢一分不错一笔!
副标题: 从流水记录到余额快照,积分系统设计全攻略


🎬 开篇:一个让财务抓狂的Bug

月底对账日:

财务:小李,这个月的积分对不上啊!😰
开发:不可能啊,我都测试过了!
财务:用户A说他有1000积分,系统显示800!
      用户B说扣了200积分,但流水里找不到记录!
      还有50个用户的积分是负数!💀

CEO:这个月奖金全扣了!查出问题来!😤

排查发现:
1. 并发扣积分导致余额为负
2. 流水记录丢失(事务回滚了)
3. 余额直接更新,无法追溯历史
4. 没有对账机制

损失:
- 补偿用户:5万+
- 加班对账:3天3夜
- 信誉损失:无价

教训:积分系统看似简单,实则坑多!

🤔 积分系统的核心难题

想象你的银行账户:

  • ❌ 只记余额: 你无法知道钱是怎么花的(不透明)
  • ✅ 记录流水: 每笔收支都清清楚楚(可追溯)

核心原则:余额 = 所有流水的累加!


📚 知识地图

积分系统设计三大支柱
├── 📝 流水记录表(Point_Log)- 每笔操作都记录
├── 💰 余额快照表(Point_Account)- 当前余额
└── 🔒 事务一致性(保证不丢不错)
    ├── 数据库事务
    ├── 分布式事务
    └── 最终一致性

📝 第一章:数据库表设计

核心表结构

-- 1. 积分账户表(余额快照)
CREATE TABLE point_account (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    balance INT NOT NULL DEFAULT 0 COMMENT '当前余额',
    total_income INT NOT NULL DEFAULT 0 COMMENT '累计收入',
    total_expense INT NOT NULL DEFAULT 0 COMMENT '累计支出',
    version INT NOT NULL DEFAULT 0 COMMENT '版本号(乐观锁)',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    UNIQUE KEY uk_user_id (user_id),
    INDEX idx_balance (balance),
    INDEX idx_update_time (update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分账户表';

-- 2. 积分流水表(关键!)
CREATE TABLE point_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    change_type TINYINT NOT NULL COMMENT '变动类型:1-收入,2-支出',
    change_amount INT NOT NULL COMMENT '变动金额(正数)',
    balance_before INT NOT NULL COMMENT '变动前余额',
    balance_after INT NOT NULL COMMENT '变动后余额',
    business_type VARCHAR(50) NOT NULL COMMENT '业务类型:ORDER_REWARD-下单奖励,SIGN_IN-签到,EXCHANGE-兑换等',
    business_id VARCHAR(100) COMMENT '业务ID(订单号、活动ID等)',
    description VARCHAR(500) COMMENT '描述',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    INDEX idx_user_id (user_id),
    INDEX idx_create_time (create_time),
    INDEX idx_business (business_type, business_id),
    INDEX idx_change_type (change_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分流水表';

-- 3. 积分规则表
CREATE TABLE point_rule (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    rule_code VARCHAR(50) NOT NULL COMMENT '规则编码',
    rule_name VARCHAR(100) NOT NULL COMMENT '规则名称',
    business_type VARCHAR(50) NOT NULL COMMENT '业务类型',
    point_amount INT NOT NULL COMMENT '积分数量',
    max_times_per_day INT COMMENT '每天最大次数(NULL表示不限制)',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-启用,0-禁用',
    start_time DATETIME COMMENT '生效开始时间',
    end_time DATETIME COMMENT '生效结束时间',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_rule_code (rule_code),
    INDEX idx_business_type (business_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分规则表';

-- 初始化规则数据
INSERT INTO point_rule (rule_code, rule_name, business_type, point_amount, max_times_per_day) VALUES
('SIGN_IN', '每日签到', 'SIGN_IN', 10, 1),
('ORDER_REWARD', '下单奖励', 'ORDER_REWARD', 100, NULL),
('INVITE_FRIEND', '邀请好友', 'INVITE_FRIEND', 500, NULL),
('COMMENT_REWARD', '评价奖励', 'COMMENT_REWARD', 20, 3),
('EXCHANGE_GOODS', '积分兑换', 'EXCHANGE', -100, NULL);

💰 第二章:核心业务实现

方案1:单库事务(推荐!)

/**
 * 积分服务实现
 */
@Service
public class PointService {
    
    @Autowired
    private PointAccountMapper accountMapper;
    
    @Autowired
    private PointLogMapper logMapper;
    
    @Autowired
    private PointRuleMapper ruleMapper;
    
    /**
     * 增加积分
     * 
     * @param userId 用户ID
     * @param businessType 业务类型
     * @param businessId 业务ID
     * @param amount 积分数量
     * @param description 描述
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean addPoints(Long userId, String businessType, String businessId, 
                            Integer amount, String description) {
        
        // 1. 参数校验
        if (amount <= 0) {
            throw new IllegalArgumentException("积分数量必须大于0");
        }
        
        // 2. 幂等性校验(防止重复发放)
        if (businessId != null) {
            PointLog existLog = logMapper.selectByBusinessId(businessType, businessId);
            if (existLog != null) {
                log.warn("积分已发放过:businessType={}, businessId={}", 
                    businessType, businessId);
                return false;
            }
        }
        
        // 3. 获取或创建账户
        PointAccount account = accountMapper.selectByUserId(userId);
        if (account == null) {
            account = createAccount(userId);
        }
        
        // 4. 记录变动前余额
        int balanceBefore = account.getBalance();
        int balanceAfter = balanceBefore + amount;
        
        // 5. ⚡ 更新账户余额(乐观锁)
        int updated = accountMapper.updateBalance(
            userId, 
            amount, 
            account.getVersion()
        );
        
        if (updated == 0) {
            log.error("更新余额失败,可能版本冲突:userId={}", userId);
            throw new OptimisticLockException("账户更新冲突,请重试");
        }
        
        // 6. 📝 插入流水记录(关键!)
        PointLog log = PointLog.builder()
            .userId(userId)
            .changeType(ChangeType.INCOME.getCode())
            .changeAmount(amount)
            .balanceBefore(balanceBefore)
            .balanceAfter(balanceAfter)
            .businessType(businessType)
            .businessId(businessId)
            .description(description)
            .build();
        
        logMapper.insert(log);
        
        log.info("积分发放成功:userId={}, amount={}, balance={}->{}", 
            userId, amount, balanceBefore, balanceAfter);
        
        return true;
    }
    
    /**
     * 扣减积分
     * 
     * @param userId 用户ID
     * @param businessType 业务类型
     * @param businessId 业务ID
     * @param amount 积分数量
     * @param description 描述
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean deductPoints(Long userId, String businessType, String businessId,
                               Integer amount, String description) {
        
        // 1. 参数校验
        if (amount <= 0) {
            throw new IllegalArgumentException("积分数量必须大于0");
        }
        
        // 2. 幂等性校验
        if (businessId != null) {
            PointLog existLog = logMapper.selectByBusinessId(businessType, businessId);
            if (existLog != null) {
                log.warn("积分已扣减过:businessType={}, businessId={}", 
                    businessType, businessId);
                return true;  // 已扣减,返回true
            }
        }
        
        // 3. 获取账户
        PointAccount account = accountMapper.selectByUserId(userId);
        if (account == null) {
            log.error("账户不存在:userId={}", userId);
            return false;
        }
        
        // 4. 检查余额
        if (account.getBalance() < amount) {
            log.warn("积分余额不足:userId={}, balance={}, need={}", 
                userId, account.getBalance(), amount);
            return false;
        }
        
        // 5. 记录变动前后余额
        int balanceBefore = account.getBalance();
        int balanceAfter = balanceBefore - amount;
        
        // 6. ⚡ 更新账户余额(带余额检查)
        int updated = accountMapper.deductBalance(
            userId,
            amount,
            account.getVersion()
        );
        
        if (updated == 0) {
            log.error("扣减余额失败:userId={}", userId);
            throw new OptimisticLockException("账户更新冲突,请重试");
        }
        
        // 7. 📝 插入流水记录
        PointLog log = PointLog.builder()
            .userId(userId)
            .changeType(ChangeType.EXPENSE.getCode())
            .changeAmount(amount)
            .balanceBefore(balanceBefore)
            .balanceAfter(balanceAfter)
            .businessType(businessType)
            .businessId(businessId)
            .description(description)
            .build();
        
        logMapper.insert(log);
        
        log.info("积分扣减成功:userId={}, amount={}, balance={}->{}", 
            userId, amount, balanceBefore, balanceAfter);
        
        return true;
    }
    
    /**
     * 创建账户
     */
    private PointAccount createAccount(Long userId) {
        PointAccount account = PointAccount.builder()
            .userId(userId)
            .balance(0)
            .totalIncome(0)
            .totalExpense(0)
            .version(0)
            .build();
        
        accountMapper.insert(account);
        
        log.info("创建积分账户:userId={}", userId);
        return account;
    }
}

/**
 * Mapper实现
 */
@Mapper
public interface PointAccountMapper {
    
    /**
     * 根据用户ID查询账户
     */
    @Select("SELECT * FROM point_account WHERE user_id = #{userId}")
    PointAccount selectByUserId(@Param("userId") Long userId);
    
    /**
     * 更新余额(增加积分)
     */
    @Update("UPDATE point_account " +
            "SET balance = balance + #{amount}, " +
            "    total_income = total_income + #{amount}, " +
            "    version = version + 1 " +
            "WHERE user_id = #{userId} " +
            "AND version = #{version}")
    int updateBalance(@Param("userId") Long userId, 
                     @Param("amount") Integer amount,
                     @Param("version") Integer version);
    
    /**
     * 扣减余额(带余额检查)
     */
    @Update("UPDATE point_account " +
            "SET balance = balance - #{amount}, " +
            "    total_expense = total_expense + #{amount}, " +
            "    version = version + 1 " +
            "WHERE user_id = #{userId} " +
            "AND balance >= #{amount} " +  // ⚡ 关键:检查余额
            "AND version = #{version}")
    int deductBalance(@Param("userId") Long userId,
                     @Param("amount") Integer amount,
                     @Param("version") Integer version);
}

@Mapper
public interface PointLogMapper {
    
    /**
     * 插入流水记录
     */
    @Insert("INSERT INTO point_log " +
            "(user_id, change_type, change_amount, balance_before, balance_after, " +
            "business_type, business_id, description) " +
            "VALUES " +
            "(#{userId}, #{changeType}, #{changeAmount}, #{balanceBefore}, #{balanceAfter}, " +
            "#{businessType}, #{businessId}, #{description})")
    int insert(PointLog log);
    
    /**
     * 根据业务ID查询流水(幂等性校验)
     */
    @Select("SELECT * FROM point_log " +
            "WHERE business_type = #{businessType} " +
            "AND business_id = #{businessId} " +
            "LIMIT 1")
    PointLog selectByBusinessId(@Param("businessType") String businessType,
                               @Param("businessId") String businessId);
    
    /**
     * 查询用户流水列表
     */
    @Select("SELECT * FROM point_log " +
            "WHERE user_id = #{userId} " +
            "ORDER BY create_time DESC " +
            "LIMIT #{offset}, #{limit}")
    List<PointLog> selectByUserId(@Param("userId") Long userId,
                                  @Param("offset") Integer offset,
                                  @Param("limit") Integer limit);
}

方案2:分布式事务(跨服务场景)

/**
 * 使用Seata实现分布式事务
 */
@Service
public class DistributedPointService {
    
    @Autowired
    private PointService pointService;
    
    @Autowired
    private OrderServiceClient orderServiceClient;
    
    @Autowired
    private UserServiceClient userServiceClient;
    
    /**
     * 下单奖励积分(分布式事务)
     */
    @GlobalTransactional(timeoutMills = 30000, name = "order-point-reward")
    public void rewardPointsForOrder(Long orderId) {
        // 1. 查询订单信息(跨服务调用)
        Order order = orderServiceClient.getOrder(orderId);
        if (order == null) {
            throw new BusinessException("订单不存在");
        }
        
        // 2. 计算积分(订单金额的10%)
        int points = (int) (order.getAmount() * 0.1);
        
        // 3. 发放积分(本地事务)
        boolean success = pointService.addPoints(
            order.getUserId(),
            "ORDER_REWARD",
            orderId.toString(),
            points,
            "下单奖励积分"
        );
        
        if (!success) {
            throw new BusinessException("发放积分失败");
        }
        
        // 4. 更新用户等级(跨服务调用)
        userServiceClient.updateUserLevel(order.getUserId());
        
        log.info("下单奖励积分完成:orderId={}, points={}", orderId, points);
    }
}

/**
 * Feign客户端
 */
@FeignClient(name = "order-service")
public interface OrderServiceClient {
    
    @GetMapping("/order/{id}")
    Order getOrder(@PathVariable("id") Long id);
}

@FeignClient(name = "user-service")
public interface UserServiceClient {
    
    @PostMapping("/user/{id}/update-level")
    void updateUserLevel(@PathVariable("id") Long userId);
}

方案3:最终一致性(高并发场景)

/**
 * 使用MQ实现最终一致性
 */
@Service
public class AsyncPointService {
    
    @Autowired
    private RabbitTemplate rabbitTemplate;
    
    /**
     * 发送积分变动消息
     */
    public void sendPointChangeMessage(PointChangeMessage message) {
        rabbitTemplate.convertAndSend(
            "point.exchange",
            "point.change",
            message
        );
        
        log.info("发送积分变动消息:userId={}, amount={}", 
            message.getUserId(), message.getAmount());
    }
    
    /**
     * MQ消费者:处理积分变动
     */
    @RabbitListener(queues = "point.change.queue")
    public void handlePointChange(PointChangeMessage message) {
        try {
            if (message.getChangeType() == ChangeType.INCOME) {
                // 增加积分
                pointService.addPoints(
                    message.getUserId(),
                    message.getBusinessType(),
                    message.getBusinessId(),
                    message.getAmount(),
                    message.getDescription()
                );
            } else {
                // 扣减积分
                pointService.deductPoints(
                    message.getUserId(),
                    message.getBusinessType(),
                    message.getBusinessId(),
                    message.getAmount(),
                    message.getDescription()
                );
            }
            
        } catch (Exception e) {
            log.error("处理积分变动失败", e);
            // 重试或死信队列
            throw new AmqpRejectAndDontRequeueException("处理失败", e);
        }
    }
}

/**
 * 积分变动消息
 */
@Data
@Builder
public class PointChangeMessage implements Serializable {
    private Long userId;
    private ChangeType changeType;  // 收入/支出
    private Integer amount;
    private String businessType;
    private String businessId;
    private String description;
}

🔒 第三章:核心功能实现

功能1:签到奖励(每日限制)

/**
 * 签到服务
 */
@Service
public class SignInService {
    
    @Autowired
    private PointService pointService;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 用户签到
     */
    public boolean signIn(Long userId) {
        String dateStr = LocalDate.now().toString();  // 2025-10-24
        String key = "sign_in:" + userId + ":" + dateStr;
        
        // 1. 检查今天是否已签到(幂等性)
        Boolean hasSignedIn = redisTemplate.hasKey(key);
        if (Boolean.TRUE.equals(hasSignedIn)) {
            log.warn("今天已签到:userId={}", userId);
            return false;
        }
        
        // 2. 发放签到积分
        boolean success = pointService.addPoints(
            userId,
            "SIGN_IN",
            "SIGN_IN_" + userId + "_" + dateStr,
            10,  // 签到奖励10积分
            "每日签到"
        );
        
        if (!success) {
            return false;
        }
        
        // 3. 标记今天已签到
        redisTemplate.opsForValue().set(
            key, 
            "1", 
            Duration.ofDays(1)
        );
        
        // 4. 累计连续签到天数
        incrementConsecutiveDays(userId);
        
        log.info("签到成功:userId={}", userId);
        return true;
    }
    
    /**
     * 累计连续签到天数
     */
    private void incrementConsecutiveDays(Long userId) {
        String key = "consecutive_sign_in:" + userId;
        
        // 检查昨天是否签到
        String yesterday = LocalDate.now().minusDays(1).toString();
        String yesterdayKey = "sign_in:" + userId + ":" + yesterday;
        
        Boolean hasSignedYesterday = redisTemplate.hasKey(yesterdayKey);
        
        if (Boolean.TRUE.equals(hasSignedYesterday)) {
            // 连续签到天数+1
            redisTemplate.opsForValue().increment(key);
        } else {
            // 重置为1
            redisTemplate.opsForValue().set(key, "1");
        }
        
        // 设置过期时间(30天)
        redisTemplate.expire(key, Duration.ofDays(30));
    }
    
    /**
     * 查询连续签到天数
     */
    public int getConsecutiveDays(Long userId) {
        String key = "consecutive_sign_in:" + userId;
        String days = redisTemplate.opsForValue().get(key);
        return days != null ? Integer.parseInt(days) : 0;
    }
}

功能2:积分兑换商品

/**
 * 积分兑换服务
 */
@Service
public class PointExchangeService {
    
    @Autowired
    private PointService pointService;
    
    @Autowired
    private GoodsService goodsService;
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 兑换商品
     */
    @Transactional(rollbackFor = Exception.class)
    public String exchangeGoods(Long userId, Long goodsId, Integer quantity) {
        // 1. 查询商品信息
        Goods goods = goodsService.getById(goodsId);
        if (goods == null) {
            throw new BusinessException("商品不存在");
        }
        
        // 2. 计算所需积分
        int totalPoints = goods.getPointPrice() * quantity;
        
        // 3. 检查库存
        if (goods.getStock() < quantity) {
            throw new BusinessException("库存不足");
        }
        
        // 4. 扣减积分
        boolean success = pointService.deductPoints(
            userId,
            "EXCHANGE",
            null,  // businessId在后面生成
            totalPoints,
            "兑换商品:" + goods.getName()
        );
        
        if (!success) {
            throw new BusinessException("积分不足");
        }
        
        // 5. 扣减库存
        goodsService.deductStock(goodsId, quantity);
        
        // 6. 创建兑换订单
        String orderId = orderService.createExchangeOrder(
            userId,
            goodsId,
            quantity,
            totalPoints
        );
        
        log.info("兑换成功:userId={}, goodsId={}, points={}, orderId={}", 
            userId, goodsId, totalPoints, orderId);
        
        return orderId;
    }
}

功能3:积分过期

/**
 * 积分过期服务
 */
@Service
public class PointExpireService {
    
    @Autowired
    private PointLogMapper logMapper;
    
    @Autowired
    private PointService pointService;
    
    /**
     * 定时任务:处理过期积分(每天凌晨1点执行)
     */
    @Scheduled(cron = "0 0 1 * * ?")
    public void handleExpiredPoints() {
        log.info("开始处理过期积分");
        
        // 1. 查询1年前的收入流水
        LocalDateTime expireTime = LocalDateTime.now().minusYears(1);
        List<PointLog> expiredLogs = logMapper.selectExpiredIncome(expireTime);
        
        log.info("查询到{}条过期积分记录", expiredLogs.size());
        
        // 2. 按用户ID分组
        Map<Long, List<PointLog>> groupByUser = expiredLogs.stream()
            .collect(Collectors.groupingBy(PointLog::getUserId));
        
        // 3. 扣减过期积分
        for (Map.Entry<Long, List<PointLog>> entry : groupByUser.entrySet()) {
            Long userId = entry.getKey();
            List<PointLog> logs = entry.getValue();
            
            // 计算过期总积分
            int expiredTotal = logs.stream()
                .mapToInt(PointLog::getChangeAmount)
                .sum();
            
            try {
                // 扣减积分
                pointService.deductPoints(
                    userId,
                    "EXPIRE",
                    "EXPIRE_" + LocalDate.now(),
                    expiredTotal,
                    "积分过期"
                );
                
                log.info("扣减过期积分:userId={}, amount={}", userId, expiredTotal);
                
            } catch (Exception e) {
                log.error("扣减过期积分失败:userId=" + userId, e);
            }
        }
        
        log.info("处理过期积分完成");
    }
}

@Mapper
public interface PointLogMapper {
    
    /**
     * 查询过期的收入流水
     */
    @Select("SELECT * FROM point_log " +
            "WHERE change_type = 1 " +  // 收入
            "AND create_time < #{expireTime} " +
            "AND id NOT IN (" +
            "  SELECT business_id FROM point_log " +
            "  WHERE business_type = 'EXPIRE' " +
            "  AND business_id LIKE 'LOG_%'" +
            ")")
    List<PointLog> selectExpiredIncome(@Param("expireTime") LocalDateTime expireTime);
}

📊 第四章:对账与修复

对账逻辑

/**
 * 积分对账服务
 */
@Service
public class PointReconciliationService {
    
    @Autowired
    private PointAccountMapper accountMapper;
    
    @Autowired
    private PointLogMapper logMapper;
    
    /**
     * 对账:检查余额是否等于流水累加
     */
    public ReconciliationResult reconcile(Long userId) {
        // 1. 查询账户余额
        PointAccount account = accountMapper.selectByUserId(userId);
        if (account == null) {
            return ReconciliationResult.notFound(userId);
        }
        
        // 2. 查询所有流水
        List<PointLog> logs = logMapper.selectAllByUserId(userId);
        
        // 3. 计算流水累加值
        int incomeSum = logs.stream()
            .filter(log -> log.getChangeType() == ChangeType.INCOME.getCode())
            .mapToInt(PointLog::getChangeAmount)
            .sum();
        
        int expenseSum = logs.stream()
            .filter(log -> log.getChangeType() == ChangeType.EXPENSE.getCode())
            .mapToInt(PointLog::getChangeAmount)
            .sum();
        
        int calculatedBalance = incomeSum - expenseSum;
        
        // 4. 对比余额
        boolean isMatch = (account.getBalance() == calculatedBalance);
        
        ReconciliationResult result = ReconciliationResult.builder()
            .userId(userId)
            .accountBalance(account.getBalance())
            .calculatedBalance(calculatedBalance)
            .incomeSum(incomeSum)
            .expenseSum(expenseSum)
            .isMatch(isMatch)
            .difference(account.getBalance() - calculatedBalance)
            .build();
        
        if (!isMatch) {
            log.error("积分对账失败:{}", result);
        }
        
        return result;
    }
    
    /**
     * 修复余额(慎用!需要人工审核)
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean fixBalance(Long userId) {
        ReconciliationResult result = reconcile(userId);
        
        if (result.isMatch()) {
            log.info("余额正确,无需修复:userId={}", userId);
            return true;
        }
        
        // 1. 计算差额
        int difference = result.getDifference();
        
        // 2. 更新账户余额为正确值
        int updated = accountMapper.updateBalanceDirectly(
            userId,
            result.getCalculatedBalance(),
            result.getIncomeSum(),
            result.getExpenseSum()
        );
        
        if (updated == 0) {
            log.error("修复余额失败:userId={}", userId);
            return false;
        }
        
        // 3. 记录修复流水
        PointLog log = PointLog.builder()
            .userId(userId)
            .changeType(difference > 0 ? ChangeType.EXPENSE.getCode() : ChangeType.INCOME.getCode())
            .changeAmount(Math.abs(difference))
            .balanceBefore(result.getAccountBalance())
            .balanceAfter(result.getCalculatedBalance())
            .businessType("FIX")
            .businessId("FIX_" + System.currentTimeMillis())
            .description("系统修复余额,差额:" + difference)
            .build();
        
        logMapper.insert(log);
        
        log.info("修复余额成功:userId={}, 修复前={}, 修复后={}, 差额={}", 
            userId, result.getAccountBalance(), result.getCalculatedBalance(), difference);
        
        return true;
    }
    
    /**
     * 批量对账(定时任务)
     */
    @Scheduled(cron = "0 0 3 * * ?")  // 每天凌晨3点
    public void batchReconcile() {
        log.info("开始批量对账");
        
        // 查询所有账户
        List<PointAccount> accounts = accountMapper.selectAll();
        
        int totalCount = accounts.size();
        int errorCount = 0;
        
        for (PointAccount account : accounts) {
            ReconciliationResult result = reconcile(account.getUserId());
            
            if (!result.isMatch()) {
                errorCount++;
                
                // 发送告警
                alertService.sendAlert(
                    "积分对账异常",
                    "用户ID:" + account.getUserId() + 
                    ",账户余额:" + result.getAccountBalance() +
                    ",计算余额:" + result.getCalculatedBalance() +
                    ",差额:" + result.getDifference()
                );
            }
        }
        
        log.info("批量对账完成:总数={}, 异常数={}", totalCount, errorCount);
    }
}

/**
 * 对账结果
 */
@Data
@Builder
public class ReconciliationResult {
    private Long userId;
    private Integer accountBalance;      // 账户余额
    private Integer calculatedBalance;   // 计算余额
    private Integer incomeSum;           // 收入总和
    private Integer expenseSum;          // 支出总和
    private boolean isMatch;             // 是否匹配
    private Integer difference;          // 差额
}

⚡ 第五章:性能优化

优化1:Redis缓存余额

/**
 * 使用Redis缓存余额(提升查询性能)
 */
@Service
public class CachedPointService {
    
    @Autowired
    private PointAccountMapper accountMapper;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 查询余额(带缓存)
     */
    public Integer getBalance(Long userId) {
        String cacheKey = "point:balance:" + userId;
        
        // 1. 查Redis缓存
        String cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return Integer.parseInt(cached);
        }
        
        // 2. 查数据库
        PointAccount account = accountMapper.selectByUserId(userId);
        int balance = account != null ? account.getBalance() : 0;
        
        // 3. 写入缓存(5分钟过期)
        redisTemplate.opsForValue().set(
            cacheKey,
            String.valueOf(balance),
            Duration.ofMinutes(5)
        );
        
        return balance;
    }
    
    /**
     * 更新余额后清除缓存
     */
    public void clearBalanceCache(Long userId) {
        String cacheKey = "point:balance:" + userId;
        redisTemplate.delete(cacheKey);
    }
}

优化2:批量发放积分

/**
 * 批量发放积分(提升吞吐量)
 */
@Service
public class BatchPointService {
    
    @Autowired
    private PointAccountMapper accountMapper;
    
    @Autowired
    private PointLogMapper logMapper;
    
    /**
     * 批量发放积分
     */
    @Transactional(rollbackFor = Exception.class)
    public void batchAddPoints(List<PointChangeRequest> requests) {
        if (requests == null || requests.isEmpty()) {
            return;
        }
        
        // 1. 按用户ID分组
        Map<Long, List<PointChangeRequest>> groupByUser = requests.stream()
            .collect(Collectors.groupingBy(PointChangeRequest::getUserId));
        
        // 2. 批量更新余额
        List<PointLog> logs = new ArrayList<>();
        
        for (Map.Entry<Long, List<PointChangeRequest>> entry : groupByUser.entrySet()) {
            Long userId = entry.getKey();
            List<PointChangeRequest> userRequests = entry.getValue();
            
            // 计算总积分
            int totalAmount = userRequests.stream()
                .mapToInt(PointChangeRequest::getAmount)
                .sum();
            
            // 查询账户
            PointAccount account = accountMapper.selectByUserId(userId);
            if (account == null) {
                continue;
            }
            
            // 更新余额
            int updated = accountMapper.updateBalance(
                userId,
                totalAmount,
                account.getVersion()
            );
            
            if (updated == 0) {
                log.error("批量更新余额失败:userId={}", userId);
                continue;
            }
            
            // 生成流水记录
            for (PointChangeRequest request : userRequests) {
                PointLog log = PointLog.builder()
                    .userId(userId)
                    .changeType(ChangeType.INCOME.getCode())
                    .changeAmount(request.getAmount())
                    .balanceBefore(account.getBalance())
                    .balanceAfter(account.getBalance() + request.getAmount())
                    .businessType(request.getBusinessType())
                    .businessId(request.getBusinessId())
                    .description(request.getDescription())
                    .build();
                
                logs.add(log);
            }
        }
        
        // 3. 批量插入流水
        if (!logs.isEmpty()) {
            logMapper.batchInsert(logs);
        }
        
        log.info("批量发放积分完成:总数={}", requests.size());
    }
}

@Mapper
public interface PointLogMapper {
    
    /**
     * 批量插入流水
     */
    @Insert("<script>" +
            "INSERT INTO point_log " +
            "(user_id, change_type, change_amount, balance_before, balance_after, " +
            "business_type, business_id, description) " +
            "VALUES " +
            "<foreach collection='logs' item='log' separator=','>" +
            "(#{log.userId}, #{log.changeType}, #{log.changeAmount}, " +
            "#{log.balanceBefore}, #{log.balanceAfter}, " +
            "#{log.businessType}, #{log.businessId}, #{log.description})" +
            "</foreach>" +
            "</script>")
    int batchInsert(@Param("logs") List<PointLog> logs);
}

✅ 最佳实践清单

数据库设计:
□ 流水表记录所有变动(包括时间、金额、余额)
□ 余额表只存储快照(加乐观锁版本号)
□ 业务ID字段用于幂等性校验
□ 添加必要的索引(user_id, create_time, business_id)

业务逻辑:
□ 每次变动都必须记录流水
□ 更新余额使用乐观锁
□ 扣减积分检查余额是否足够
□ 幂等性设计(防止重复发放)
□ 流水记录变动前后余额

事务管理:
□ 余额更新和流水记录在同一事务
□ 分布式场景使用Seata或MQ
□ 设置合理的事务超时时间
□ 异常回滚机制

对账机制:
□ 定时对账(余额 = 流水累加)
□ 发现异常及时告警
□ 提供修复工具(需审批)
□ 保留完整的修复记录

性能优化:
□ Redis缓存余额(提升查询性能)
□ 批量操作(提升吞吐量)
□ 异步处理(MQ削峰)
□ 分库分表(海量数据)

🎉 总结

核心原则

积分系统的金科玉律:

1️⃣ 余额 = 流水累加
   - 余额只是快照
   - 流水才是真相

2️⃣ 每笔操作都记录流水
   - 包括增加、扣减、过期、修复
   - 记录变动前后余额

3️⃣ 保证事务一致性
   - 余额和流水同一事务
   - 使用乐观锁防并发

4️⃣ 幂等性设计
   - 防止重复发放
   - 使用业务ID去重

5️⃣ 定期对账
   - 每天对账
   - 发现问题及时修复

📚 延伸阅读


记住:积分系统的核心是"流水记录+余额快照+事务一致性"! 💰


文档编写时间:2025年10月24日
作者:热爱金融系统的积分工程师
版本:v1.0
愿你的每一分都清清楚楚! 💰✨