
最近在开发一个基于Spring Boot的在线音乐平台时遇到了一个非常典型的分布式环境下的数据一致性问题。具体场景是当用户A在收藏一首歌的同时用户B可能正在取消对同一首歌的收藏后台需要实时、准确地更新这首歌的“收藏数”。在高并发请求下简单的数据库更新操作极易导致最终计数不准也就是我们常说的“超卖”或“数据覆盖”问题。这不仅仅是收藏功能点赞数、库存扣减、积分增减等业务场景都面临同样的挑战。本文将围绕如何在高并发场景下实现类似“收藏数”这类计数器的强一致性与高性能更新提供一个从理论到实战的完整解决方案。我们会从最基础的数据库行锁开始逐步深入到更优雅的分布式锁和Redis原子操作并最终给出一个基于Redis Lua脚本的生产级方案。无论你是正在处理类似业务难题的开发者还是希望深入理解并发控制的同学都能从本文中获得可直接复用的代码和清晰的解决思路。1. 背景与核心概念为什么简单的“UPDATE”会出问题在深入解决方案之前我们必须先理解问题产生的根源。很多初级开发者可能会写出如下SQL来实现收藏数的增减-- 用户收藏歌曲 UPDATE song SET favorite_count favorite_count 1 WHERE id 123; -- 用户取消收藏歌曲 UPDATE song SET favorite_count favorite_count - 1 WHERE id 123;在单线程或极低并发下这段代码工作良好。但在高并发场景下问题就暴露出来了。核心问题数据库的UPDATE操作本身是原子的但一个完整的“业务操作”通常包含多个步骤查询当前状态、业务逻辑判断、执行更新。在高并发下多个请求可能几乎同时读取到同一个favorite_count值例如100然后各自在其基础上进行1或-1计算并写回数据库。最终后写入的操作会覆盖前一个操作的结果导致数据丢失。例如请求A收藏和请求B取消收藏同时读取到favorite_count 100。请求A计算100 1 101。请求B计算100 - 1 99。请求B先将99写入数据库。请求A再将101写入数据库覆盖了99。最终结果favorite_count 101但正确结果应为100。一次取消收藏的操作被丢失了。这就是典型的并发写冲突。要解决它我们需要确保对同一个计数器数据的“读-改-写”这一系列操作成为一个不可分割的原子操作即在操作完成前其他请求必须等待。2. 环境准备与版本说明本文将使用一个简化的Spring Boot项目来演示所有方案。你可以跟随步骤搭建环境也可以直接关注核心代码逻辑。基础环境操作系统: macOS/Linux/Windows (WSL2推荐)JDK: 11 或以上 (本文使用 Amazon Corretto 11)构建工具: Maven 3.6IDE: IntelliJ IDEA 或 VS Code主要依赖 (Spring Boot 2.7.x)核心依赖是Spring Boot Web、Spring Data JPA (连接MySQL)、以及Spring Data Redis。!-- pom.xml 关键依赖 -- dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.17.7/version !-- 用于分布式锁方案 -- /dependency /dependencies数据库与中间件MySQL: 8.0用于存储歌曲信息及计数器。Redis: 6.2用于缓存和原子操作。项目结构预览src/main/java/com/example/musiccounter/ ├── entity/ │ └── Song.java # 歌曲实体类 ├── repository/ │ └── SongRepository.java # JPA仓库 ├── service/ │ └── FavoriteService.java # 收藏业务服务 └── controller/ └── FavoriteController.java # 收藏接口3. 解决方案一数据库悲观锁行锁这是最直接、利用数据库自身能力的方案。通过SELECT ... FOR UPDATE语句在事务中锁定目标数据行直到当前事务提交或回滚其他试图读取该行的事务会被阻塞。3.1 原理与实现我们在Service层的方法上使用Transactional注解并在查询时使用Lock(LockModeType.PESSIMISTIC_WRITE)。// 文件路径src/main/java/com/example/musiccounter/service/FavoriteService.java Service RequiredArgsConstructor public class FavoriteService { private final SongRepository songRepository; /** * 使用数据库行锁实现收藏 * param songId 歌曲ID */ Transactional(rollbackFor Exception.class) public void favoriteWithPessimisticLock(Long songId) { // 1. 使用悲观写锁查询歌曲此行已被锁定 Song song songRepository.findSongByIdForUpdate(songId); // 2. 业务逻辑判断例如是否已收藏此处简化 // 3. 更新计数器 song.setFavoriteCount(song.getFavoriteCount() 1); // 4. 保存事务提交后锁释放 songRepository.save(song); } /** * 使用数据库行锁实现取消收藏 * param songId 歌曲ID */ Transactional(rollbackFor Exception.class) public void unfavoriteWithPessimisticLock(Long songId) { Song song songRepository.findSongByIdForUpdate(songId); if (song.getFavoriteCount() 0) { song.setFavoriteCount(song.getFavoriteCount() - 1); songRepository.save(song); } } }对应的Repository需要定义特定的查询方法// 文件路径src/main/java/com/example/musiccounter/repository/SongRepository.java Repository public interface SongRepository extends JpaRepositorySong, Long { /** * 使用悲观写锁查询歌曲 * param id 歌曲ID * return 歌曲实体 */ Lock(LockModeType.PESSIMISTIC_WRITE) Query(SELECT s FROM Song s WHERE s.id :id) OptionalSong findSongByIdForUpdate(Param(id) Long id); }3.2 优缺点分析优点强一致性绝对保证数据正确由数据库本身保证。实现简单利用JPA注解即可无需引入额外组件。缺点性能瓶颈锁是数据库级别的并发量高时大量线程会阻塞在数据库导致连接池耗尽、响应时间飙升。死锁风险如果多个业务需要按不同顺序锁定多行数据可能引发死锁。不适合分布式如果应用是多实例部署数据库锁依然有效但性能压力会集中在数据库。适用场景并发量不高、对数据一致性要求极其严格、且业务逻辑简单的场景。4. 解决方案二基于Redis的分布式锁当应用是分布式部署时我们需要一个所有实例都能访问的锁服务Redis因其高性能和原子性命令成为首选。这里我们使用成熟的Redisson客户端。4.1 原理与实现Redisson的RLock实现了Java的Lock接口并提供了看门狗Watchdog机制自动续期防止业务未执行完锁过期同时保证了锁的可重入性。首先配置Redisson# application.yml spring: redis: host: localhost port: 6379 database: 0 redisson: config: | singleServerConfig: address: redis://${spring.redis.host}:${spring.redis.port} database: ${spring.redis.database}然后实现服务// 在FavoriteService中注入RedissonClient并新增方法 Service RequiredArgsConstructor public class FavoriteService { // ... 其他依赖 private final RedissonClient redissonClient; /** * 使用Redisson分布式锁实现收藏 * param songId 歌曲ID */ public void favoriteWithDistributedLock(Long songId) { // 1. 为每个歌曲ID创建独立的锁对象锁粒度更细性能更好 String lockKey LOCK:SONG_FAVORITE: songId; RLock lock redissonClient.getLock(lockKey); // 2. 尝试加锁最多等待3秒锁持有时间10秒看门狗会自动续期 try { boolean isLocked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!isLocked) { throw new RuntimeException(获取锁失败请稍后重试); } // 3. 执行业务逻辑 Song song songRepository.findById(songId).orElseThrow(); song.setFavoriteCount(song.getFavoriteCount() 1); songRepository.save(song); // 4. 业务完成锁会在finally块中释放 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(锁等待被中断, e); } finally { // 5. 释放锁只释放自己加的锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }4.2 优缺点分析优点分布式友好完美支持多实例部署。高性能相比数据库行锁Redis锁的获取和释放速度极快。功能完善Redisson提供了可重入锁、公平锁、联锁、红锁等多种锁类型以及自动续期机制。缺点系统复杂度增加需要引入和维护Redis集群及Redisson客户端。非绝对强一致Redis主从异步复制下主节点崩溃可能导致锁信息丢失可用RedLock算法缓解但更复杂。锁粒度管理锁的key设计需要仔细粒度过粗影响并发过细增加管理成本。适用场景分布式系统对性能要求高可以接受极低概率锁失效的场景。5. 解决方案三Redis原子操作INCR/DECR如果我们把计数器本身从数据库迁移到Redis就可以利用Redis单线程和原子命令的特性从根本上避免并发问题。这是性能最高的方案。5.1 原理与实现Redis的INCR和DECR命令是原子性的意味着执行增减时不会被其他命令打断。我们将歌曲的收藏数存储在Redis的String或Hash结构中。方案A使用String类型Service RequiredArgsConstructor public class FavoriteService { // ... 其他依赖 private final StringRedisTemplate stringRedisTemplate; private static final String FAV_COUNT_KEY_PREFIX song:fav:count:; /** * 使用Redis原子INCR实现收藏 * param songId 歌曲ID */ public void favoriteWithRedisAtomic(Long songId) { String key FAV_COUNT_KEY_PREFIX songId; // INCR命令如果key不存在会先初始化为0再1 Long newCount stringRedisTemplate.opsForValue().increment(key); // 这里可以记录日志或触发其他事件newCount就是最新的收藏数 log.info(歌曲{}收藏数更新为{}, songId, newCount); // 可选异步将数据写回数据库保证最终一致性 // asyncUpdateDatabase(songId, newCount); } /** * 使用Redis原子DECR实现取消收藏 * param songId 歌曲ID */ public void unfavoriteWithRedisAtomic(Long songId) { String key FAV_COUNT_KEY_PREFIX songId; // DECR命令如果key不存在会先初始化为0再-1得到-1。需要业务判断。 Long newCount stringRedisTemplate.opsForValue().decrement(key); if (newCount ! null newCount 0) { // 如果减到负数说明数据有问题可以回滚或告警 stringRedisTemplate.opsForValue().increment(key); // 回滚 throw new RuntimeException(收藏数不能为负); } log.info(歌曲{}收藏数更新为{}, songId, newCount); } /** * 获取收藏数优先从Redis读 * param songId 歌曲ID * return 收藏数 */ public Long getFavoriteCount(Long songId) { String key FAV_COUNT_KEY_PREFIX songId; String countStr stringRedisTemplate.opsForValue().get(key); if (countStr ! null) { return Long.parseLong(countStr); } else { // Redis没有从数据库加载并预热到Redis Song song songRepository.findById(songId).orElseThrow(); stringRedisTemplate.opsForValue().set(key, String.valueOf(song.getFavoriteCount())); return song.getFavoriteCount(); } } }方案B使用Hash类型管理多个计数器如果一首歌有多个需要原子更新的计数如收藏、点赞、播放使用Hash更高效。public void favoriteWithRedisHash(Long songId) { String key song:counters: songId; // HINCRBY 同样具有原子性 Long newCount stringRedisTemplate.opsForHash().increment(key, favorite, 1); log.info(歌曲{}收藏数更新为{}, songId, newCount); }5.2 数据同步与最终一致性纯Redis方案面临一个核心问题数据在Redis里如何与源数据库同步我们通常采用异步写回的策略保证最终一致性。写操作路径用户操作 → Redis原子更新 → 发送MQ消息或记录Binlog → 异步消费者更新数据库。读操作路径优先读Redis → Redis无数据则读数据库并回填Redis。容灾考虑Redis重启或崩溃可以从数据库重新加载计数。为防止重启后数据为0可以考虑定期将Redis数据持久化快照或使用AOF。这是一个简单的异步更新示例使用Spring Event或MQComponent RequiredArgsConstructor public class CounterUpdateListener { private final SongRepository songRepository; Async // 异步执行 EventListener public void handleCounterUpdateEvent(CounterUpdateEvent event) { // 这里可以批量更新减少数据库压力 songRepository.updateFavoriteCount(event.getSongId(), event.getNewCount()); } } // 在favoriteWithRedisAtomic方法中发布事件 // applicationEventPublisher.publishEvent(new CounterUpdateEvent(songId, newCount));5.3 优缺点分析优点性能极致Redis内存操作单线程原子命令吞吐量极高。实现简洁无需显式加锁代码更清晰。扩展性好易于实现计数器的分片和集群化。缺点数据一致性模型变化从事务强一致变为最终一致业务需要接受短暂的数据延迟。系统架构复杂化需要设计可靠的数据同步机制引入了消息队列或定时任务。缓存穿透/雪崩需要处理Redis缓存失效的问题。适用场景高并发、对实时一致性要求不是绝对严格允许秒级延迟、计数查询频繁的场景。如微博点赞、视频播放量、热点文章阅读数等。6. 终极方案Redis Lua脚本保证原子性与业务逻辑对于更复杂的业务逻辑例如每人每天只能点赞一次点赞同时增加积分单纯的INCR不够用而“先查后改”在分布式锁下也有风险。此时Redis Lua脚本是终极武器。Lua脚本在Redis中执行时是原子的相当于把整个业务逻辑打包成一个原子命令。6.1 实战使用Lua脚本实现“每日首次收藏奖励积分”假设业务规则用户收藏歌曲歌曲收藏数1。如果用户是当天第一次收藏该歌曲则额外奖励10积分。Service public class FavoriteService { private final StringRedisTemplate stringRedisTemplate; // Lua脚本收藏并检查每日首次 private static final String FAVORITE_LUA_SCRIPT local songKey KEYS[1] -- 歌曲收藏数key例如 song:fav:count:123 \n local userDailyKey KEYS[2] -- 用户每日记录key例如 user:daily:fav:456:20240520 \n local userId ARGV[1] \n \n -- 1. 增加歌曲收藏数 \n local newFavCount redis.call(INCR, songKey) \n \n -- 2. 检查用户今天是否已收藏过这首歌 \n local hasFavToday redis.call(SETNX, userDailyKey, 1) \n \n -- 3. 如果是首次收藏SETNX返回1设置key过期时间为当天剩余秒数并返回奖励标记 \n local rewardPoints 0 \n if hasFavToday 1 then \n redis.call(EXPIREAT, userDailyKey, tonumber(ARGV[2])) -- ARGV[2]是当天23:59:59的时间戳 \n rewardPoints 10 \n end \n \n -- 4. 返回结果新的收藏数、是否奖励积分 \n return {newFavCount, rewardPoints}; private final DefaultRedisScriptList favoriteScript; public FavoriteService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; this.favoriteScript new DefaultRedisScript(); this.favoriteScript.setScriptText(FAVORITE_LUA_SCRIPT); this.favoriteScript.setResultType(List.class); } public MapString, Object favoriteWithLuaScript(Long songId, Long userId) { String songKey song:fav:count: songId; // 用户每日记录key包含用户ID和当天日期 String today LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String userDailyKey user:daily:fav: userId : today; // 计算当天结束的时间戳 long endOfDay LocalDateTime.now().with(LocalTime.MAX).toEpochSecond(ZoneOffset.UTC); // 执行Lua脚本 ListLong result stringRedisTemplate.execute( favoriteScript, Arrays.asList(songKey, userDailyKey), userId.toString(), String.valueOf(endOfDay) ); Long newFavCount result.get(0); Long rewardPoints result.get(1); MapString, Object response new HashMap(); response.put(newFavoriteCount, newFavCount); response.put(rewardPoints, rewardPoints); response.put(message, rewardPoints 0 ? 收藏成功获得 rewardPoints 积分 : 收藏成功); // 异步处理积分奖励到数据库 if (rewardPoints 0) { asyncAddUserPoints(userId, rewardPoints); } // 异步同步收藏数到数据库 asyncUpdateSongFavoriteCount(songId, newFavCount); return response; } }6.2 方案优势绝对的原子性整个“判断-更新-奖励”流程在Redis服务器端一次性完成无并发干扰。高性能一次网络通信减少多次命令的往返延迟RTT。减少锁竞争完全避免了分布式锁的争用。业务逻辑封装复杂的业务规则可以封装在脚本中客户端调用简单。7. 方案对比与选型指南特性数据库悲观锁Redis分布式锁Redis原子操作Redis Lua脚本一致性强一致最终一致主从延迟最终一致最终一致Redis内强一致性能低数据库瓶颈中高网络Redis极高内存操作高单次网络通信复杂度低中需管理锁中需数据同步中高需维护脚本分布式支持是但数据库是中心是是是适用场景低频、强一致业务分布式、复杂事务业务高频计数、允许延迟高频、带业务逻辑的计数选型建议追求极致性能与高并发首选Redis原子操作 (INCR/DECR) 异步同步方案。这是互联网公司处理点赞、收藏、阅读数的标准做法。业务逻辑复杂且需原子性选择Redis Lua脚本。它将复杂逻辑原子化性能优于分布式锁。已有分布式锁框架且业务复杂使用Redis分布式锁 (Redisson)。它对代码侵入小能处理跨多个资源或服务的复杂事务场景。传统项目、并发量低、强一致性优先使用数据库悲观锁。简单直接但必须密切监控数据库压力。8. 生产环境注意事项与最佳实践无论选择哪种方案以下几点对于生产系统都至关重要监控与告警数据库行锁监控数据库活跃连接数、锁等待时间。设置慢查询告警。Redis分布式锁监控Redis连接数、内存使用、锁等待超时次数。Redis计数器监控内存使用率、Key数量增长趋势防止无限增长。Key设计规范使用清晰的命名空间如业务:类型:标识(song:fav:count:123)。为计数器Key设置合理的TTL即使有异步同步也避免无用数据常驻内存。可以设置一个较长的TTL如7天并由同步程序刷新TTL。降级与容灾Redis不可用设计降级策略例如快速失败返回默认值或临时切换为基于数据库的悲观锁方案需有流量开关控制。数据同步失败消息队列需保证可靠性持久化、重试、死信队列。定期运行补偿任务核对Redis与数据库的数据差异。防止恶意刷在接口层增加频率限制Rate Limiting例如使用Redis的INCR和EXPIRE实现滑动窗口限流。对关键操作进行用户行为验证如验证码、token。代码健壮性分布式锁一定要在finally块中释放并判断当前线程是否持有锁。Lua脚本应尽量简洁避免长时间运行的脚本阻塞Redis。所有异步操作如发MQ、更新DB都要有完善的错误处理和日志记录。9. 常见问题排查清单问题现象可能原因排查步骤与解决方案收藏数偶尔少1或多11. 并发写冲突未用锁或原子操作2. 异步同步消息丢失1. 检查代码是否使用了本文所述的任一并发控制方案。2. 检查MQ消息是否有未ack的情况查看消费者日志。接口响应突然变慢数据库CPU高数据库行锁竞争激烈1. 使用SHOW PROCESSLIST或INNODB_LOCKS查看锁状态。2. 考虑引入Redis缓存计数器降低数据库压力。Redis内存增长过快计数器Key未设置过期时间或业务激增1. 分析Redis大Keyredis-cli --bigkeys。2. 为计数器Key设置一个较长的TTL或定期清理冷数据。分布式锁下出现“锁已超时”但业务仍在执行业务执行时间超过锁的租期leaseTime锁被自动释放其他线程获取到锁1. 合理评估业务最大耗时设置足够的锁超时时间。2. 使用Redisson的看门狗机制默认有它会自动续期。3. 优化业务逻辑拆分长事务。Lua脚本执行报错NOSCRIPT脚本缓存丢失Redis重启或脚本未缓存1. 使用SCRIPT LOAD命令预加载脚本通过SHA1标识执行。2. 在客户端代码中实现脚本缓存机制如Spring的DefaultRedisScript。从Redis读到的计数为0但数据库有值缓存穿透查询一个不存在的Key且未回种缓存1. 在getFavoriteCount方法中如果从数据库回填一定要执行SET操作。2. 对于肯定不存在的Key如非法ID可缓存空值如-1并设置短TTL。从数据库行锁的强一致保障到Redis原子操作的高性能巅峰再到Lua脚本对复杂业务原子性的完美封装解决高并发计数问题的技术路径是清晰的。对于大多数互联网应用“Redis原子操作 异步最终一致”是平衡性能、复杂度与一致性的最佳选择。在引入任何方案时务必结合自身业务的并发量、数据一致性要求和技术栈现状来做决策并配以完善的监控、告警和降级措施。