利用Redis keyspace notification 实现定时执行

来源:互联网 发布:潍柴车用发动机的数据 编辑:程序博客网 时间:2024/06/11 15:21

需求场景: 

比如当用户在商城下单之后, 对于半小时未支付的订单进行自动取消, 再例如,商品定时上架,下架等需求。


解决方案 

  1. 使用调度框架,或者后台线程对数据库或者其他存储DB进行轮询,找出需要处理的数据,进行调度
  2. 使用MQ延迟队列, 比如Rocket 队列的延迟消息, 再者通过自己实现的伪延迟消息, 如博客前面文章Rabbit延迟队列实现
  3. 使用redis的key event 策略,对key失效事件订阅,进行处理。
优缺点:
  1. 调度方式,优点是实现最简单,简单粗暴,弊端也是非常大,受数据量影响,对大数据量轮询,给数据库造成压力,轮询时间间隔太短,容易造成job堆积,且暂时不考虑业务处理阻塞情况。
  2. MQ队列,优点,相对比较稳定,不会对数据库造成压力,时间控制比较精确,只需要做好消费者的消费能力保证即可。推荐。当环境没有MQ时可以使用第三种方案替代。
  3. Redis, 优点,不会对数据库造成压力,时间控制比较精确,缺点:redis作为缓存数据库,那么首先从程序运行角度来说,数据主要是存储在文件数据库,如MYSQL,而对于redis作用,更多是把其放在一个不影响程序业务逻辑的位置,简单的比喻:假如redis服务器挂掉,那么程序是否会执行不下去,或者程序业务无法正常执行? 所以笔者认为此时redis的稳定性并没有保障(注意:没有保障并不是说在redis本身运行稳定性,而是对于程序业务来说),另外,redis key event 实现方式是通过订阅频道接收推送消息,那么分布式情况下,就必须要做好相应的防止重复处理措施。 

Redis Keyspace Notifications(键空间回调通知):

键空间通知使得客户端可以通过订阅频道或模式, 来接收那些以某种方式改动了 Redis 数据集的事件。事件通过 Redis 的订阅与发布功能(pub/sub)来进行分发, 因此所有支持订阅与发布功能的客户端都可以在无须做任何修改的情况下, 直接使用键空间通知功能。
因为开启键空间通知功能需要消耗一些 CPU , 所以在默认配置下, 该功能处于关闭状态。可以通过修改 redis.conf 文件, 或者直接使用 CONFIG SET 命令来开启或关闭键空间通知功能(notify-keyspace-events)。


notify-keyspace-events, 当参数值为空串表示关闭, 不为空串为开启, 具体可配置参数值如下:

字符发送的通知K键空间通知,所有通知以 __keyspace@<db>__ 为前缀E键事件通知,所有通知以 __keyevent@<db>__ 为前缀gDEL 、 EXPIRE 、 RENAME 等类型无关的通用命令的通知$字符串命令的通知l列表命令的通知s集合命令的通知h哈希命令的通知z有序集合命令的通知x过期事件:每当有过期键被删除时发送e驱逐(evict)事件:每当有键因为 maxmemory 政策而被删除时发送A参数 g$lshzxe 的别名


相关参考文章   英文    中文翻译



   一句话概括: Redis提供了对存储的KEY 一些事件回调通知功能, 比如KEY 过期,删除等, 回调的方式通过订阅指定频道, 接受回调消息,  由于此功能会消耗一些CPU, 默认是关闭的, 可以通过修改配置文件或者直接在在客户端使用命令进行修改开启。



实现思路:
1.  开启redis的key事件回调功能,并配置回调类型为KEY 失效事件
2.  编写程序订阅对应的频道, 接受回调。
3.  接受到回调,使用分布式锁控制重复执行控制,分发到对应的业务。



接下来,主要是操作过程以及示例, 只会提供部分主要代码样例, 具体请下载例子自行查看代码, 如有不妥,敬请指出, 笔者野路子出身菜鸟一枚,多多指教:

