• 使用Redis和Lua的原子性实现抢红包功能


    数据库最终会将数据保存到磁盘中,而 Redis 使用的是内存,内存的速度比磁盘速度快得多,所以这里将讨论使用 Redis 实现抢红包

    对于使用 Redis 实现抢红包,首先需要知道的是 Redis 的功能不如数据库强大,事务也不完整,因此要保证数据的正确性,数据的正确性可以通过严格的验证得以保证。

    而 Redis 的 Lua 语言是原子性的,且功能更为强大,所以优先选择使用 Lua 语言来实现抢红包。

    但是无论如何对于数据而言,在 Redis 当中存储,始终都不是长久之计,因为 Redis 并非一个长久储存数据的地方,它存储的数据是非严格和安全的环境,更多的时候只是为了提供更为快速的缓存。

    所以当红包金额为 0 或者红包超时的时候(超时操作可以使用定时机制实现),会将红包数据保存到数据库中,这样才能够保证数据的安全性和严格性。

    使用注解方式配置 Redis

    首先在类 RootConfig 上创建一个 RedisTemplate 对象,并将其装载到 Spring IoC 容器中,代码如下所示。

    1. @Bean(name = "redisTemplate")
    2. public RedisTemplate initRedisTemplate() {
    3. JedisPoolConfig poolConfig = new JedisPoolConfig();
    4. // 最大空闲数
    5. poolConfig.setMaxIdle(50);
    6. // 最大连接数
    7. poolConfig.setMaxTotal(100);
    8. // 最大等待毫秒数
    9. poolConfig.setMaxWaitMillis(20000);
    10. // 创建Jedis链接工厂
    11. JedisConnectionFactory connectionFactory = new JedisConnectionFactory(poolConfig);
    12. connectionFactory.setHostName("localhost");
    13. connectionFactory.setPort(6379);
    14. // 调用后初始化方法,没有它将抛出异常
    15. connectionFactory.afterPropertiesSet();
    16. // 自定Redis序列化器
    17. RedisSerializer jdkSerializationRedisSerializer = new JdkSerializationRedisSerializer();
    18. RedisSerializer stringRedisSerializer = new StringRedisSerializer(); // 定义RedisTemplate,并设置连接工厂
    19. RedisTemplate redisTemplate = new RedisTemplate();
    20. redisTemplate.setConnectionFactory(connectionFactory);
    21. // 设置序列化器
    22. redisTemplate.setDefaultSerializer(stringRedisSerializer);
    23. redisTemplate.setKeySerializer(stringRedisSerializer);
    24. redisTemplate.setValueSerializer(stringRedisSerializer);
    25. redisTemplate.setHashKeySerializer(stringRedisSerializer);
    26. redisTemplate.setHashValueSerializer(stringRedisSerializer);
    27. return redisTemplate;
    28. }

    这样 RedisTemplate 就可以在 Spring 上下文中使用了。注意,JedisConnectionFactory 对象在最后的时候需要自行调用 afterPropertiesSet 方法,它实现了 InitializingBean 接口。

    如果将其配置在 Spring IoC 容器中,Spring 会自动调用它,但是这里我们是自行创建的,因此需要自行调用,否则在运用的时候会抛出异常,从而出现错误。

    数据存储设计

    Redis 并不是一个严格的事务,而且事务的功能也是有限的。加上 Redis 本身的命令也比较有限,功能性不强,为了增强功能性,还可以使用 Lua 语言。

    Redis 中的 Lua 语言是一种原子性的操作,可以保证数据的一致性。依据这个原理可以避免超发现象,完成抢红包的功能,而且对于性能而言,Redis 会比数据库快得多。

    第一次运行 Lua 脚本的时候,先在 Redis 中编译和缓存脚本,这样就可以得到一个 SHA1 字符串,之后通过 SHA1 字符串和参数就能调用 Lua 脚本了。先来编写 Lua 脚本,代码如下所示。

    1. --缓存抢红包列表信息列表key
    2. local listKey = 'red_packet_list_'..KEYS[1]
    3. --当前被抢红包key
    4. local redPacket = 'red_packet_'..KEYS[1]
    5. --获取当前红包库存
    6. local stock = tonumber(redis.call('hget', redPacket, 'stock'))
    7. --没有库存,返回为0
    8. if stock <= 0 then return 0 end
    9. --库存减1
    10. stock = stock -1
    11. --保存当前库存
    12. redis.call('hset',redPacket,'stock', tostring(stock))
    13. --往链表中加入当前红包信息
    14. redis.call('rpush', listKey, ARGV[1])
    15. --如果是最后一个红包,则返回2,表示抢红包已经结束,需要将列表中的数据保存到数据库中
    16. if stock == 0 then return 2 end
    17. --如果并非最后一个红包,则返回1,表示抢红包成功
    18. return 1

    这里可以看到这样一个流程:

    • 判断是否存在可抢的库存,如果已经没有可抢夺的红包,则返回为 0,结束流程。
    • 有可抢夺的红包,对于红包的库存减一,然后重新设置库存。
    • 将抢红包数据保存到 Redis 的链表当中,链表的 key 为 redpacket_list{id}。
    • 如果当前库存为 0,那么返回 2,这说明可以触发数据库对 Redis 链表数据的保存,链表的 key 为 redpacket_list{id},它将保存抢红包的用户名和抢的时间。
    • 如果当前库存不为 0,那么将返回 1,这说明抢红包信息保存成功。

    当返回为 2 的时候(现实中如果抢不完红包,可以使用超时机制触发,这比较复杂,本书不讨论这样的情况),说明红包已经没有库存,会触发数据库对链表数据的保存,这是一个大数据量的保存。

    为了不影响最后一次抢红包的响应,在实际的操作中往往会考虑使用 JMS 消息发送到别的服务器进行操作,这样会比较复杂,而 JMS 消息不属于本教程讨论的范围,所以这里只是创建一条新的线程去运行保存 Redis 链表数据到数据库,为此我们需要一个新的服务类,代码如下所示。

    1. package com.service;
    2. public interface RedisRedPacketService {
    3. /**
    4. * 保存redis抢红包列表
    5. *
    6. * @param redPacketId ―抢红包编号
    7. * @param unitAmount --红包金额
    8. */
    9. public void saveUserRedPacketByRedis(Long redPacketId, Double unitAmount);
    10. }

    还需要这个接口的实现类,代码如下所示。

    1. package com.service.impl;
    2. import java.beans.*;
    3. import java.sql.SQLException;
    4. import java.sql.Timestamp;
    5. import java.util.ArrayList;
    6. import java.util.List;
    7. import javax.sql.DataSource;
    8. import org.springframework.beans.factory.annotation.Autowired;
    9. import org.springframework.data.redis.core.BoundListOperations;
    10. import org.springframework.data.redis.core.RedisTemplate;
    11. import org.springframework.scheduling.annotation.Async;
    12. import org.springframework.stereotype.Service;
    13. import com.pojo.UserRedPacket;
    14. import com.service.RedisRedPacketService;
    15. @Service
    16. public class RedisRedPacketServiceImpl implements RedisRedPacketService {
    17. private static final String PREFIX = "red_packet_list_";
    18. // 每次取出1000条,避免一次取出消耗太多内存
    19. private static final int TIME_SIZE = 1000;
    20. @Autowired
    21. private RedisTemplate redisTemplate = null; // RedisTemplate
    22. @Autowired
    23. private DataSource datasource = null; // 数据源
    24. @Override
    25. // 开启新线程运行
    26. @Async
    27. public void saveUserRedPacketByRedis(Long redPacketId, Double unitAmount) {
    28. System.err.println("开始保存数据");
    29. Long start = System.currentTimeMillis();
    30. // 获取列表操作对象
    31. BoundListOperations ops = redisTemplate.boundListOps(PREFIX + redPacketId);
    32. Long size = ops.size();
    33. Long times = size % TIME_SIZE == 0 ? size / TIME_SIZE : size / TIME_SIZE + 1;
    34. int count = 0;
    35. List userRedPacketList = new ArrayList(TIME_SIZE);
    36. for (int i = 0; i < times; i++) {
    37. // 获取至多TIME_SIZE个抢红包信息
    38. List userIdList = null;
    39. if (i == 0) {
    40. userIdList = ops.range(i * TIME_SIZE, (i + 1) * TIME_SIZE);
    41. } else {
    42. userIdList = ops.range(i * TIME_SIZE, (i + 1) * TIME_SIZE);
    43. }
    44. userRedPacketList.clear();
    45. // 保存红包信息
    46. for (int j = 0; j < userIdList.size(); j++) {
    47. String args = userIdList.get(j).toString();
    48. String[] arr = args.split("_");
    49. String userIdStr = arr[0];
    50. String timeStr = arr[1];
    51. Long userId = Long.parseLong(userIdStr);
    52. Long time = Long.parseLong(timeStr);
    53. // 生成抢红包信息
    54. UserRedPacket UserRedPacket = new UserRedPacket();
    55. UserRedPacket.setRedPacketId(redPacketId);
    56. UserRedPacket.setUserId(userId);
    57. UserRedPacket.setAmount(unitAmount);
    58. UserRedPacket.setGrabTime(new Timestamp(time));
    59. UserRedPacket.setNote("抢红包 " + redPacketId);
    60. userRedPacketList.add(UserRedPacket);
    61. }
    62. // 插入抢红包信息
    63. count += executeBatch(userRedPacketList);
    64. }
    65. // 删除Redis列表
    66. redisTemplate.delete(PREFIX + redPacketId);
    67. Long end = System.currentTimeMillis();
    68. System.err.println("保存数据结束,耗时" + (end - start) + "毫秒,共" + count + "条记录被保存。");
    69. }
    70. /**
    71. * 使用JDBC批量处理Redis缓存数据.
    72. *
    73. * @param userRedPacketList --抢红包列表 @return抢红包插入数量.
    74. */
    75. private int executeBatch(List userRedPacketList) {
    76. Connection conn = null;
    77. Statement stmt = null;
    78. int[] count = null;
    79. try {
    80. conn = datasource.getConnection();
    81. conn.setAutoCommit(false);
    82. stmt = conn.createStmtement();
    83. for (UserRedPacket userRedPacket : userRedPacketList) {
    84. String sql1 = "update T_RED_PACKET set stock = stock-1 where id=" + userRedPacket.getRedPacketId();
    85. DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    86. String sql2 = "insert into T_USER_RED_PACKET (red_packet_id,user_id," + "amount, grab_time, note)"
    87. + " values (" + userRedPacket.getRedPacketId() + "," + userRedPacket.getUserId() + ", "
    88. + userRedPacket.getAmount() + "," + "'" + df.format(userRedPacket.getGrabTime()) + "'"
    89. + userRedPacket.getNote() + "')";
    90. stmt.addBatch(sql1);
    91. stmt.addBatch(sql2);
    92. }
    93. // 执行批量
    94. count = stmt.executeBatch();
    95. // 提交事务
    96. conn.commit();
    97. } catch (SQLException e) {
    98. /********* 错误处理逻辑 ********/
    99. throw new RuntimeException("抢红包批量执行程序错误");
    100. } finally {
    101. try {
    102. if (conn != null && !conn.isClosed()) {
    103. conn.close();
    104. }
    105. } catch (SQLException e) {
    106. e.printStackTrace();
    107. }
    108. }
    109. // 返冋插入抢红包数据记录
    110. return count.length / 2;
    111. }
    112. }

    注意,注解 @Async 表示让 Spring 自动创建另外一条线程去运行它,这样它便不在抢最后一个红包的线程之内。因为这个方法是一个较长时间的方法,如果在同一个线程内,那么对于最后抢红包的用户需要等待的时间太长,影响其体验。

    这里是每次取出 1 000 个抢红包的信息,之所以这样做是为了避免取出的数据过大,导致 JVM 消耗过多的内存影响系统性能。

    对于大批量的数据操作,这是我们在实际操作中要注意的,最后还会删除 Redis 保存的链表信息,这样就帮助 Redis 释放内存了。对于数据库的保存,这里采用了 JDBC 的批量处理,每 1 000 条批量保存一次,使用批量有助于性能的提高。

    在笔者的实际测试中,2 万条数据 6 秒就可以保存到数据库中了,性能还是不错的。

    用注解 @Async 的前提是提供一个任务池给 Spring 环境,这个时候要在原有的基础上改写配置类 WebConfig,如下面代码所示。

    1. ......
    2. @EnableAsync
    3. public class WebConfig extends AsyncConfigurerSupport {
    4. ......
    5. public Executor getAsyncExecutor() {
    6. ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
    7. taskExecutor.setCorePoolSize(5);
    8. taskExecutor.setMaxPoolSize(10);
    9. taskExecutor.setQueueCapacity(200);
    10. taskExecutor.initialize();
    11. return taskExecutor;
    12. }
    13. }

    使用 @EnableAsync 表明支持异步调用,而我们实现了接口 AsyncConfigurerSupport 的 getAsyncExecutor 方法,它是获取一个任务池,当在 Spring 环境中遇到注解 @Async 就会启动这个任务池的一条线程去运行对应的方法,这样便能执行异步了。

    使用 Redis 实现抢红包

    有了 Redis 的配置,下面讨论一下如何使用 Redis 实现抢红包的逻辑,首先要自己编写 Lua 语言,然后通过对应的链接发送给 Redis 服务器,那么 Redis 会返回一个 SHA1 字符串,我们保存它,之后的发送可以只发送这个字符和对应的参数。下面在 UserRedPacketService 接口中加入一个新的方法:

    1. /**
    2. * 通过Redis实现抢红包
    3. * @param redPacketId 红包编号
    4. * @param userId 用户编号
    5. * @return
    6. * 0-没有库存,失败
    7. * 1-成功,且不是最后一个红包
    8. * 2-成功,且是最后一个红包
    9. */
    10. public Long grapRedPacketByRedis(Long redPacketId, Long userId);

    它的实现类 UserRedPacketServiceImpl 也要加入其实现方法,代码如下所示。

    1. @Autowired
    2. private RedisTemplate redisTemplate = null;
    3. @Autowired
    4. private RedisRedPacketService redisRedPacketService = null;
    5. // Lua脚本
    6. String script = "local listKey = 'red_packet_list_'..KEYS[1] \n" + "local redPacket = 'red_packet_'..KEYS[1] \n"
    7. + "local stock = tonumber(redis.call('hget', redPacket, 'stock'))\n" + "if stock <= 0 then return 0 end \n"
    8. + "stock = stock -1 \n" + "redis . call ('hset', redPacket, 'stock', tostring (stock)) \n"
    9. + "redis.call('rpush', listKey, ARGV[1]) \n" + "if stock == 0 then return 2 end \n" + "return 1 \n";
    10. // 在缓存Lua脚本后,使用该变量保存Redis返回的32位的SHA1编码,使用它去执行缓存的 Lua脚本
    11. String sha1 = null;
    12. @Override
    13. public Long grapRedPacketByRedis(Long redPacketId, Long userId) {
    14. // 当前抢红包用户和日期信息
    15. String args = userId + "_" + System.currentTimeMillis();
    16. Long result = null;
    17. // 获取底层Redis操作对象
    18. Jedis jedis = (Jedis) redisTemplate.getConnectionFactory().getConnection().getNativeConnection();
    19. try {
    20. // 如果脚本没有加载过,那么进行加载,这样就会返回一个sha1编码
    21. if (sha1 == null) {
    22. sha1 = jedis.scriptLoad(script);
    23. }
    24. // 执行脚本,返回结果
    25. Object res = jedis.evalsha(sha1, 1, redPacketId + args);
    26. result = (Long) res;
    27. // 返回2时为最后一个红包,此时将抢红包信息通过异步保存到数据库中
    28. if (result == 2) {
    29. // 获取单个小红包金额
    30. String unitAmountStr = jedis.hget("red_pmcket_" + redPacketId, "unit_amount");
    31. // 触发保存数据库操作
    32. Double unitAmount = Double.parseDouble(unitAmountstr);
    33. System.err.println("thread_name = " + Thread.currentThread().getName());
    34. redisRedPacketService.saveUserRedPacketByRedis(redPacketId, unitAmount);
    35. }
    36. } finally {
    37. // 确保jedis顺利关闭
    38. if (jedis != null && jedis.isConnected()) {
    39. jedis.close();
    40. }
    41. }
    42. return result;
    43. }

    这里使用了保存脚本返回的 SHA1 字符串,所以只会发送一次脚本到 Redis 服务器,之后只传输 SHA1 字符串和参数到 Redis 就能执行脚本了,当脚本返回为 2 的时候,表示此时所有的红包都已经被抢光了,那么就会触发 redisRedPacketService 的 saveUserRedPacketByRedis 方法。

    由于在 saveUserRedPacketByRedis 加入注解 @Async,所以 Spring 会创建一条新的线程去运行它,这样就不会影响最后抢一个红包用户的响应时间了。

    此时重新在控制器 UserRedPacketController 上加入新的方法作为响应便可以了,代码如下所示。

    1. @RequestMapping(value = "/grapRedPacketByRedis")
    2. @ResponseBody
    3. public Map grapRedPacketByRedis(Long redPacketId, Long userId) {
    4. Map resultMap = new HashMap();
    5. Long result = userRedPacketService.grmpRedPmcketByRedis(redPacketId, userId);
    6. boolean flag = result > 0;
    7. resultMap.put("result", flag);
    8. resultMap.put("message", flag ? "抢红包成功" : "抢红包失败");
    9. return resultMap;
    10. }

    为了测试它,我们先在 Redis 上添加红包信息,于是执行这样的命令:

    1. hset red_packet_5 stock 20000
    2. hset red_packet_5 unit_amount 10

    初始化了一个编号为 5 的大红包,其中库存为 2 万个,每个 10 元,读者在自己操作的时候,需要保证数据库的红包表内也有对应的记录。然后写一个 JSP 文件,对其进行测试,代码如下所示。

    1. <%@ page language="java" contentType="text/html; charset=utf-8"
    2. pageEncoding="utf-8"%>
    3. 参数

    这样运行服务器,使用 JSP 便能够进行测试了,下面是笔者测试的结果,如图 1 所示。


    图 1 Redis 实现抢红包测试

    结果正确,那么它的性能如何呢?再次进行查询,如图 2 所示。


    图 2 查询Redis抢红包性能

    2 万个红包只要 4 秒便完成了,而且没有发生超发的状况,性能远远超过乐观锁的 33 秒,更是远超使用悲观锁的 100 多秒,可见使用 Redis 是多么高效。

    注意,在一个普通请求的过程中,并没有去操作任何数据库,而只是使用 Redis 缓存数据而已,这就是程序能够高速运行的原因。Redis 抢红包流程图,如图 3 所示。


    图 3 Redis 抢红包流程图

  • 相关阅读:
    麒麟系统开发笔记(十四):在国产麒麟系统上编译libmodbus库、搭建基础开发环境和移植测试Demo
    初识 kubernetes 之 Pod
    使用 Docker + Jenkins + Gitlab + Maven 自动化部署 Spring Boot
    【螺旋旋转爱心特效】(Html+JS+CSS+效果+全部源代码)
    Docker容器故障排查与解决方案
    第二章 进程与线程 十五、互斥锁
    Rust嵌入式编程---panic处理和异常处理
    java实现倒水瓶排序
    STM32 ADC基础知识讲解
    QT学习总结之QWidget详解
  • 原文地址:https://blog.csdn.net/unbelievevc/article/details/126845466