标题: 积分系统难做?看我如何保证不丢一分不错一笔!
副标题: 从流水记录到余额快照,积分系统设计全攻略
🎬 开篇:一个让财务抓狂的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
愿你的每一分都清清楚楚! 💰✨