首先,修改Redis 配置文件, 配置参数并重启redis,使配置生效:
notify-keyspace-events Ex



开启配置之后redis将会在key失效时,将消息发送到__keyevent@<db>__:expired频道,其中db代表key所在数据库的index, 比如当前项目使用的dbIndex 是0 ,那么发送的频道topic为 __keyevent@1__:expired    如果你想订阅所有的dbindex 那么订阅配置应该改为通配符方式, topic为 __keyevent@*__:expired  那么你将接受到所有的dbindex中的key过期回调。

到此时很多同学应该就可以完全自己实现, 当然也可以参考下笔者的实现:  

此处有个小细节需要注意: 接收到redis推送的key失效消息, 消息内容里面只会有key的值, 但是并没有key所对应的value, 比如set name zhangsan  那么收到的消息内容只能获取到name ,  并不能获取到zhangsan, 开始我也很不理解为什么不把value一起推送过来,仔细一想,这样貌似也是很合理的,加入一个key存储的一个巨大无比的value,那么将value一起推送到订阅者,后果可想而知。

由此看来redis获取到key虽然能够拿到对应的事件,但想要实现定时任务不同类型业务不同处理,还需要做一些文章将失效的key和业务数据关联起来, 或者实现一个key中存储自定义data的功能, 既然使用了redis ,那么实现起来也是非常简单 , 定义一个map, 将失效的key作为field,业务自定义data作为field的value, 通过key来获取对应value即可。


redis配置类, 主要是链接配置,以及订阅配置:

配置文件: application.properties
#tomcatserver.port=8099server.session-timeout=1800server.context-path=/redis/timer#redisspring.redis.hostName=xxxspring.redis.port=6379  spring.redis.password=xxxspring.redis.pool.maxActive=128  spring.redis.pool.maxWait=-1  spring.redis.pool.maxIdle=-1  spring.redis.pool.minIdle=0  spring.redis.timeout=30000spring.redis.dbIndex=1


