<think>我们使用Redis的Lua脚本实现原子积分操作,并开发积分流水对账功能。
积分操作包括:增加积分、扣减积分、冻结积分等。
对账功能:比对Redis中的积分流水与MySQL中的积分流水,确保数据一致性。
步骤:
1. 设计Redis数据结构
2. 编写Lua脚本实现原子操作
3. 设计积分流水表
4. 实现定时对账任务
一、Redis数据结构设计
- 用户积分(Hash):key为`user:points:{userId}`,field为`available`(可用积分)和`frozen`(冻结积分)
- 积分流水(Sorted Set):key为`points:tx:log`,member为事务ID,score为时间戳(用于排序)
二、Lua脚本示例(原子增加积分)
```lua
-- 参数:用户ID,增加积分值,事务ID
local userId = KEYS[1]
local points = tonumber(ARGV[1])
local txId = ARGV[2]
-- 用户积分键
local userKey = "user:points:" .. userId
-- 增加可用积分
redis.call('HINCRBY', userKey, 'available', points)
-- 记录流水到Sorted Set(事务ID作为member,当前时间戳作为score)
local ts = redis.call('TIME')[1] -- 获取当前时间戳(秒)
redis.call('ZADD', 'points:tx:log', ts, txId)
-- 返回成功
return 1
```
三、积分流水表设计(MySQL)
```sql
CREATE TABLE points_transaction (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tx_id VARCHAR(64) NOT NULL COMMENT '全局事务ID',
user_id BIGINT NOT NULL,
points INT NOT NULL COMMENT '变动积分(正为增加,负为减少)',
type TINYINT NOT NULL COMMENT '流水类型:1-增加 2-扣减 3-冻结 4-解冻',
before_available INT NOT NULL COMMENT '操作前可用积分',
after_available INT NOT NULL COMMENT '操作后可用积分',
before_frozen INT NOT NULL COMMENT '操作前冻结积分',
after_frozen INT NOT NULL COMMENT '操作后冻结积分',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-成功 2-失败',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_tx_id (tx_id)
);
```
四、对账功能实现
- 定时任务(如每天凌晨)执行对账
- 步骤:
1. 从Redis的Sorted Set中取出前一天的所有流水事务ID(根据时间戳范围)
2. 根据事务ID去MySQL的积分流水表中查询是否存在对应记录
3. 如果不存在,则记录对账异常,并触发补偿流程(如重放事务)
4. 如果存在,则比对积分变动值是否一致
五、Java实现(Spring Boot + RedisTemplate)
```java
// 1. 增加积分的Lua脚本执行
@Service
public class PointsService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String ADD_POINTS_SCRIPT =
"local userId = KEYS[1] " +
"local points = tonumber(ARGV[1]) " +
"local txId = ARGV[2] " +
"local userKey = 'user:points:' .. userId " +
"redis.call('HINCRBY', userKey, 'available', points) " +
"local ts = redis.call('TIME')[1] " +
"redis.call('ZADD', 'points:tx:log', ts, txId) " +
"return 1";
public void addPoints(Long userId, Integer points, String txId) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(ADD_POINTS_SCRIPT, Long.class);
List<String> keys = Collections.singletonList(userId.toString());
redisTemplate.execute(script, keys, points.toString(), txId);
}
}
// 2. 对账服务
@Service
public class ReconciliationService {
@Autowired
private PointsTransactionRepository txRepo; // MySQL流水DAO
@Autowired
private StringRedisTemplate redisTemplate;
@Scheduled(cron = "0 0 0 * * ?") // 每天凌晨执行
public void reconcile() {
// 获取前一天的开始和结束时间戳(秒)
long start = LocalDateTime.now().minusDays(1).withHour(0).withMinute(0).withSecond(0).toEpochSecond(ZoneOffset.UTC);
long end = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0).toEpochSecond(ZoneOffset.UTC) - 1;
// 从Redis获取前一天的所有事务ID
Set<String> txIds = redisTemplate.opsForZSet().rangeByScore("points:tx:log", start, end);
// 遍历每个事务ID
for (String txId : txIds) {
// 检查MySQL中是否存在该事务记录
Optional<PointsTransaction> txOpt = txRepo.findByTxId(txId);
if (!txOpt.isPresent()) {
// 记录对账失败,触发补偿
handleMissingTx(txId);
} else {
// 可选:比对积分变动值(根据业务需要)
PointsTransaction tx = txOpt.get();
// 获取Redis中的操作详情(需要额外记录,这里简化)
// 如果流水表有详细前后值,可以进一步比对
}
}
}
private void handleMissingTx(String txId) {
// 1. 从Redis中获取该事务的原始操作数据(需要额外存储,这里简化)
// 2. 根据操作类型重放事务到MySQL(如插入流水记录)
// 注意:重放需确保幂等性
// 示例:插入流水记录
// PointsTransaction newTx = new PointsTransaction();
// ... 设置属性
// txRepo.save(newTx);
// 记录日志
}
}
```
六、注意事项
1. Redis事务流水存储:实际应用中,除了事务ID,可能还需要存储操作类型、积分变动值等,以便对账时重放。可以考虑使用Hash存储详细流水,key为`points:tx:detail:{txId}`。
2. 对账补偿:补偿操作需要谨慎,避免重复补偿。可以通过事务ID幂等控制。
3. 性能:如果流水量很大,对账时一次性获取所有事务ID可能导致内存溢出,可以分批次处理。
§§相关问题§§
1. 如何设计Redis数据结构以支持按用户查询积分流水?
2. 当Lua脚本执行过程中Redis发生故障,如何保证积分操作的原子性和数据一致性?
3. 在对账过程中发现数据不一致,除了补偿插入缺失流水,是否需要修正用户积分余额?
4. 如何优化对账任务以处理海量积分流水数据(例如每天千万级流水)?
5. 在分布式环境下,如何确保对账任务的唯一执行(避免多个实例同时执行对账)?</think>### Redis+Lua脚本实现原子积分操作与流水对账功能
#### Redis数据结构设计
```bash
# 用户积分总览(Hash)
user:points:{userId} -> {available: 1000, frozen: 200}
# 积分流水(Sorted Set)
points:tx:log -> [
score: timestamp,
member: txId#type#amount#status
]
# 对账异常记录(Hash)
points:reconcile:errors -> {txId: errorDetail}
```
#### Lua脚本实现原子积分操作
```lua
-- 原子积分操作脚本(支持扣减/增加/冻结/解冻)
local key = KEYS[1] -- user:points:{userId}
local txKey = KEYS[2] -- points:tx:log
local txId = ARGV[1] -- 事务ID
local opType = ARGV[2] -- 操作类型:deduct/add/freeze/unfreeze
local amount = tonumber(ARGV[3]) -- 操作数值
-- 获取当前积分
local available = tonumber(redis.call('HGET', key, 'available') or 0)
local frozen = tonumber(redis.call('HGET', key, 'frozen') or 0)
-- 验证操作合法性
if opType == 'deduct' and available < amount then
return {err = 'INSUFFICIENT_POINTS'}
end
if opType == 'freeze' and available < amount then
return {err = 'INSUFFICIENT_AVAILABLE'}
end
if opType == 'unfreeze' and frozen < amount then
return {err = 'INSUFFICIENT_FROZEN'}
end
-- 执行操作
if opType == 'deduct' then
redis.call('HSET', key, 'available', available - amount)
elseif opType == 'add' then
redis.call('HSET', key, 'available', available + amount)
elseif opType == 'freeze' then
redis.call('HSET', key, 'available', available - amount)
redis.call('HSET', key, 'frozen', frozen + amount)
elseif opType == 'unfreeze' then
redis.call('HSET', key, 'available', available + amount)
redis.call('HSET', key, 'frozen', frozen - amount)
end
-- 记录流水(格式:txId|type|amount|status)
local status = 'success'
local logEntry = table.concat({txId, opType, amount, status}, "|")
redis.call('ZADD', txKey, 'NX', redis.call('TIME')[1], logEntry)
return {ok = status}
```
#### Java调用示例(Spring Boot)
```java
@Component
public class PointsService {
@Autowired private RedisTemplate<String, String> redisTemplate;
private static final String LUA_SCRIPT = "local key = KEYS[1]..."; // 完整Lua脚本
public void executePointsOperation(String userId, String txId,
String opType, int amount) {
String userKey = "user:points:" + userId;
String txLogKey = "points:tx:log";
DefaultRedisScript<List> script = new DefaultRedisScript<>(LUA_SCRIPT, List.class);
List<String> keys = Arrays.asList(userKey, txLogKey);
// 执行原子操作
List<Object> result = redisTemplate.execute(script, keys, txId, opType, String.valueOf(amount));
if (result != null && result.get(0) instanceof String &&
((String)result.get(0)).equals("err")) {
throw new PointsException("操作失败: " + result.get(1));
}
}
}
```
### 积分流水对账功能实现
#### 对账流程设计
```mermaid
graph TD
A[启动对账任务] --> B[获取对账时间范围]
B --> C[从Redis导出流水]
B --> D[从MySQL导出流水]
C --> E[数据规范化处理]
D --> E
E --> F[逐条比对]
F -->|一致| G[记录成功]
F -->|不一致| H[记录异常]
H --> I[生成修复任务]
I --> J[执行自动修复]
```
#### 核心对账服务
```java
@Service
public class ReconciliationService {
@Autowired private PointsTxRepository mysqlRepo;
@Autowired private RedisTemplate<String, String> redisTemplate;
@Autowired private TaskExecutor taskExecutor;
// 每日对账任务
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void dailyReconcile() {
LocalDate reconcileDate = LocalDate.now().minusDays(1);
reconcileForDate(reconcileDate);
}
public void reconcileForDate(LocalDate date) {
// 获取时间范围
long startTs = date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond();
long endTs = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toEpochSecond() - 1;
// 获取Redis流水
Set<String> redisLogs = redisTemplate.opsForZSet()
.rangeByScore("points:tx:log", startTs, endTs);
// 获取MySQL流水
List<PointsTx> mysqlTxs = mysqlRepo.findByDateRange(
Timestamp.valueOf(date.atStartOfDay()),
Timestamp.valueOf(date.plusDays(1).atStartOfDay())
);
// 并行比对
Map<String, TxRecord> redisMap = redisLogs.parallelStream()
.map(log -> parseLogEntry(log))
.collect(Collectors.toMap(TxRecord::getTxId, Function.identity()));
Map<String, TxRecord> mysqlMap = mysqlTxs.parallelStream()
.collect(Collectors.toMap(PointsTx::getTxId, this::convertToRecord));
// 比对差异
Set<String> allTxIds = new HashSet<>();
allTxIds.addAll(redisMap.keySet());
allTxIds.addAll(mysqlMap.keySet());
allTxIds.parallelStream().forEach(txId -> {
TxRecord redisRec = redisMap.get(txId);
TxRecord mysqlRec = mysqlMap.get(txId);
if (redisRec == null) {
handleMissingInRedis(txId, mysqlRec);
} else if (mysqlRec == null) {
handleMissingInMySQL(txId, redisRec);
} else if (!redisRec.equals(mysqlRec)) {
handleMismatch(txId, redisRec, mysqlRec);
}
});
}
// 自动修复处理
private void handleMissingInMySQL(String txId, TxRecord redisRec) {
// 1. 插入MySQL记录
PointsTx newTx = new PointsTx();
newTx.setTxId(txId);
newTx.setType(redisRec.getType());
newTx.setAmount(redisRec.getAmount());
newTx.setStatus(redisRec.getStatus());
mysqlRepo.save(newTx);
// 2. 记录异常
redisTemplate.opsForHash().put("points:reconcile:errors", txId,
"MISSING_IN_MYSQL|" + Instant.now().toString());
}
private static class TxRecord {
private String txId;
private String type;
private int amount;
private String status;
// 构造函数/equals/hashCode
}
}
```
#### 对账优化策略
1. **分片处理**:
```java
// 按用户ID分片处理
IntStream.range(0, 32).parallel().forEach(shard -> {
String userKeyPattern = "user:points:" + shard + ":*";
// 扫描该分片下的所有用户
});
```
2. **增量对账**:
```java
// 使用Redis Stream实现实时增量对账
@Bean
public StreamMessageListenerContainer<String, MapRecord<String, String, String>> streamContainer() {
StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, MapRecord<String, String, String>> options =
StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder()
.pollTimeout(Duration.ofSeconds(1))
.build();
StreamMessageListenerContainer<String, MapRecord<String, String, String>> container =
StreamMessageListenerContainer.create(redisConnectionFactory, options);
container.receive(StreamOffset.create("points:tx:stream", ReadOffset.lastConsumed()),
message -> {
// 实时处理流水
reconcileSingleTx(message.getValue());
});
return container;
}
```
3. **对账报告生成**:
```java
public ReconciliationReport generateReport(LocalDate date) {
Map<Object, Object> errors = redisTemplate.opsForHash()
.entries("points:reconcile:errors");
return new ReconciliationReport(
date,
errors.size(),
errors.values().stream()
.collect(Collectors.groupingBy(
e -> ((String)e).split("\\|")[0],
Collectors.counting()
))
);
}
```
#### 监控指标
1. 每日对账总数
2. 数据不一致率
3. 自动修复成功率
4. 对账执行时长
5. 异常类型分布