Redis和Lua脚本实现原子操作,加锁

本文探讨如何利用Redis的Setnx命令和Lua脚本来实现原子性的加锁机制,包括设置锁的超时避免死锁,为每个线程分配唯一标识以避免误删,以及使用Lua脚本确保解锁操作的原子性。同时,文章还涉及了Redis Lua脚本的执行原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  1. 使用Setnx命令加锁;
  2. 设置锁的失效时间,防止其他线程长时间无法获取锁;
  3. 加锁时,给每个线程设置唯一标识,防止误删其他线程的锁;
  4. 使用Redis内嵌的Lua脚本释放锁,保证原子性;
  5. 未获取锁的线程循环反复获取锁。
<?php
 
function getRedis()
{
   
   
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379, 60);
    return $redis;
}
 
function lock($key
<think>我们使用RedisLua脚本实现原子积分操作,并开发积分流水对账功能。 积分操作包括:增加积分、扣减积分、冻结积分等。 对账功能:比对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. 异常类型分布
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值