积分系统设计 - 带过期时间的FIFO积分扣减方案

 

设计背景

积分系统是电商、会员系统中的常见功能模块。本文记录了一个支持积分过期时间的积分系统设计方案。

设计演进过程

最初的设计思路比较简单:

  • 用户积分账户表:记录用户的总积分、可用积分等汇总信息
  • 用户积分记录表:记录每笔积分的获得和消费明细

但在加入积分过期时间需求后,发现这种设计存在问题:

  • 每笔积分的过期时间不同
  • 扣减积分时需要遵循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_idadd_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;
}

算法特点

  1. FIFO原则:按积分过期时间升序排列,优先扣减即将过期的积分
  2. 乐观锁控制:使用数据库层面的乐观锁防止并发问题
  3. 详细记录:通过扣减详情表记录每次扣减的来源,支持积分溯源
  4. 原子性保证:整个扣减过程在事务中执行,保证数据一致性

数据库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='积分扣减详情表';

设计优势

  1. 数据一致性:三表设计确保积分数据的完整性和一致性
  2. 高并发支持:乐观锁机制支持高并发场景下的积分操作
  3. FIFO算法:避免积分过期浪费,提高积分利用率
  4. 可追溯性:详细记录每次积分变动,支持积分来源追踪
  5. 扩展性好:支持多种积分来源类型和业务场景扩展

适用场景

  • 电商平台的会员积分系统
  • 游戏平台的虚拟货币系统
  • 企业内部的员工积分激励系统
  • 任何需要积分过期管理的业务场景

注意事项

  1. 需要定期清理过期的积分记录,避免表数据过大
  2. 在高并发场景下,建议使用分布式锁进一步保证数据安全
  3. 积分账户表的汇总数据需要定期校验,确保与明细数据一致
  4. 建议增加积分变动的异步通知机制,便于业务监控