设计背景
积分系统是电商、会员系统中的常见功能模块。本文记录了一个支持积分过期时间的积分系统设计方案。
设计演进过程
最初的设计思路比较简单:
- 用户积分账户表:记录用户的总积分、可用积分等汇总信息
- 用户积分记录表:记录每笔积分的获得和消费明细
但在加入积分过期时间需求后,发现这种设计存在问题:
- 每笔积分的过期时间不同
- 扣减积分时需要遵循FIFO原则(先进先出,优先扣减即将过期的积分)
- 需要能够追溯每次扣减的积分来源
因此重新设计了三张表的方案,并增加了积分扣减详情表来记录积分扣减的来源追踪。
数据库表设计
表结构概览
| 表名 | 作用 | 核心字段 |
|---|---|---|
member_point_account |
用户积分账户汇总 | available_points, frozen_points, version |
point_record |
积分变动记录 | point_amount, remain_point, expire_time |
point_reduce_detail |
积分扣减详情 | reduce_id, add_id, used_value |
1. 用户积分账户表 (member_point_account)
| 字段名 | 类型 | 说明 | 备注 |
|---|---|---|---|
id |
int | 主键ID | 自增 |
member_id |
int | 用户ID | 唯一索引 |
total_points |
int | 总积分 | 累计获得的积分总数 |
available_points |
int | 可用积分 | 当前可使用的积分 |
frozen_points |
int | 冻结积分 | 暂时不可用的积分 |
version |
int | 乐观锁版本号 | 防止并发问题 |
create_time |
datetime | 创建时间 | - |
update_time |
datetime | 更新时间 | - |
设计要点:
- 使用乐观锁(
version)解决并发更新问题 available_points是实时计算的可用积分,用于快速查询- 支持积分冻结功能
2. 积分记录表 (point_record)
| 字段名 | 类型 | 说明 | 备注 |
|---|---|---|---|
id |
int | 主键ID | 自增 |
member_id |
int | 用户ID | - |
point_type |
varchar(50) | 积分类型 | EARN获得, SPEND消费, EXPIRE过期 |
point_amount |
int | 积分数量 | 正数为获得,负数为消费 |
remain_point |
int | 剩余积分 | 该记录剩余的可用积分 |
source_type |
varchar(50) | 来源类型 | REGISTER, FIRST_ORDER, INVITE等 |
source_id |
int | 来源ID | 订单ID、邀请记录ID等 |
point_product_id |
int | 积分商品ID | 积分商城兑换时使用 |
description |
varchar(255) | 描述信息 | - |
expire_time |
datetime | 过期时间 | 积分过期时间 |
create_time |
datetime | 创建时间 | - |
update_time |
datetime | 更新时间 | - |
deleted |
tinyint | 删除标记 | 0未删除,1已删除 |
设计要点:
remain_point是关键字段,记录该笔积分记录还剩多少可用- 按
expire_time排序实现FIFO扣减 - 支持多种积分来源类型的扩展
3. 积分扣减详情表 (point_reduce_detail)
| 字段名 | 类型 | 说明 | 备注 |
|---|---|---|---|
id |
bigint | 主键ID | 自增 |
reduce_id |
bigint | 扣减记录ID | 指向point_record的消费记录 |
add_id |
bigint | 获得记录ID | 指向point_record的获得记录 |
used_value |
int | 扣减积分值 | 从add_id记录中扣减的积分数 |
expire_time |
datetime | 原积分过期时间 | 冗余存储,方便查询 |
member_id |
int | 用户ID | 冗余存储,提高查询效率 |
deleted |
tinyint | 删除标记 | - |
create_time |
datetime | 创建时间 | - |
update_time |
datetime | 更新时间 | - |
设计要点:
- 记录每次扣减的详细来源,支持积分溯源
- 通过
reduce_id和add_id建立扣减记录与获得记录的关联 - 冗余存储常用查询字段,提高查询性能
核心算法实现
FIFO积分扣减算法
积分扣减遵循先进先出(FIFO)原则,优先扣减即将过期的积分,避免积分浪费。
/**
* 执行积分扣减(FIFO算法 - 优先扣减即将过期的积分)
*
* @param userId 用户ID
* @param totalNeedPoints 需要扣减的总积分
* @return 消费记录
*/
private PointRecord executePointDeduction(Integer userId, int totalNeedPoints) {
// 1. 创建消费记录
PointRecord consumeRecord = new PointRecord();
consumeRecord.setMemberId(userId);
consumeRecord.setPointType(PointRecordTypeEnum.SPEND.getCode());
consumeRecord.setPointAmount(-totalNeedPoints);
consumeRecord.setRemainPoint(0); // 消费记录无剩余积分
consumeRecord.setSourceType("POINT_MALL");
consumeRecord.setDescription("积分商城兑换");
pointRecordService.save(consumeRecord);
// 2. 查询用户可用积分记录(优先按过期时间排序,避免积分过期浪费)
List<PointRecord> availableRecords = pointRecordService.list(
new LambdaQueryWrapper<PointRecord>()
.eq(PointRecord::getMemberId, userId)
.eq(PointRecord::getPointType, PointRecordTypeEnum.EARN.getCode())
.gt(PointRecord::getRemainPoint, 0)
.eq(PointRecord::getDeleted, 0)
.orderByAsc(PointRecord::getExpireTime) // 优先按过期时间升序,即将过期的先扣减
.orderByAsc(PointRecord::getCreateTime)); // 相同过期时间的,按创建时间升序
// 3. FIFO扣减积分(优先扣减即将过期的)
int remainingToDeduct = totalNeedPoints;
List<PointReduceDetail> reduceDetails = new ArrayList<>();
for (PointRecord record : availableRecords) {
if (remainingToDeduct <= 0) {
break;
}
int availableInThisRecord = record.getRemainPoint();
int useFromThisRecord = Math.min(remainingToDeduct, availableInThisRecord);
// 使用乐观锁更新积分记录
boolean updateResult = pointRecordService.lambdaUpdate()
.setSql("remain_point = remain_point - " + useFromThisRecord)
.eq(PointRecord::getId, record.getId())
.ge(PointRecord::getRemainPoint, useFromThisRecord)
.update();
if (!updateResult) {
log.error("积分扣减失败,记录ID: {}, 需要扣减: {}", record.getId(), useFromThisRecord);
throw new ApiException("积分扣减失败,请重试");
}
// 创建扣减详情记录
PointReduceDetail reduceDetail = new PointReduceDetail();
reduceDetail.setReduceId(consumeRecord.getId().longValue());
reduceDetail.setAddId(record.getId().longValue());
reduceDetail.setUsedValue(useFromThisRecord);
reduceDetail.setExpireTime(record.getExpireTime());
reduceDetail.setMemberId(userId);
reduceDetails.add(reduceDetail);
remainingToDeduct -= useFromThisRecord;
log.debug("从积分记录 {} 扣减 {} 积分,过期时间: {}",
record.getId(), useFromThisRecord, record.getExpireTime());
}
// 批量保存扣减详情
if (!reduceDetails.isEmpty()) {
pointReduceDetailService.saveBatch(reduceDetails);
}
if (remainingToDeduct > 0) {
log.error("积分扣减不完整,用户ID: {}, 剩余未扣减: {}", userId, remainingToDeduct);
throw new ApiException("积分扣减异常,请联系客服");
}
log.info("积分扣减完成,用户ID: {}, 总扣减: {}, 涉及记录数: {}",
userId, totalNeedPoints, reduceDetails.size());
return consumeRecord;
}
算法特点
- FIFO原则:按积分过期时间升序排列,优先扣减即将过期的积分
- 乐观锁控制:使用数据库层面的乐观锁防止并发问题
- 详细记录:通过扣减详情表记录每次扣减的来源,支持积分溯源
- 原子性保证:整个扣减过程在事务中执行,保证数据一致性
数据库DDL
用户积分账户表
DROP TABLE IF EXISTS `member_point_account`;
CREATE TABLE `member_point_account` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`member_id` int NOT NULL COMMENT '用户ID',
`total_points` int NOT NULL DEFAULT 0 COMMENT '总积分',
`available_points` int NOT NULL DEFAULT 0 COMMENT '可用积分',
`frozen_points` int NOT NULL DEFAULT 0 COMMENT '冻结积分',
`version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_member_id` (`member_id`) COMMENT '用户ID唯一索引',
KEY `idx_version` (`version`) COMMENT '版本号索引',
KEY `idx_available_points` (`available_points`) COMMENT '可用积分索引',
KEY `idx_create_time` (`create_time`) COMMENT '创建时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户积分账户表';
积分记录表
CREATE TABLE `point_record` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`member_id` int NOT NULL COMMENT '用户ID',
`point_type` varchar(50) NOT NULL COMMENT '积分类型: EARN, SPEND, EXPIRE...',
`point_amount` int NOT NULL COMMENT '积分数量(正数为获得,负数为消费)',
`remain_point` int NOT NULL DEFAULT 0 COMMENT '剩余积分数量(该记录还剩余多少积分可用)',
`source_type` varchar(50) NOT NULL COMMENT '来源类型:REGISTER, FIRST_ORDER, INVITE, POINT_MALL...',
`source_id` int DEFAULT NULL COMMENT '通用来源ID(如订单ID, 邀请记录ID等)',
`point_product_id` int DEFAULT NULL COMMENT '积分商品ID(仅当source_type为POINT_MALL时使用)',
`description` varchar(255) DEFAULT NULL COMMENT '描述',
`expire_time` datetime DEFAULT NULL COMMENT '积分过期时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint NOT NULL DEFAULT 0 COMMENT '删除标记:0-未删除,1-已删除',
PRIMARY KEY (`id`),
KEY `idx_member_remain` (`member_id`, `remain_point`) COMMENT '用户剩余积分联合索引',
KEY `idx_expire_time` (`expire_time`) COMMENT '过期时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='积分记录表';
积分扣减详情表
CREATE TABLE `point_reduce_detail` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`reduce_id` bigint NOT NULL COMMENT '积分表中扣减积分记录id',
`add_id` bigint NOT NULL COMMENT '积分表中新增积分记录id',
`used_value` int NOT NULL COMMENT '扣减的积分值',
`expire_time` datetime DEFAULT NULL COMMENT '原新增积分记录的过期时间',
`member_id` int NOT NULL COMMENT '用户ID',
`deleted` tinyint NOT NULL DEFAULT 0 COMMENT '删除标记:0-未删除,1-已删除',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_reduce_id` (`reduce_id`) COMMENT '扣减记录ID索引',
KEY `idx_add_id` (`add_id`) COMMENT '新增记录ID索引',
KEY `idx_member_id` (`member_id`) COMMENT '用户ID索引',
KEY `idx_expire_time` (`expire_time`) COMMENT '过期时间索引',
KEY `idx_deleted` (`deleted`) COMMENT '删除标记索引',
KEY `idx_create_time` (`create_time`) COMMENT '创建时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='积分扣减详情表';
设计优势
- 数据一致性:三表设计确保积分数据的完整性和一致性
- 高并发支持:乐观锁机制支持高并发场景下的积分操作
- FIFO算法:避免积分过期浪费,提高积分利用率
- 可追溯性:详细记录每次积分变动,支持积分来源追踪
- 扩展性好:支持多种积分来源类型和业务场景扩展
适用场景
- 电商平台的会员积分系统
- 游戏平台的虚拟货币系统
- 企业内部的员工积分激励系统
- 任何需要积分过期管理的业务场景
注意事项
- 需要定期清理过期的积分记录,避免表数据过大
- 在高并发场景下,建议使用分布式锁进一步保证数据安全
- 积分账户表的汇总数据需要定期校验,确保与明细数据一致
- 建议增加积分变动的异步通知机制,便于业务监控