“200块红包发出250块”这个场景看起来像是一个段子但它在分布式系统开发中是一个非常典型且值得警惕的信号你遇到了并发问题而且是最危险的一种——数据一致性被破坏。如果你做过支付、红包、优惠券、秒杀这类和资金相关的系统大概率会有同感单机时代锁住一行记录就能解决的问题到了微服务、高并发场景下会突然变得面目全非。一个接口被连续点了几次或者超时重试被触发结果就可能从“用户抢到红包”变成“公司倒贴钱”。更麻烦的是这种问题在测试环境很难复现一旦上了生产环境往往要等到对账时才会暴露。这篇文章不会教你“如何让红包多发出去”而是反过来从“200块发出250块”这个事故现场出发拆解分布式系统里强一致性Strong Consistency的真实含义。我们会先看清楚“多发钱”背后到底发生了什么再给出对应的解决方案包括数据库事务、分布式锁、幂等设计和最终一致性的兜底策略。文章会提供完整的代码示例和验证方法适合正在做支付、交易、营销类系统的后端开发者阅读也适合准备系统学习分布式理论的读者作为实战参考。1. 红包多发50块的背后是分布式系统最典型的故障先说结论一个红包系统如果出现“200块发出250块”几乎可以断定问题出在“并发控制”和“状态校验”这两个环节而不是金额计算逻辑本身。我们把红包发放的流程拆开看通常包含三个动作从用户账户扣减红包总额。生成红包记录状态为“待领取”或“进行中”。将红包ID返回给前端用户开始分享和领取。这三个动作如果在同一个数据库事务里执行并且数据库隔离级别设置合理那么正常情况下不会出问题。但现实中的红包系统往往是微服务架构账户服务、红包服务、用户服务各自独立部署数据库也可能做了读写分离。这时候“200块红包发出250块”就有几种典型的产生路径。最经典的路径是并发重复提交。用户点击“发红包”按钮因为网络延迟或者前端没有做防抖同一个请求被发送了两次。服务端如果没有做幂等处理两个请求各自扣减了同一个账户的200元生成了两个红包账户被扣了400元其中一个红包发不出去最终对账时发现金额不平。另一种路径是状态校验失效。发红包之前服务端会检查用户余额是否足够。在单机场景下这个检查加扣款是一个原子操作。但在分布式场景下如果先查询余额再执行扣款中间隔了一次网络调用或线程切换两个并发请求可以同时读到“余额充足”然后同时完成扣款最终扣减金额超过用户实际余额。这就是典型的“检查再操作”Check-Then-Act竞态条件。还有一种路径发生在超时重试场景。上游服务调用红包服务时因为网络抖动导致超时上游触发了重试机制。第一个请求其实已经成功扣款并生成了红包但响应没有返回给上游。重试请求再次执行扣款再次生成红包最终一个红包变成了两个。从这些路径里可以提炼出一个共同点问题不在某一台服务器的计算能力而在多个请求、多个服务实例之间的协作顺序没有被严格控制。这正是分布式系统领域研究一致性的原因。1.1 容易混淆的概念强一致性、最终一致性与线性一致性在继续之前先把几个术语说清楚。很多读者容易把“强一致性”和“事务”混为一谈实际上它们是两个维度的问题。事务Transaction是数据库执行操作的逻辑单元它通过ACID属性保证单机或单数据库内的一组操作要么全部成功要么全部失败。事务解决的是单点写入的一致性问题。强一致性指的是在分布式系统中任何一次写操作完成后所有后续的读操作都能立即读到这个新值。也就是说用户发了200块红包不管请求打到哪个服务实例、哪台数据库节点读到的都是“这个红包已创建余额已扣减”的状态不会出现“一部分节点看到扣款成功另一部分节点看到还没扣款”的情况。最终一致性Eventual Consistency则允许系统在一段时间内存在短暂的不一致比如“红包已创建”和“账户已扣款”两个数据源暂时不同步但系统通过异步消息、对账任务等方式在一段时间后自动达到一致状态。线性一致性Linearizability是强一致性的一种实现模型它要求所有操作看起来像是按照某个全局时间顺序执行的。对于需要保证资金安全的场景线性一致性通常是目标。从实践经验来看在红包系统中并不是所有环节都需要强一致性。比如“用户头像”“红包祝福语”这类非关键数据即使短暂不一致也不会造成资金风险但“账户余额”“红包状态”“领取金额”这些数据必须保证强一致或者至少通过幂等和补偿机制达到最终一致后不能出现资金差错。1.2 为什么要关注强一致性CAP理论的现实约束说到分布式系统一致性绕不开CAP理论。CConsistency指强一致性AAvailability指可用性PPartition Tolerance指分区容错性。CAP理论指出在分布式系统中网络分区不可避免当分区发生时系统必须在一致性和可用性之间做取舍。在红包场景中如果为了保证强一致性而拒绝所有写入用户就会看到“系统繁忙请稍后重试”这是可用性受损如果为了保证可用性让所有节点继续接受写入就可能出现“200块发出250块”这种资金差错这是一致性受损。实际工程中多数互联网业务不会选择极端方案而是采用“Base理论”的思路基本可用Basically Available、软状态Soft State、最终一致Eventually Consistent。但在账户扣款、红包状态变更这类关键路径上仍然要使用强一致性的手段如分布式事务、分布式锁否则资金风险无法控制。这就是为什么“200块红包发出250块”这个看似段子的场景实际上指向了一个严肃的架构问题如何在保证用户体验可用性的前提下不让资金数据出错一致性。2. 红包系统的核心概念与一致性需求在进入代码之前我们先定义一下这个红包系统的关键实体和一致性边界。这样后面写代码和配置时读者能清楚知道每一行代码在保护什么。2.1 核心实体定义一个最小可跑通的红包系统至少需要以下三个实体用户账户UserAccount记录用户的可用余额。红包订单RedPacketOrder记录一次发红包的行为包括总金额、剩余金额、状态。红包领取记录RedPacketDetail记录每个用户领取的金额和时间。这三个实体之间存在明确的资金流转关系。发红包时用户账户余额减少红包订单金额增加领红包时红包订单剩余金额减少领取人的账户余额增加。每一笔金额变动都要有据可查。2.2 一致性需求拆解从业务角度我们可以定义出红包系统的四条一致性规则用户余额不能被扣成负数余额足够才允许发红包。一个红包的总发放金额不能超过红包总金额也就是“200块不能发出250块”。同一个用户对同一个红包不能重复领取。用户的发红包请求不能因为超时重试而重复扣款幂等性。这四条规则每一条都对应一类分布式系统的经典问题第一条对应并发扣款和余额校验第二条对应总额控制和并发领取第三条对应幂等和防重第四条对应接口幂等设计。后面我们会用代码逐个实现这些规则并用并发测试来验证。2.3 红包系统为什么容易出问题并发写入和事务边界很多单体应用开发者在转向分布式系统时最容易犯的一个错误是把原本依靠数据库行锁、外键约束、唯一索引来保证的规则全部交给了应用层逻辑但应用层又没有做好并发控制。比如在单体时代账户余额扣减可以简单写成UPDATE account SET balance balance - 200 WHERE id ? AND balance 200。这一条SQL天然保证了“余额不能为负数”。但到了分布式系统如果账户服务、红包服务分开部署服务之间通过RPC通信这条SQL的执行现场和红包订单的创建现场不在同一个事务里原有的保护就失效了。此时如果只在应用层这样写if (account.balance amount) { // 执行扣款和创建红包 }这段代码在高并发下根本无法保证数据一致。因为多个线程可以同时进入if判断并且都读到余额为200元然后各自执行扣款。最终余额变成负数红包却已经创建成功。所以理解红包系统的第一步是要理解“事务边界”这个概念。一个操作应该被包含在哪个事务里这个事务跨越了几个数据源如果跨数据源如何保证要么全部成功、要么全部回滚这些问题的答案直接决定了系统会不会出现“200块发出250块”的事故。3. 环境准备与前置条件本文的示例代码使用Spring Boot MyBatis-Plus MySQL通过一个简化的红包服务演示并发控制和一致性保障。版本信息以实际项目为准本文重点演示通用思路。3.1 开发环境JDK 1.8 或更高版本。Maven 3.6 或更高版本。MySQL 5.7 或更高版本推荐8.0方便使用窗口函数和CTE。IDEA 或其他IDE非必须命令行也可以编译运行。Postman 或 curl用于接口测试。JMeter 或自写并发脚本用于模拟并发请求。3.2 项目结构redpacket-demo ├── pom.xml ├── src │ └── main │ ├── java │ │ └── com │ │ └── example │ │ └── redpacket │ │ ├── RedPacketApplication.java │ │ ├── controller │ │ │ └── RedPacketController.java │ │ ├── service │ │ │ ├── RedPacketService.java │ │ │ └── impl │ │ │ └── RedPacketServiceImpl.java │ │ ├── entity │ │ │ ├── UserAccount.java │ │ │ ├── RedPacketOrder.java │ │ │ └── RedPacketDetail.java │ │ └── mapper │ │ ├── UserAccountMapper.java │ │ ├── RedPacketOrderMapper.java │ │ └── RedPacketDetailMapper.java │ └── resources │ ├── application.yml │ └── schema.sql3.3 数据库表结构先创建三张表-- 文件路径src/main/resources/schema.sql CREATE TABLE IF NOT EXISTS user_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL UNIQUE, balance DECIMAL(10, 2) NOT NULL DEFAULT 0.00, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE IF NOT EXISTS red_packet_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, packet_id VARCHAR(64) NOT NULL UNIQUE, user_id VARCHAR(64) NOT NULL, total_amount DECIMAL(10, 2) NOT NULL, remaining_amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-进行中 1-已抢完 2-已过期, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE IF NOT EXISTS red_packet_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, packet_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, amount DECIMAL(10, 2) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_packet_user (packet_id, user_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;注意这里的几个关键设计user_account表增加了version字段用于乐观锁控制。red_packet_order表的packet_id字段设置了唯一索引用于幂等控制。red_packet_detail表设置了联合唯一索引uk_packet_user用于防止重复领取。这三个设计点分别对应我们前面提到的一致性问题。3.4 Maven 依赖!-- 文件路径pom.xml -- dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies4. 核心流程拆解从一个未加并发控制的实现开始很多事故并不是在复杂的高并发场景下发生的而是从一个“看起来没问题”的简单实现开始的。我们先写一个完全没有并发控制的版本然后用并发测试暴露问题再逐步加保护。这个顺序非常重要因为它能帮助你理解每一层防护到底解决了什么问题。4.1 第一步发起红包没有任何并发控制这是最原始的实现// 文件路径src/main/java/com/example/redpacket/service/impl/RedPacketServiceImpl.java Service public class RedPacketServiceImpl implements RedPacketService { Resource private UserAccountMapper userAccountMapper; Resource private RedPacketOrderMapper redPacketOrderMapper; Override Transactional public String createRedPacket(String userId, BigDecimal amount) { // 第一步查询用户余额 UserAccount account userAccountMapper.selectByUserId(userId); if (account null) { throw new RuntimeException(用户不存在); } if (account.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足); } // 第二步扣减余额 userAccountMapper.deductBalance(userId, amount); // 第三步创建红包订单 String packetId UUID.randomUUID().toString().replace(-, ); RedPacketOrder order new RedPacketOrder(); order.setPacketId(packetId); order.setUserId(userId); order.setTotalAmount(amount); order.setRemainingAmount(amount); order.setStatus(0); redPacketOrderMapper.insert(order); return packetId; } }对应Mapper中的扣款SQL!-- 文件路径src/main/resources/mapper/UserAccountMapper.xml -- update iddeductBalance UPDATE user_account SET balance balance - #{amount} WHERE user_id #{userId} /update这段代码在单线程下工作得很好。用户余额足够扣款成功红包创建成功。但在并发场景下问题立刻暴露。4.2 第二步并发测试验证故障我们模拟20个并发请求每个请求都是“同一个用户发200块红包账户余额只有200块”。测试步骤初始化用户数据INSERT INTO user_account (user_id, balance) VALUES (u001, 200.00);使用JMeter或自写多线程脚本并发调用POST /api/redpacket/create?userIdu001amount20020次。查看数据库中的红包订单数量和用户余额。预期结果是只有1个请求成功因为余额只有200只够发一次19个请求返回“余额不足”。用户余额变为0。实际结果很可能是多个请求同时成功红包订单有多条用户余额变成负数。如果每个红包金额是250元而账户余额只有200元就可能出现“200块账户余额发出多个红包”的事故。4.3 第三步问题分析这个故障的根源是并发竞态条件。多个线程同时执行了selectByUserId并在余额检查通过后还没有执行扣款之前其他线程也完成了余额检查。最终每个线程都认为“余额足够”于是都执行了扣款。更关键的是deductBalance这个SQL本身没有余额校验条件。即使账户余额只有0元执行balance balance - 200也会把余额更新为负数。这在资金系统中是不可接受的。一句话总结问题出在两个地方一个是余额检查与扣款不是原子操作另一个是扣款SQL没有防止余额为负。5. 解决方案一数据库层面的原子操作与行级锁理解了故障原因第一个修复思路就自然浮现把余额检查和扣款合并为一条SQL利用数据库的行级锁保证原子性。5.1 条件更新让扣款SQL自己判断余额是否足够修改Mapper中的扣款SQL!-- 文件路径src/main/resources/mapper/UserAccountMapper.xml -- update iddeductBalanceWithCheck UPDATE user_account SET balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount} /update然后在Service中调整逻辑Override Transactional public String createRedPacket(String userId, BigDecimal amount) { // 第一步原子扣款余额不足时影响行数为0 int rows userAccountMapper.deductBalanceWithCheck(userId, amount); if (rows 0) { throw new RuntimeException(余额不足); } // 第二步创建红包订单 String packetId UUID.randomUUID().toString().replace(-, ); RedPacketOrder order new RedPacketOrder(); order.setPacketId(packetId); order.setUserId(userId); order.setTotalAmount(amount); order.setRemainingAmount(amount); order.setStatus(0); redPacketOrderMapper.insert(order); return packetId; }这个方案的关键在于WHERE子句中的balance #{amount}。MySQL在执行UPDATE时会对匹配的行加锁两个并发事务同时执行这条SQL时后执行的事务会等待前一个事务提交或回滚。前一个事务如果扣款成功后一个事务重新执行条件判断发现余额不足影响行数为0直接报错。这种方案简单高效也是目前很多真实系统的基础做法。5.2 为什么不用SELECT FOR UPDATE有的读者可能会问用SELECT ... FOR UPDATE先锁行再检查余额再更新不是也可以吗可以但不如条件更新简洁。SELECT ... FOR UPDATE需要在应用层显式获取锁最后还要释放锁提交或回滚事务代码链路更长而且要求使用方对事务边界有严格的把控。一旦事务内出现慢查询或远程调用锁被持有的时间就会很长数据库连接池容易被占满。条件更新的缺点是不能在更新前读取账户的其他字段但如果只是做余额扣减这个缺点不影响业务。5.3 数据库方案能完全解决问题吗数据库方案解决了“余额不能为负”和“扣款与检查的原子性”问题。但它没有解决“重复请求”的问题。什么意思假设用户账户余额是400元可以发两个200块的红包也可以发一个200块的红包。但网络超时导致用户点击发送时实际上第一个请求已经成功了只是响应没有返回。用户又点了一次系统收到第二个请求检查余额为200元因为第一个请求已经扣了200仍然足够于是又创建了一个红包。用户最终发出去两个红包但用户只想要一个。这种情况就需要幂等设计来解决。6. 解决方案二幂等控制防止重复扣款和重复创建幂等Idempotency是分布式系统中最基础也最重要的设计原则之一。它的含义是同样的请求执行一次和执行多次产生的结果是一样的。在红包场景中幂等控制要解决两个问题同一个发红包请求被重复执行时不应该重复扣款。同一个红包不应该被同一个人重复领取。6.1 为发红包接口增加幂等键常见做法是客户端生成一个请求ID也可以由服务端生成在服务端收到请求时先检查这个请求ID是否已经处理过。如果处理过直接返回第一次处理的结果不再执行扣款和创建红包。实现方式有很多种最简单可靠的是利用数据库唯一索引。在red_packet_order表中增加一个biz_id字段用于存储业务幂等键。ALTER TABLE red_packet_order ADD COLUMN biz_id VARCHAR(64) NOT NULL DEFAULT AFTER user_id, ADD UNIQUE KEY uk_biz_id (biz_id);Service代码调整为Override public String createRedPacket(String userId, BigDecimal amount, String bizId) { // 尝试插入红包订单如果biz_id重复则说明请求已处理过 String packetId UUID.randomUUID().toString().replace(-, ); RedPacketOrder order new RedPacketOrder(); order.setPacketId(packetId); order.setBizId(bizId); order.setUserId(userId); order.setTotalAmount(amount); order.setRemainingAmount(amount); order.setStatus(0); try { redPacketOrderMapper.insert(order); } catch (DuplicateKeyException e) { // 已处理过直接查询原订单并返回 RedPacketOrder existOrder redPacketOrderMapper.selectByBizId(bizId); return existOrder.getPacketId(); } // 扣款 int rows userAccountMapper.deductBalanceWithCheck(userId, amount); if (rows 0) { // 扣款失败将订单状态改为失败避免脏数据 redPacketOrderMapper.updateStatus(packetId, 2); throw new RuntimeException(余额不足); } return packetId; }这个思路的核心是“先插入订单再扣款”。如果请求是重复的插入订单时会因为biz_id唯一索引而失败代码捕获DuplicateKeyException后直接返回已有订单不会再次扣款。但这种方式有一个副作用如果扣款成功之前进程崩溃会留下一个没有完成扣款的订单。因此需要在订单表中增加一个状态字段并且提供定时对账任务去补偿这类“中间状态”订单。更稳妥的做法是使用分布式事务但引入分布式事务框架如Seata会大大增加系统复杂度。在很多实际项目中团队会更倾向于“事件表 对账”的方案先记录事件异步扣款最终通过事件状态机保证一致。6.2 防重复领取联合唯一索引防重复领取的实现比防重复发红包更简单。我们已经在red_packet_detail表上建立了uk_packet_user (packet_id, user_id)联合唯一索引。在领取接口中直接插入领取记录如果插入失败说明用户已经领取过。Override Transactional public BigDecimal receiveRedPacket(String packetId, String userId) { // 校验红包状态 RedPacketOrder order redPacketOrderMapper.selectByPacketId(packetId); if (order null) { throw new RuntimeException(红包不存在); } if (order.getStatus() ! 0) { throw new RuntimeException(红包已不可领取); } // 模拟金额这里为了演示简化固定领取1元真实场景要有随机算法 BigDecimal amount new BigDecimal(1.00); // 插入领取记录 RedPacketDetail detail new RedPacketDetail(); detail.setPacketId(packetId); detail.setUserId(userId); detail.setAmount(amount); try { redPacketDetailMapper.insert(detail); } catch (DuplicateKeyException e) { throw new RuntimeException(您已领取过该红包请勿重复领取); } // 扣减红包剩余金额 int rows redPacketOrderMapper.deductRemainingAmount(packetId, amount); if (rows 0) { throw new RuntimeException(红包已被抢完); } // 累加用户余额 userAccountMapper.increaseBalance(userId, amount); return amount; }联合唯一索引在这里起到了“数据库层防重”的作用比应用层通过查询判断更可靠。因为即时应用层做了“查一下是否已经领取”的判断两个并发请求仍然可能在查询结束后同时执行插入操作。唯一索引则会在数据库层直接拒绝第二个插入。7. 解决方案三分布式锁与乐观锁的适用边界前面的方案都停留在“单库”的假设下。如果红包服务和账户服务使用独立的数据库上面的SQL无法跨库执行就需要引入分布式锁或分布式事务。7.1 分布式锁分布式锁的思路是在操作关键资源之前先获取一把全局唯一的锁拿到锁的节点才能执行操作操作完成后再释放锁。Redis是常用的分布式锁实现方式。使用SETNX命令加锁使用Lua脚本保证判断和删除的原子性。// 文件路径src/main/java/com/example/redpacket/common/RedisLock.java Component public class RedisLock { Resource private StringRedisTemplate stringRedisTemplate; private static final String LOCK_PREFIX lock:redpacket:; private static final Duration LOCK_EXPIRE Duration.ofSeconds(30); public boolean tryLock(String key, String requestId) { Boolean success stringRedisTemplate.opsForValue() .setIfAbsent(LOCK_PREFIX key, requestId, LOCK_EXPIRE); return Boolean.TRUE.equals(success); } public void unlock(String key, String requestId) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; stringRedisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(LOCK_PREFIX key), requestId); } }使用分布式锁之后可以保证同一时刻只有一个请求在处理同一个用户的发红包操作public String createRedPacketWithLock(String userId, BigDecimal amount, String bizId) { String lockKey userId; String requestId UUID.randomUUID().toString(); boolean locked redisLock.tryLock(lockKey, requestId); if (!locked) { throw new RuntimeException(系统繁忙请稍后重试); } try { // 执行业务逻辑 return createRedPacket(userId, amount, bizId); } finally { redisLock.unlock(lockKey, requestId); } }用分布式锁有一个必须注意的坑锁的粒度。如果锁的粒度是整个用户那么同一个用户并发发两个不同金额的红包时第二个请求会被阻塞。这在业务上可能导致用户体验下降。如果锁的粒度是“用户红包类型有效期”链路会更复杂但并发度更高。从经验来看分布式锁适合“需要短时间互斥操作”的场景不适合替代数据库层的一致性约束。换句话说分布式锁是第一道闸门数据库条件更新是底线防线两者配合使用而不是二选一。7.2 乐观锁乐观锁适合冲突率比较低的场景。它的做法是在数据表中增加version字段更新时检查版本号是否匹配匹配才更新。// 文件路径src/main/java/com/example/redpacket/service/impl/RedPacketServiceImpl.java Update(UPDATE user_account SET balance balance - #{amount}, version version 1 WHERE user_id #{userId} AND version #{version}) int deductBalanceWithVersion(Param(userId) String userId, Param(amount) BigDecimal amount, Param(version) Integer version);乐观锁的问题在于当并发冲突严重时大量请求会因为版本号不匹配而失败用户需要不断重试。对于红包这种高并发场景乐观锁通常不如条件更新高效因此不是首选方案。7.3 分布式事务与最终一致性兜底如果红包服务和账户服务完全独立数据库层面的条件更新无法跨库生效此时需要引入分布式事务。常见的方案包括XA协议两阶段提交强一致但性能开销大不适合高并发。TCCTry-Confirm-Cancel业务侵入性强适合需要严格控制的场景。本地消息表 消息队列最终一致适合大多数互联网业务。事务消息RocketMQ等将本地事务和消息发送绑定实现最终一致。对于红包系统很多团队会采用最终一致方案发红包时先记录事件异步扣款通过消息队列保证账户服务最终收到扣款请求。如果扣款失败通过定时对账发现并补偿。但要注意最终一致不等于“可以出错”。最终一致系统出了差错之后必须有额外的对账和补偿机制来修正错误。而对账机制的实现又依赖于系统的每一笔操作都有完整的流水记录。8. 完整示例一个含幂等与并发控制的发红包接口把前面几种方案组合起来我们可以得到一个相对完整的发红包实现。8.1 完整代码// 文件路径src/main/java/com/example/redpacket/service/impl/RedPacketServiceImpl.java Service public class RedPacketServiceImpl implements RedPacketService { Resource private UserAccountMapper userAccountMapper; Resource private RedPacketOrderMapper redPacketOrderMapper; Resource private RedisLock redisLock; Override public String createRedPacket(String userId, BigDecimal amount, String bizId) { String lockKey user: userId; String requestId UUID.randomUUID().toString(); boolean locked redisLock.tryLock(lockKey, requestId); if (!locked) { throw new RuntimeException(系统繁忙请稍后重试); } try { return doCreateRedPacket(userId, amount, bizId); } finally { redisLock.unlock(lockKey, requestId); } } Transactional(rollbackFor Exception.class) public String doCreateRedPacket(String userId, BigDecimal amount, String bizId) { // 1. 幂等检查通过唯一索引防止重复创建 String packetId UUID.randomUUID().toString().replace(-, ); RedPacketOrder order new RedPacketOrder(); order.setPacketId(packetId); order.setBizId(bizId); order.setUserId(userId); order.setTotalAmount(amount); order.setRemainingAmount(amount); order.setStatus(0); try { redPacketOrderMapper.insert(order); } catch (DuplicateKeyException e) { // 重复请求返回已有红包 RedPacketOrder existOrder redPacketOrderMapper.selectByBizId(bizId); return existOrder.getPacketId(); } // 2. 原子扣款防止余额为负 int rows userAccountMapper.deductBalanceWithCheck(userId, amount); if (rows 0) { // 扣款失败将本地订单标记为失败事务最终回滚 throw new RuntimeException(余额不足); } return packetId; } }注意这里使用了Transactional但Redis的锁操作不在数据库事务中。因为Redis锁的生命周期应该尽量短而数据库事务可能在等待锁数据库行锁时持续较长时间。如果把Redis锁的释放放在事务提交之后逻辑会更严谨。这里简化处理主要是为了演示一致性控制的层次结构。还有一点很重要当deductBalanceWithCheck返回0时代码抛出异常事务回滚之前插入的red_packet_order记录会被回滚不会留下脏数据。这比“先插入下单再更新状态”的方式更干净。8.2 Controller 层代码// 文件路径src/main/java/com/example/redpacket/controller/RedPacketController.java RestController RequestMapping(/api/redpacket) public class RedPacketController { Resource private RedPacketService redPacketService; PostMapping(/create) public MapString, Object create(RequestParam String userId, RequestParam BigDecimal amount, RequestParam String bizId) { String packetId redPacketService.createRedPacket(userId, amount, bizId); MapString, Object result new HashMap(); result.put(code, 0); result.put(packetId, packetId); result.put(message, success); return result; } PostMapping(/receive) public MapString, Object receive(RequestParam String packetId, RequestParam String userId) { BigDecimal amount redPacketService.receiveRedPacket(packetId, userId); MapString, Object result new HashMap(); result.put(code, 0); result.put(amount, amount); result.put(message, success); return result; } }9. 运行结果与效果验证9.1 启动项目并初始化数据先执行schema.sql完成建表再插入测试数据INSERT INTO user_account (user_id, balance) VALUES (u001, 200.00); INSERT INTO user_account (user_id, balance) VALUES (u002, 500.00);然后启动Spring Boot应用mvn spring-boot:run9.2 并发测试发红包接口使用一个简单的Java并发程序模拟20个并发请求全部使用同一个bizId验证幂等控制// 文件路径src/test/java/com/example/redpacket/ConcurrentTest.java Test public void testConcurrentCreate() throws InterruptedException { int threadCount 20; CountDownLatch latch new CountDownLatch(threadCount); ExecutorService executor Executors.newFixedThreadPool(threadCount); String bizId B202502130001; for (int i 0; i threadCount; i) { executor.submit(() - { try { // 调用接口 RestTemplate restTemplate new RestTemplate(); String url http://localhost:8080/api/redpacket/create?userIdu001amount200bizId bizId; String response restTemplate.postForObject(url, null, String.class); System.out.println(response); } finally { latch.countDown(); } }); } latch.await(); executor.shutdown(); }运行结束后查询数据库SELECT COUNT(*) FROM red_packet_order WHERE biz_id B202502130001;预期结果是1而不是20。这证明了幂等控制生效。接着验证余额正确性SELECT user_id, balance FROM user_account WHERE user_id u001;预期余额是0.00如果出现负数或者余额大于0但订单为多条说明还有一致性问题。如果你用不同的bizId并发执行20次请求同时账户余额只有200元预期结果是只有1个请求成功其余请求抛出“余额不足”或“系统繁忙”。这证明了并发扣款的原子性和分布式锁的互斥性。9.3 验证防重复领取初始化一个余额为200元的红包让同一个用户并发领取10次计算该红包的领取记录条数和用户余额增加总额。SELECT COUNT(*) FROM red_packet_detail WHERE packet_id xxx; SELECT balance FROM user_account WHERE user_id u002;预期结果领取记录数为1余额增加金额为1次领取的金额。如果出现多条领取记录说明防重复领取没有生效。9.4 判断成功与失败的参考标准验证目标预期结果失败特征幂等控制同一bizId只创建1个红包订单同一bizId出现多条订单余额不为负用户余额始终大于等于0出现负数余额并发扣款多个不同请求只成功扣款一次多个请求都扣款成功防重复领取同一用户同一红包只有1条领取记录出现多条领取记录订单与账户一致红包总金额等于用户扣款总额两边金额不一致如果并发测试出现失败第一步应该看数据库中的红包订单数量和用户余额第二步看应用日志中是否有DuplicateKeyException或余额不足的报错第三步确认Redis服务是否正常运行。10. 常见问题与排查思路在实际项目中即使代码看起来正确也可能会遇到各种诡异问题。下面整理了几个高频故障现象和排查方向。问题现象可能原因排查方式解决方案并发测试时出现多个红包订单bizId没有传递到服务端或唯一索引未生成查看应用日志确认每次请求的bizId用SQL查询订单表检查接口调用是否传了bizId确认数据库表结构已加唯一索引扣款后余额为负数扣款SQL没有加balance amount条件查看SQL日志确认执行的SQL改用条件更新SQL分布式锁未生效Redis未启动或tryLock异常被忽略查看日志中是否有Redis连接异常检查Redis连接配置增加降级策略事务回滚后订单记录仍然存在事务未正确开启或MyBatis自动提交检查Service方法上是否有 Transactional并确认事务管理器配置在方法上添加 Transactional避免类内部方法自调用同一用户重复领取成功红包详情表未建联合唯一索引查询表结构确认索引是否存在增加UNIQUE KEY uk_packet_user (packet_id, user_id)锁超时导致业务中断业务执行时间超过Redis锁过期时间增大锁过期时间或在业务结束后主动释放锁合理设置锁过期时间建议使用看门狗续期机制数据库死锁多个事务以不同顺序更新同一批记录查看死锁日志统一更新顺序保持一致的加锁顺序在排查这些问题时有一个通用原则先看数据库锁和事务再看应用层逻辑最后才考虑网络和中间件。因为大多数一致性问题根源都在“谁先更新、谁后更新、谁读到旧值”这个层面。11. 最佳实践与工程建议回到文章开头的问题200块的红包为什么能发出250块答案不再神秘——是并发控制和幂等设计出了问题。下面给出几条工程建议帮助你把这类问题挡在开发阶段。11.1 资金操作必须使用条件更新在涉及余额扣减、库存扣减、红包金额减少这类操作时不要在应用层先查询再判断而是把判断条件放在SQL的WHERE子句中。这不仅是性能优化更是数据安全的基本要求。推荐写法UPDATE user_account SET balance balance - #{amount} WHERE user_id #{userId} AND balance #{amount}不推荐写法UserAccount account userAccountMapper.selectByUserId(userId); if (account.getBalance().compareTo(amount) 0) { userAccountMapper.deductBalance(userId, amount); }11.2 幂等设计要形成规范所有对资金、订单、状态有影响的写接口都应该支持幂等。建议在接口定义阶段就明确幂等键的传递规则比如在HTTP Header中增加Idempotency-Key或者要求客户端在请求体中传bizId。幂等键生成规则建议使用“业务类型 用户ID 时间戳 随机数”的组合确保全局唯一。服务端通过唯一索引或Redis SETNX判断是否处理过。11.3 事务内不要做远程调用如果一个数据库事务里夹杂着远程RPC、HTTP调用、消息发送事务的持有时间会大大增加数据库连接池很可能被耗尽同时也会放大分布式锁的冲突范围。正确的做法是把远程调用放在事务提交之后使用事务同步事件或消息队列解耦。11.4 对账是最后一根救命稻草无论你的并发控制做得多完善都应该建立对账机制。对账可以是每日定时任务也可以是在每次业务操作后异步比对相关数据。以红包系统为例至少要对账两类数据用户扣款总额是否等于红包订单总额。红包订单发放金额是否等于所有领取记录之和。对账发现差异后要有自动或半自动的补偿流程。在真实生产环境中对账往往比并发控制更能发现潜在问题因为有些问题只在极其特殊的时序下才会触发靠人工测试几乎复现不出来。11.5 从源头减少不一致发生的概率有些一致性问题完全可以通过产品设计规避。比如发红包按钮在点击后立即置灰并显示“处理中”减少用户重复点击再比如接口层做简单的请求频率控制一个用户一秒内最多只能发起一次发红包请求。这些手段虽然简单但在高流量场景下效果非常明显。11.6 安全和权限边界本文中的示例仅用于技术演示所有接口都没有做权限校验。在生产环境中发红包、领红包接口需要严格的登录认证、权限校验、金额校验和风控策略。数据库操作账号也应该遵循最小权限原则应用账号不应该拥有DROP TABLE等危险权限。任何涉及资金变动的接口修改都必须经过测试环境验证、代码评审和灰度发布流程。12. 总结与后续学习方向这次通过“200块红包发出250块”的故障场景我们把分布式系统一致性问题从现象到原理、从代码到工程实践完整走了一遍。核心要点可以浓缩为几条第一从根本上说这类故障来自并发竞态条件而解决竞态条件的关键在于把“检查-操作”合并为一个原子动作。 第二数据库条件更新是资金类系统最基本、最可靠的防线它利用的是数据库的行锁机制不需要引入额外组件。 第三幂等设计是防止重复扣款、重复创建订单的关键唯一索引和Redis分布式锁是两种常见实现手段。 第四分布式锁、分布式事务、乐观锁各有适用场景选择时要考虑并发度、性能要求和业务容忍度。 第五对账与补偿机制是分布式系统一致性的最后一道防线绝不能省略。如果你对这篇文章中的概念还想深入可以往以下方向继续学习MySQL的锁机制与隔离级别搞清楚行锁、间隙锁、MVCC在并发控制中的角色。Seata等分布式事务框架的AT模式、TCC模式实现原理。Redis分布式锁的Redisson看门狗机制与RedLock算法争议。消息队列的事务消息实现以及本地消息表的设计思路。金融级系统的对账系统设计包括流水表、差额表、差错处理流程。建议你动手做一个小实验把文中“没有任何并发控制的版本”部署到本地用JMeter压一下亲眼看看余额是怎么变成负数的。然后再一步步加上条件更新、幂等控制、分布式锁重新压测对比结果。这种“先看事故再修事故”的方式比直接读理论记得更牢。如果文章对你有帮助建议收藏备用。也欢迎在评论区留言交流你在实际项目中遇到过哪些“金额对不上”的诡异问题后来是怎么定位和解决的
