ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis原子操作INCR/DECR原理与高并发实战:从库存超卖到分布式ID生成

Redis原子操作INCR/DECR原理与高并发实战:从库存超卖到分布式ID生成 1. 从一次库存超卖事故说起去年我负责一个电商秒杀项目核心逻辑很简单用户点击“立即购买”系统先检查商品库存如果库存大于0则扣减库存然后生成订单。我们最初的设计是在应用层用Java代码先执行一次SELECT stock FROM product WHERE id ?查询库存判断stock 0后再执行UPDATE product SET stock stock - 1 WHERE id ?来扣减。上线后风平浪静但在第一次大促的流量洪峰下噩梦来了——部分热门商品出现了库存超卖卖出的数量竟然超过了实际库存。事后复盘问题根因就在于这两步操作查询和更新不是原子性的。在高并发场景下两个线程可能同时查询到库存为1都判断为可售然后相继执行扣减最终导致库存被扣成了-1。这就是典型的“竞态条件”。为了解决这个问题我们几乎毫不犹豫地将库存扣减这个核心操作迁移到了Redis并使用了它的INCRBY和DECRBY命令。自那以后再未发生过超卖。今天我就结合这个踩坑经历来深入聊聊Redis是如何实现原子性的自增自减操作的这远不止是一个命令那么简单背后涉及到Redis的单线程模型、命令的原子性保证以及在高并发业务中的实战应用。2. Redis命令原子性的基石单线程事件循环要理解Redis如何实现原子性自增首先要抛开对传统数据库“锁”机制的固有印象。Redis的原子性其核心保障来源于它那经典且高效的单线程事件循环架构。2.1 为什么单线程反而成了优势与MySQL等关系型数据库使用多线程处理并发请求不同Redis的核心网络I/O和命令执行是由一个主线程串行处理的。这意味着在任何给定的时刻Redis服务器只会执行一个客户端发来的命令。当多个客户端同时发送INCR key命令时这些命令会在Redis的队列中排队被主线程一个一个地顺序执行。这就从根本上杜绝了并发冲突。因为不存在两个线程同时操作同一个内存数据的情况。对于一个键stock:1001的INCR或DECR操作从读取旧值、计算新值到写回内存的整个过程对于其他命令来说是不可分割的。这个特性使得像INCR,DECR,INCRBY,DECRBY,HINCRBY等命令天生就是原子操作。注意这里说的“单线程”指的是核心命令处理逻辑。实际上Redis在6.0版本之后引入了多线程来处理网络I/O但命令的解析和执行依然由主线程串行进行因此原子性特性得以保留。2.2 原子操作命令家族一览Redis提供了一组用于对数值进行原子操作的命令它们不仅是原子的而且性能极高因为只是内存操作。以下是核心成员命令格式描述返回值INCRINCR key将键中储存的数字值增一。执行命令后的新值。DECRDECR key将键中储存的数字值减一。执行命令后的新值。INCRBYINCRBY key increment将键所储存的值加上指定的增量值。执行命令后的新值。DECRBYDECRBY key decrement将键所储存的值减去指定的减量值。执行命令后的新值。INCRBYFLOATINCRBYFLOAT key increment为键所储存的值加上指定的浮点数增量值。执行命令后的新值。这些命令有一个共同前提操作的键对应的值必须是数字类型Redis内部存储为字符串但能被解释为整数或浮点数。如果键不存在命令会先将值初始化为0然后再执行操作。如果值不能被解释为数字命令将返回一个错误。2.3 与事务MULTI/EXEC的对比很多人会混淆原子命令和Redis事务。虽然事务MULTI...EXEC能将多个命令打包执行但其原子性含义不同原子命令单个命令的执行是原子的不可分割。Redis事务它保证的是序列化隔离——事务中的所有命令会被排队在EXEC时作为一个整体、按顺序执行。在执行过程中不会被其他客户端的命令插入。但它不提供回滚机制。如果事务中的某个命令出错其他命令依然会继续执行。因此对于简单的计数器场景直接使用INCR/DECR是最高效、最安全的选择。而对于需要连续执行多个不同命令且希望它们不被干扰的场景例如先GET再SET但中间值不能被其他客户端修改则需要结合WATCH命令来实现乐观锁这就复杂多了。3. 自增自减在实战中的典型应用场景理解了原理我们来看看这些原子操作命令能用在哪些具体业务中它们是如何解决实际痛点的。3.1 场景一高并发库存扣减与计数器这是最经典的应用开篇的库存超卖问题就是最佳案例。将商品库存存放在Redis中秒杀时直接使用DECR或DECRBY进行扣减。# 初始化库存 SET stock:product_1001 1000 # 用户下单时原子扣减1 DECR stock:product_1001如果DECR命令返回的值大于等于0说明扣减成功库存充足如果返回-1则说明库存已售罄假设我们从0开始扣减。这种方案完美解决了并发下的超卖问题。实操心得初始化与回滚库存初始化通常是在活动开始前从数据库加载到Redis。活动结束后需要将Redis中的最终库存同步回数据库。如果遇到订单取消等需要回滚库存的情况不能简单地用INCR因为可能造成超库存比如恶意取消。更安全的做法是使用一个独立的回滚库存键或记录回滚日志在活动结束后统一处理。防负数处理DECR不会阻止值变为负数。在业务上我们通常需要在扣减前判断。可以使用Lua脚本同样是原子执行将判断和扣减合二为一local current redis.call(GET, KEYS[1]) if current and tonumber(current) 0 then return redis.call(DECR, KEYS[1]) else return -1 -- 或一个特定的错误码 end3.2 场景二分布式环境下的全局ID生成与序列号在分布式系统中生成全局唯一的递增ID如订单号、消息ID是一个常见需求。利用Redis单机单线程的特性INCR命令可以轻松实现一个高性能的序列生成器。# 生成下一个订单ID key可以按业务日期划分 INCR order:id:20231027为什么可行因为INCR命令的原子性保证了即使在成千上万的并发请求下每次调用返回的值也一定是唯一的、递增的。进阶技巧分段设计直接使用INCR生成的ID过于简单。通常我们会组合业务前缀、日期和自增序列例如ORDER20231027000001。这可以通过在应用层拼接实现业务码 日期 (INCR序列).ToString().PadLeft(8, 0)。性能与持久化权衡纯内存操作性能极高但需要警惕Redis重启导致序列丢失或重复的风险。一种方案是定期将当前序列值持久化到数据库另一种更常见的方案是使用Redis的RDB或AOF持久化机制虽然可能丢失最近几秒的数据但对于订单ID这类业务通常可以接受一个小的“空洞”丢失的ID不再使用或者通过初始值设置一个较大的偏移量来规避风险。3.3 场景三用户行为限流与频率控制原子自增是实现滑动窗口限流算法的核心组件。例如限制一个用户每分钟只能发送5条短信。# 用户UID:1001 使用当前分钟数作为key的一部分 local key sms_limit:1001: .. os.date(%Y%m%d%H%M) # 原子增加本次操作计数 local current redis.call(INCR, key) # 如果是第一次访问设置key的过期时间为60秒 if current 1 then redis.call(EXPIRE, key, 60) end # 如果计数超过阈值则拒绝请求 if current 5 then return 0 -- 表示限流 else return 1 -- 表示允许 end这段Lua脚本保证了“判断-计数-设置过期时间”整个操作的原子性避免了在并发下可能出现的过期时间设置竞争条件。3.4 场景四实时统计与热度排行统计文章的阅读量、视频的播放次数、用户的点赞数等要求实时性高且并发更新频繁。# 文章阅读量1 INCR article:view:12345 # 用户获赞数10比如一次操作代表10个赞 INCRBY user:like:67890 10这些数据可以先在Redis中高速累加再通过定时任务例如每5分钟将增量数据同步到持久化数据库中从而大大减轻数据库的写入压力。踩坑记录 在早期的一次活动中我们直接将INCR的返回值作为实时热度值展示在大屏上。当QPS极高时虽然Redis本身毫无压力但频繁通过GET命令读取这个计数器用于展示的网络开销和序列化/反序列化成本变得不可忽视。后来我们改为在本地应用层使用一个短期缓存每100毫秒或每累计100次更新才去Redis读取一次最新值显著降低了Redis的读压力。4. 超越基础命令用Lua脚本实现复杂原子逻辑虽然INCR/DECR能解决单一操作的原子性但业务逻辑往往更复杂。例如“只有当库存大于10时才扣减1否则返回库存不足”。这涉及到“判断-执行”的组合在Redis中确保这种组合原子性的银弹就是Lua脚本。4.1 Lua脚本的原子性保障当Redis执行一个Lua脚本时它会将整个脚本作为一个命令来执行。在此期间Redis服务器不会处理其他任何命令直到脚本执行完毕。这相当于给一段自定义逻辑加了一个“全局锁”。用Lua脚本实现安全库存扣减-- KEYS[1] 库存key -- ARGV[1] 扣减数量 local current tonumber(redis.call(GET, KEYS[1]) or 0) local decrement tonumber(ARGV[1]) if current decrement then redis.call(DECRBY, KEYS[1], decrement) return current - decrement -- 返回扣减后的库存 else return -1 -- 库存不足 end在客户端如Java的Jedis或Lettuce中你可以加载并执行这个脚本它比发送多个命令再用WATCH监控的方式更简洁、性能更好。4.2 脚本编写的注意事项与性能保持脚本精简Lua脚本执行期间会阻塞整个Redis实例。务必确保脚本逻辑简单执行速度快避免在脚本中执行耗时的循环或复杂的计算。避免硬编码将key和参数作为KEYS和ARGV数组传入提高脚本的复用性。脚本缓存Redis会缓存执行过的脚本通过SHA1摘要。客户端可以先尝试用EVALSHA执行缓存的脚本如果不存在再使用EVAL命令这样可以节省网络传输脚本内容的开销。5. 高可用与集群环境下的考量在单机Redis上原子性由单线程模型天然保证。但在主从复制或Redis Cluster集群环境下我们需要有新的认识。5.1 主从复制与读写分离的陷阱常见的架构是主库写从库读以分担压力。但这里有一个数据一致性的时间窗口。当你在主库上执行了一个INCR操作后这个命令需要异步地复制到从库。如果在复制完成之前一个读请求被路由到了从库那么读到的就是旧值。解决方案强一致性要求对于需要绝对强一致的场景如扣减库存后立刻查询余额这类读请求必须强制走主库。大多数Redis客户端都支持设置“读主库”的标签。最终一致性容忍对于阅读量、点赞数这类允许短暂不一致的场景可以放心使用读写分离。通常主从延迟在毫秒级业务上可以接受。5.2 Redis Cluster与键哈希分区在Redis Cluster中数据根据键被分片到不同的节点上。INCR一个键的操作只会发生在该键所在的特定节点上其原子性在该节点内部依然由单线程保证。需要注意的问题多键操作如果你想原子性地对多个键进行操作例如同时扣减商品A和商品B的库存如果这两个键通过哈希计算后被分配到了不同的集群节点那么就无法用一个Lua脚本或事务来实现原子性因为Redis Cluster要求Lua脚本中的所有key必须在同一个哈希槽slot内。应对策略可以通过使用哈希标签Hash Tag来“欺骗”集群的键分配算法。例如使用{order}123:stock_A和{order}123:stock_B作为键名Redis只会根据{}内的内容order来计算分片从而确保这两个键落在同一个节点上使得针对它们的多键原子操作成为可能。但这需要谨慎设计避免导致数据倾斜。6. 性能监控与最佳实践将核心计数器放在Redis意味着Redis成为了系统的关键依赖。它的稳定性直接关系到业务核心流程。监控命令延迟使用redis-cli --latency-history或通过监控系统如PrometheusGrafana采集redis_command_duration_seconds等指标。特别关注INCR/DECR等高频命令的P99、P999延迟。突增可能意味着Redis负载过高或网络问题。避免大Key虽然一个计数器本身很小但如果你错误地使用了一个Hash结构来存储成千上万个商品的库存并频繁对其中某个字段进行HINCRBY整个大Hash的序列化/反序列化、网络传输会成为瓶颈。建议将计数器分散到独立的key中。设置合理的过期时间对于临时性的计数器如限流key、当日累计值一定要设置EXPIRE。我曾遇到过因为忘记设置过期时间导致Redis内存被无数个历史活动的计数器Key慢慢撑满的线上事故。容量规划与预警根据业务峰值估算计数器Key的增长速度和内存占用对Redis内存使用设置预警线。例如一个全局ID生成器每天增长100万那么order:id:20231027这个键的值大约会占用几MB内存Redis存储数字很高效这是可以接受的但需要心里有数。原子自增自减这个看似简单的功能是Redis作为高性能数据结构的精髓体现之一。它背后是单线程模型的简洁哲学解决的是高并发下最棘手的数据一致性问题。从库存扣减到分布式ID从限流到统计它的身影无处不在。下次当你面临一个需要确保计数准确无误的高并发场景时不妨首先想一想“这个问题能不能用一个INCR命令来解决” 在大多数情况下答案会是肯定的。
返回列表