配置类:
@Configuration@ConfigurationProperties(prefix = "spring.redis")public class RedisConfiguration {private static Logger logger = Logger.getLogger(RedisConfiguration.class);private String hostName;private int port;private String password;private int timeout;private int dbIndex;@Beanpublic JedisPoolConfig getRedisConfig(){JedisPoolConfig config = new JedisPoolConfig();return config;}@Bean@ConfigurationProperties(prefix = "spring.redis")public JedisPool getJedisPool(){JedisPoolConfig config = getRedisConfig();JedisPool pool = new JedisPool(config,hostName,port,timeout,password,dbIndex);logger.info("init JredisPool ...");return pool;}    @Bean      @ConfigurationProperties(prefix = "spring.redis")    public JedisConnectionFactory getConnectionFactory(){          JedisConnectionFactory factory = new JedisConnectionFactory();          JedisPoolConfig config = getRedisConfig();          factory.setPoolConfig(config);          logger.info("JedisConnectionFactory bean init success.");          return factory;      }          @Autowired    private KeyEventMessageListener keyEventMessageListener;//key event 订阅监听        @Bean    public RedisMessageListenerContainer getRedisMessageListenerContainer(){        RedisMessageListenerContainer container = new RedisMessageListenerContainer();        container.setConnectionFactory(getConnectionFactory());        Topic topic = new ChannelTopic("__keyevent@"+ getDbIndex() +"__:expired");        System.out.println("Redis db index : " + getDbIndex());        container.addMessageListener(keyEventMessageListener,topic);        return container;    }    public String getHostName() {return hostName;}public void setHostName(String hostName) {this.hostName = hostName;}public int getPort() {return port;}public void setPort(int port) {this.port = port;}public String getPassword() {return password;}public void setPassword(String password) {this.password = password;}public int getTimeout() {return timeout;}public void setTimeout(int timeout) {this.timeout = timeout;}public int getDbIndex() {return dbIndex;}public void setDbIndex(int dbIndex) {this.dbIndex = dbIndex;}}


上面主要配置的连接池和工厂, 订阅 topic 为__keyevent@<dbIndex>__:expired   频道, 其中dbIndex 通过配置文件中的db拼接, 具体订阅类KeyEventMessageListener类, 再看KeyEventMessageListener类之前,先说明下几个常量类:

EventConstants类, 主要是用来定义事件相关常量

/** * 事件相关常量 * @author victor * */public class EventConstants {public static final String KEY_EVENT_AUTO_ID = "KEY_EVENT_AUTO_ID"; // event 计数器, 用于生成EVENT_IDpublic static final String KEY_EVENT_DATA_MAP = "KEY_EVENT_DATA_MAP"; //用来存储用户自定义data的 Hashpublic static final String EVENT_KEY = "KEY_EVENT"; // 用来做event redis唯一标识前缀public static final String EVENT_DATA_KEY = "DATA";// hash  中存储data前缀public static final String DEFAULT_EMPTY_DATA = "KEY_EVENT_DATA_EMPTY"; // 空data时常量替换串public static final String EVENT_META_KEY = "META";//用来存储 事件类型前缀}



Event 枚举类, 用来区分事件类型:

/** * 事件类型枚举 * @author victor * */public enum EventEnum implements IndexedEnum<EventEnum>{EXAMPLE(1,"example","示例事件");EventEnum(int index,String code,String name){this.index = index;this.name = name;this.code = code;}private int index;private String name;private String code;private static final ImmutableMap<Integer, EventEnum> INDEXS = IndexedEnumUtil.toIndexes(values());public static EventEnum indexOf(int index){return INDEXS.get(index);}public int getIndex() {return index;}public String getName() {return name;}public String getCode() {return code;}}




消息注册Service以及接口:负责注册事件,移出事件等实现:

/** * 事件服务接口 * @author victor * */public interface IEventService {/** * 注册事件,相同事件不会覆盖,注册成功将返回全局唯一事件ID * @param event  事件 * @param data业务数据 * @return 事件ID,全局唯一 */public String register(EventEnum event,String data);/** * 注册事件,相同事件不会覆盖,注册成功将返回全局唯一事件ID * @param event  事件 * @param data业务数据 * @param seconds 事件执行时间, 单位秒 * @return 事件ID,全局唯一 */public String register(EventEnum event,String data,int seconds);/** * 注册事件,相同事件不会覆盖,注册成功将返回全局唯一事件ID * @param event  事件 * @param data业务数据 * @param seconds 事件执行时间 * @return 事件ID,全局唯一 */public String register(EventEnum event,String data,Date date);/** * 移除事件,如果事件已经被执行,移除无意义,如果事件未执行,则返回移除成功或者失败 * @param eventId  事件ID * @return 是否移除成功,true 表示成功,false表示失败(如事件正在被执行) */public boolean remove(String eventId);}


实现类:

@Servicepublic class EventServiceImpl implements IEventService{private static Logger logger = Logger.getLogger(EventServiceImpl.class);@Autowiredprivate IRedisService redisService;@Autowiredprivate IDistributedLock distributedLock;@Overridepublic String register(EventEnum event, String data) {return register(event, data,1);}@Overridepublic String register(EventEnum event, String data, int seconds) {seconds = seconds <= 0 ? 1 : seconds;data = StringUtils.isNullOrEmpty(data) ? EventConstants.DEFAULT_EMPTY_DATA : data;String eventId = String.valueOf(redisService.incyby(EventConstants.KEY_EVENT_AUTO_ID));Map<String,String> hash = new HashMap<>();hash.put(EventUtils.getDataKey(eventId), data);hash.put(EventUtils.getMetaKey(eventId), event.getIndex() + "");String res = redisService.hmset(EventConstants.KEY_EVENT_DATA_MAP,hash);if(StringUtils.isNullOrEmpty(data) || StringUtils.isNullOrEmpty(hash) || StringUtils.isNullOrEmpty(res)){logger.error("Set " + EventConstants.KEY_EVENT_DATA_MAP + " error . eventId:" + eventId + ",data: " + data + ",meta:" + event.getIndex());}redisService.setex(EventUtils.getEventKey(eventId), event.getIndex() + "", seconds);logger.info("register event :" + eventId);/*ThreadPoolUtil.executor(ThreadPoolEnum.KEY_EVENT_REGISTER).execute(new KeyEventRegisterRunable(redisService,eventId,event,data,seconds));*/return eventId;}@Overridepublic String register(EventEnum event, String data, Date date) {int seconds = DateUtils.calculateDifferenceSeconds(date, new Date());return register(event, data,seconds);}@Overridepublic boolean remove(String eventId) {if(distributedLock.lock(eventId, 5)){redisService.del(EventUtils.getEventKey(eventId));redisService.hdel(EventConstants.KEY_EVENT_DATA_MAP, EventUtils.getDataKey(eventId),EventUtils.getMetaKey(eventId));distributedLock.unlock(eventId);}logger.info("Remove key event error : event is executed!");return false;}}


实现比较简单:
注册事件: 根据事件类型,向redis中新增key, 并设置相关过期时间,然向hash中插入用户自定义data.

移出事件: 直接删除事件, 防止删除事件同时事件正在执行,加锁



订阅监听类:

/** * Key event 频道订阅<br/> * 需要redis版本2.8以上,并且修改conf配置文件参数: <cite>notify-keyspace-events Ex</cite><br/> * 开启配置之后redis将会在key失效时,将消息发送到<cite>__keyevent@<db>__:expired</cite>频道,其中db代表key所在数据库的index * @author victor * */@Component@Configurationpublic class KeyEventMessageListener implements MessageListener{private static Logger logger = Logger.getLogger(KeyEventMessageListener.class);private final RedisSerializer<String> serializer = new StringRedisSerializer();@Autowiredprivate EventHandlerServiceFacotry eventHandlerServiceFacotry;@Autowiredprivate IRedisService redisService;@Autowiredprivate IRedisLock redisLock;@Overridepublic void onMessage(Message message, byte[] pattern) {String key = serializer.deserialize(message.getBody());String eventId = EventUtils.getEventId(key);if(!StringUtils.isNullOrEmpty(eventId)){if(redisLock.lock(eventId, 300)){//must executed in 300Slogger.info("Handle event :" + eventId + " start !");try{handler(eventId);}catch (Exception ex) {logger.error("Key event hadppend bug : eventId:" + eventId + ", message :" + ex.getMessage() , ex);ex.printStackTrace();}finally{//delete data and metaredisService.del(EventUtils.getEventKey(eventId));redisService.hdel(EventConstants.KEY_EVENT_DATA_MAP, EventUtils.getDataKey(eventId),EventUtils.getMetaKey(eventId));redisLock.unlock(eventId);}logger.info("Handle event :" + eventId + " end!");}else{// event was handled or is handlinglogger.debug("Event is handled");}} }private void handler(String eventId){String data = redisService.hget(EventConstants.KEY_EVENT_DATA_MAP,EventUtils.getDataKey(eventId));String meta = redisService.hget(EventConstants.KEY_EVENT_DATA_MAP,EventUtils.getMetaKey(eventId));if(StringUtils.isNullOrEmpty(data) || StringUtils.isNullOrEmpty(meta)){// event was handledlogger.info("Event was handled, eventId:" + eventId + ".");return;}int event = Integer.parseInt(meta);data = data.equals(EventConstants.DEFAULT_EMPTY_DATA) ? null : data;IEventHandlerService service = eventHandlerServiceFacotry.instance(event);if(service == null){logger.error("Event handler not found, eventId:" + eventId + ",data:" + data + ",meta:" + meta + "!");throw new EventNotMappingHandlerException("Event handler service not mapping!",eventId,event,data);}try{service.handle(EventEnum.indexOf(event), eventId, data);}catch (Exception e) {logger.error("Handler event error, eventId : " + eventId, e);throw new EventHandleException("Handler event error.",eventId,event,data);}}}


此类主要完成功能, 接受redis推送的订阅消息, 并且根据key来找到对应的 事件类型, 事件data,  加锁防止重复消费, 通过事件执行工厂根据事件类型获取不同的事件最终业务service.


正常情况, 不同的事件可能各自都有自己不同的事件, 比如订单取消事件 和 商品自动上架两种事件执行的业务逻辑就不同, 所以此处使用多态动态获取不同的事件业务实现实例。

事件业务工厂 EventHandlerServiceFactory类:
/** * 事件处理工厂 * @author victor * */@Servicepublic class EventHandlerServiceFacotry {private List<IEventHandlerService> services;@Autowiredpublic EventHandlerServiceFacotry(List<IEventHandlerService> services){this.services = services;}public IEventHandlerService instance(int event){for(IEventHandlerService service : services){if(service.getEvent().getIndex() == event){return service;}}return null;}public IEventHandlerService instance(EventEnum event){return instance(event.getIndex());}}


IEventHandlerService接口, 定义事件执行接口方法以及获取时间类型接口:

/** * 事件触发服务 自定义 * @author victor * */public interface IEventHandlerService {/** * 执行 * @param eventId * @param event * @param data */public void handle(EventEnum event,String eventId,String data);/** * 事件类型 * @return */public EventEnum getEvent();}



EXAMPLE_EVENT 示例事件实现示例:

@Servicepublic class ExampleEventHandlerServiceImpl implements IEventHandlerService{@Overridepublic void handle(EventEnum event, String eventId, String data) {System.out.println("执行事件:" + event.getName() + ",用户自定义data:" + data);}@Overridepublic EventEnum getEvent() {return EventEnum.EXAMPLE;}}


辅助类EventUtils:

public final class EventUtils {private EventUtils(){}public static String getEventKey(String eventId){return EventConstants.EVENT_KEY + "_" + eventId;}public static String getDataKey(String eventId){return eventId + "_" + EventConstants.EVENT_DATA_KEY;}public static String getMetaKey(String eventId){return eventId + "_" + EventConstants.EVENT_META_KEY;}public static boolean isEvent(String key){if(!StringUtils.isNullOrEmpty(key) && key.toUpperCase().indexOf(EventConstants.EVENT_KEY + "_") == 0){return true;}return false;}public static String getEventId(String key){if(isEvent(key)){String eventId = key.replace(EventConstants.EVENT_KEY + "_", "");return eventId;}return null;}}



上述代码中, 部分类可能没有, 比如redisService : 为redis操作命令封装   distributionLock :分布式锁,笔者博客前面有自己实现的redis锁。


调用注册事件:注册一个类型为EXAMPLE类型事件,自定义data为 hello,example字符串, 并且在60秒之后执行:

@Autowiredprivate  IEventService eventService;
eventService.register(EventEnum.EXAMPLE,"hello , example !" ,60);





如果增加一种事件, 即可添加枚举,  并且针对此种事件,编写对应的service,那么接受到推送key之后会根据相应的事件类型来调用对应的实现service. 此处不再贴示例代码, 笔者对当前代码做过相应测试, 同时像redis注册6000条事件, 并且指定执行时间为同一时间,同时运行2个项目模仿分布式, 运行结果:  无重复重复执行事件现象, 无丢事件情况, 上述笔者配置大概在1分钟左右能够执行完成。 执行能力跟redis配置相关, 上述针对redis操作效率, 线程数等代码部分没有做过多的优化。   点击下载示例

阅读全文
0 0