主题
遵循各种实用的设计规则,帮助我们编写安全和高性能的并发应用程序。
内容
基础知识
1、并发性和线程安全性的概念
- 驱动因素:资源利用率、使用权公平性、开发便利性;
- 线程特点:线程是操作系统调度的基本单位。独有的:栈、局部变量和程序计数器;共享的:进程的内存地址空间,包括堆、全局变量等;
- 线程执行的需求:细粒度的数据共享机制。用来避免共享对象被不同线程操纵导致不可预测的后果;
- 线程的劣势:
- 不同线程的执行时间和顺序不可预测,不加以同步机制控制时,就会引发安全性问题(不可预测的效果发生)和活跃性问题(预期效果最终不会发生,例如死锁);
- 线程需要上下文切换和恢复的调度开销,如果系统在线程调度的开销大于线程执行的开销,就会导致性能问题;
- 采用同步机制会压制编译器的某些优化策略,使得例如内存缓存区无效和共享内存总线流量增大等增加开销的问题无法得到优化;
避免并发危险的规则
- 线程安全的核心在于,管理数据的共享状态和可变状态的访问操作;
- 线程安全类只有当类仅包含自己的状态时,才有意义。因为线程安全性是只和状态相关的,是对封装的代码的状态进行可控管理,所以对于不可控的外部状态,线程安全也就无从说起;
1. 对象的修改
- 竞态条件:意味着条件的状态变化因为线程的竞争执行而变得不可预期。如果动作的执行取决于一个可能因为线程交替执行的时序而产生变化的条件时,该动作的发生就会导致无效甚至糟糕的结果。这种“先检查,后执行”的执行流程严重受竞态条件影响;
- 线程安全性:多个线程访问时,类总能保证其状态和行为可控地产生正确的效果;
- 由线程安全性的定义可以引申出:
- 无状态的类一定是线程安全的;这种类既没有自己的数据域的状态,也没有引用其他域,产生的临时状态为线程私有,不会有状态的共享也没有状态变化的对外影响,自然没有安全性问题;
- 原子操作:如果类中包含状态,当一个线程对数据执行读取、修改和写入或者“先检查后执行”等复合操作时,如果出现其他线程介入状态访问的操作,都会产生并发错误。避免错误的一个方法就是使用原子操作,让这种复合操作不可分割地、排他地执行,不让其他线程介入;
- 加锁:尽管原子性的线程安全对象可以保证复合操作对一种状态的可控变化管理,但是如果有多个状态,就算都使用线程安全对象进行管理,由于管理是独立的,当这些状态之间互相依赖时,仅保护单个对象变化的安全性而不保证依赖的相关对象同步变化时,整体的线程安全性一样会被破坏。java提供锁机制来支持多状态的原子操作:
- 内置锁:用关键字synchronized 修饰想要锁住的对象引用和同步的代码块,来构建同步代码块。例如修饰变量引用、静态方法、普通方法,分别锁住变量、类和调用方法的对象。线程在进入和退出代码块时自动获取和释放锁,锁被占用时,其他线程则阻塞获取;
- 重入:线程试图获取自己持有的锁时(例如子类重写同步方法并在其中调用父类同步方法)如果这个锁是可重入锁,就会获取成功进入代码块,否则阻塞产生死锁。重入代表获取锁的操作的粒度是线程而不是调用;
- 粗粒度的加锁:多个状态被封装在一个对象中,并使用对象的内置锁来保护这些状态。使用同一个锁来保护多个状态变量,来保证这些状态可以同步更新,使不变性条件不被破坏;
- 合理加锁:加锁时,保证安全性的前提下,合理地安排同步代码块的体量和同步的方法,来平衡简单性和性能。对于需要较多计算和较长时间的操作,一定不要加锁;
2. 对象的共享
- 重排序:在没有同步的情况下,编译器、处理器和运行时都可能出于优化的目的对指令进行重排序,使得指令的执行顺序不可预期,从而导致不同线程对状态的修改和访问不一致;
- 内存可见性:线程安全地修改对象的状态后,确保让其他线程看到状态的变化。通过显式的同步和类库内置的同步确保对象被安全地发布;
- 失效数据:当不同线程在没有同步的情况下进行修改和访问时,一个线程可能读取到另一个线程修改之前的数值,称为失效数据。失效数据的出现是随机的,可能不会同时获得多个状态失效数据;
- 非原子的64位操作:java要求读取和写入操作都必须是原子的,但是对于非volatile的64位操作(double和long),jvm运行将之拆分为两个32位操作,因此在没有同步且读写在不同线程时,会产生其他线程读取到失效的低32位数据或高32位数据。64位操作必须用volatile或者锁保护起来;
- 保证内存可见性:加锁不仅可以提供互斥性,还可以保证线程在执行同步块前看到所有其他线程在同步块执行的操作,实现内存可见性;
- volatile变量:实质是保证该变量不会缓存在寄存器或其他处理器看不见的位置,它的状态更新在主存中,在状态变化后就会通知其他线程,保证了修改后其他线程一定可以看得到。被称为“轻量级的synchronized”,不同于锁,volatile不会造成线程阻塞。它可以提供变量的内存可见性,但不保证原子性。因此,只有在有限的情形下适用volatile才能保证线程安全性:1.该变量的写操作不依赖与当前值或者只有一个线程允许写操作,也就是写与读不互斥;2.该变量不会和其他状态一起纳入不变性条件;3.访问变量时不需要加锁;因此,volatile变量的使用场景通常是判断状态标记;
- 失效数据:当不同线程在没有同步的情况下进行修改和访问时,一个线程可能读取到另一个线程修改之前的数值,称为失效数据。失效数据的出现是随机的,可能不会同时获得多个状态失效数据;
- 发布与逸出:让一个对象的状态被其他代码或外部作用域使用,就是发布这个对象。例如方法返回一个对象的引用、将对象引用保存在公有静态变量中对所有对象可用等。发布内部状态会破坏对象的封装性,并对线程安全造成一定风险,但有时不得不这么做。如果将一个不想发布的对象发布,就称之为逸出;
- this引用在构造函数中逸出:当在构造函数的内部创建和发布实例时,其本身实例也隐式地逸出了,而且此时的实例是未构造完成的状态。当且仅当构造函数返回时,对象才处于可预测和一致的状态。因此内部逸出的构造函数是不正确的构造函数。例如,在构造函数内启动一个线程。因为线程创建时this会被线程共享,所以应该在构造函数创建完线程后在返回后才进行启动,才能保证不逸出;
- 使用工厂方法防止this引用逸出:构造一个私有的构造函数,来防止作用域外泄,同时构造一个公有的工厂方法,让它调用这个私有构造函数并返回构造好的对象,供外部使用;
- 线程封闭:当对象数据被封闭在一个线程中不进行共享,就可以自动地实现线程安全性。例如jdbc从连接池分配不同的connector对象给不同的线程,避免了对象共享,隐式地实现了线程封闭。线程封闭是程序设计和实现需要考虑的一个因素,开发者可以借助局部变量或ThreadLocal类,来保证封装对象不逸出;
- Ad - hoc 线程封闭:指维护线程封闭的职责完全由程序实现来承担,也就是靠程序设计来保证封闭性。因为没有任何语言特性,如可见性修饰符或局部变量等,能保证将对象封闭在某个线程中,因此这种技术的封闭性非常脆弱。一般设计单线程的子系统来替代使用这种技术;
- 栈封闭:指使用方法中的局部变量来引用方法内创建的对象,同时不发布局部变量的引用。由于局部变量放置在线程独有的栈之中,那么局部变量就不会被其他线程访问,只要不将这个局部变量的引用发布,就保证了对它所引用对象的线程封闭性。如此保证栈的封闭性就能保证线程的封闭性。例如,基本类型的局部变量没有任何引用,使用它就必定可以保证栈封闭性,进而保证线程封闭性;
- ThreadLoacl类:该类让每个线程保存对象的独立副本,避免对象的共享。该类提供的get和set访问器都只对线程保存的副本进行操作,副本保存在Thread对象中,线程终止则进行回收。该类通常用于避免全局变量和可变的单例实例进行共享,只要将他们放入ThreadLocal类中即可。例如,对jdbc的连接对象的操作。该项技术还可以用在需要频繁使用临时对象的情况,通过ThreadLocal指导的副本进行操作来避免频繁地申请临时对象;
- Final域:使用final关键词修饰可以构造不可变的对象,还可以保证对象的初始化安全性,不用担心逸出,天然拥有线程安全性。将不可变的对象修饰为final是良好的编程习惯。每当需要将多个相关的状态以原子方式进行操作时,就可以考虑创建一个不可变的类来保存这些状态,通过含参构造创建新实例来更新里面的状态,同时将其实例声明为volatile来保证对其他线程的可见性;
- 安全发布:考虑到可见性问题导致的不一致性,如果只保证对象的引用可见而不保证对象的状态可见,就不足以确保对象的安全发布;
- 不可变对象可以任意地发布:不可变对象的初始化安全性以及状态的不可变相当于一种隐式的可见性了,即使不进行同步,也能安全地访问;
- 可变对象必须使用同步:可变对象必须使用如下安全的构造并发布,这可以保证”当时“的可见性。
因为后面对象的状态会修改,所有要保证线程安全或者使用锁保护起来;
- 安全共享:发布一个对象后必须明确说明对象的访问形式,以免引起并发错误。综合所述,安全共享的策略如下:
- 线程封闭:线程封闭的对象只在一个线程中拥有和使用;
- 只读共享:只读对象可以被多个线程访问但不允许线程修改;
- 线程安全对象的共享:线程安全的对象在其内部实现线程安全性,通过公有的访问器接受访问,而不需要进一步的同步;
- 保护共享:通过锁来保护多线程共享的对象;
2、构建线程安全类
- 实例封闭:将一个对象封装到另一个对象中,只通过这个对象的方法来访问这个被封装的对象,如果对象可变则再加锁或同步机制协同工作。例如,将对象封装为类的私有成员或局部变量、使用线程封闭该对象,从而限制作用域及访问路径。从限制对象作用域的层面上,来保证访问对象的线程安全性,从而减轻了设计线程安全类的难度。对于非线程安全的各类容器,可以使用类库内置的很多线程安全的包装器工厂类,使用装饰器模式将非线程安全的对象封装到同步的对象中,只要该包装器对象引用唯一就可以实现线程安全。以上就是设计线程安全类最简单的方法–实例封闭;
- java监视器模式:将对象的所有可变状态封装起来,并通过对象的内置锁保护。典型应用是,只能通过对象的同步方法去访问私有变量,当需要获取对象信息时,返回其副本信息以免逸出。特点是粗粒度、简单;
- 线程安全性委托:将类的线程安全性委托给线程安全的类对象,并且保证对象的引用是线程安全的。一个典型的做法是,将对象状态通过线程安全的对象来保存(例如构建一个不可变的类或者类库内置的线程安全类),同时封装到final修饰的私有成员中,保证引用的线程安全性,这样就可以通过公有的普通方法进行访问;
- 状态独立:如果多个变量的状态彼此独立,即不会增加其他不变性条件,就可以将类的线程安全性委托给这些状态变量;
- 委托失效:一般而言,状态之间存在依赖,仅靠委托给这些状态变量会使安全性委托失效,要对依赖的状态变量进行加锁的复合操作;
- 状态变量的安全发布:只要这个状态变量是线程安全的,没有任何不变性条件约束它的值,而且没有任何约束性的状态转换,就可以安全地发布它;
- 在现有的线程安全类中添加功能:
- 1.修改源码:这需要理解其中的同步策略,好处是便于维护。如果是内置类库无法访问源码进行修改;
- 2.扩展子类:需要现有类的状态公开,让子类进行利用和扩展同步策略,好处是便捷。这将同步策略分散到不同的类中,脆弱不便维护。同时,一旦底层超类改变,子类的安全性则不能保证;
- 3.客户端加锁:构建辅助类来添加功能,类中使用现有的安全类对象并使用它保护其状态的锁来保护这段代码。因此必须知道对象使用的锁,才能保证添加功能的同步。这样将同步策略分散在无关的类上,更加脆弱;
- 4.组合:使用java监视器模式将需要使用的对象封装到辅助类中,使用引用操作该对象,相当于将线程安全性反向委托给辅助类。辅助类则使用自身的内置锁来提供一致的锁,这样不用管使用的对象是否线程安全;
构建和验证线程安全的规则
3、组装成更大型的线程安全类
4、基本的并发构建模块
将线程安全性委托给现有的线程安全类,让它们管理类的所有状态,是最有效的构架线程安全类的策略。
- 同步容器:包括Vector、Stack、HashTable,Collections.synchronizedXXX提供了相应容器的封装器,它们使用工厂方法创建同步容器。其同步的策略是:将对象状态封装起来,需要线程安全的方法都使用synchronized同步。线程安全性是通过将对状态的访问串行化来实现的,其主要目的是线程安全性,但这严重降低了并发性能;
- 问题:同步容器自身的方法都是线程安全的,但是当多个线程并发的执行复合操作(例如迭代、跳转、条件运算等)会导致并发错误,需要客户端进行额外的加锁处理来保证复合操作的并发正确性。然而加锁需要细致,因此麻烦。对于一些隐性迭代器如for-each循环中的并发性也难以维护;
- 缓和:使用客户端加锁来保证对象的并发同步,但是需要对每个操作都进行大量的加锁,嵌套操作的情况下容易导致死锁的产生,并且极大消耗了程序性能和降低CPU利用率。替代的方法是,操作都在克隆副本(克隆时锁保护)上进行,这样不影响其他线程的操作,不过其中有一定的开销,视需求而定;
- 并发容器:并发容器ConcurrentXXX是专门针对并发而设计的容器,用它们替代同步容器可以提高程序的可伸缩性并降低风险;
- 与同步容器的区别:同步容器会导致多个线程中对容器方法调用的串行执行。因为它们的每个操作都是以容器自身对象为锁,会降低并发性能。所以在需要支持并发的环境中,可以考虑使用并发容器来替代。并发容器则提供了一些在使用同步容器时需要自己实现的复合操作,通过原子方式避免多线程的等待,提高并发性能;
- ConcurrentHashMap: 替代hashtable和synchronizedMap,使用比同步哈希表更细粒度的锁机制–分段锁,容许任意数量的线程进行并发读取和一定数量的并发修改(弱一致性:容忍并发的修改而不会抛出异常)。但是,它并不提供对Map的加锁独占访问,因此无法通过客户端加锁来实现新的原子操作,此时一般使用具有更多原子操作的ConcurrentMap来替代;
- CopyOnWriteArrayList:替代同步List和同步Set。它通过正确地发布一个事实不可变的对象来避免额外的进一步的同步。每次修改都会创建并发布一个新的容器副本,显然具备一定的开销,仅当迭代操作远多于更新操作时使用该容器;
- 阻塞与中断:例如阻塞队列的put和take等阻塞方法会抛出InterruptedException受查异常。调用阻塞方法会抛出中断异常,必须要进行处理。处理方式为:要么传递异常给方法的调用者,可以先捕获进行简单清理再抛出;要么在不能抛出异常的情况下,捕获异常并调用当前线程的interrupt方法,它会恢复中断状态,并通知调用栈的高层此处引发了中断;
- 生产者-消费者模式:生产者和消费者共享一个阻塞队列各自执行流水线的串行工作;
- 工作密取模式:消费者有各自的阻塞双端队列并专注工作于此,减少了在同一队列进行竞争。同时在处理完自己的队列后,还可以从其他队列的队尾进行处理,所属者则是处理队头,因此不会竞争,较之前者模式减少了阻塞,提高线程工作效率,具有更强的伸缩性。;
- 同步工具类:类似于阻塞队列,可以协调生产者和消费者等线程之间控制流的类。例如信号量、栅栏、闭锁等任何可实现这种作用的对象;
- 闭锁:用于等待一组事件发生后才允许其他操作执行,例如等待一些资源初始化后才允许执行后续操作。CountDownLatch是一种灵活的闭锁,用来使多个线程等待一组事件完成。它使用计数器来设置需要等待的事件数量,当事件发生则减1,直到最终为0则让等待阻塞的await方法执行;
- FutureTask: 用来异步地开启一个需要长时间计算或者提前计算的操作,先定义并导入相关操作的线程来创建对应的实例,再启动该实例对象的run方法启动异步操作,然后通过实例的get方法阻塞地等待预设任务完成或者异常抛出。如果将后续操作放置在get后则可以起到闭锁功能;
结构化并发应用程序
1、 识别可并行执行的任务
程序完成的工作和功能可以抽象为一个个离散的任务,它们可以:
- 串行地单线程执行任务:将需要完成的任务按顺序地串行执行。响应慢,系统吞吐量低,资源利用率不高;
- 显式创建线程执行任务:在主线程中为可分离执行的任务显式创建新新线程。
- 线程的生命周期开销高–如果线程处理简单请求,则开销高的劣势凸显;
- 资源消耗–特别是内存,如果已有线程可以使所有处理器忙碌,再添加线程只能让某些线程,从而闲置占用资源;
- 稳定性低–每个平台都有不同的线程最大数量限制,受诸多因素影响,例如jvm启动参数、Thread的构造函数中请求的栈大小和底层操作系统对线程的限制。如果无节制的创建线程,会引发异常并难以处理;
在任务执行框架中如何执行
- 在java类库中,任务执行的主要抽象是Executor,不是Thread;虽然Executor只是个简单的函数接口,但它为灵活的异步执行框架提供了基础。
- 提供了一个标准方法来解耦任务提交与执行,用Runnable表示任务;
- 提供了对生命周期的支持,以及统计信息收集、程序管理和性能监视;
- 基于生产者-消费者模式,提交相当于生产者,执行相当于消费者。是生产者-消费者模式的最简单方式;
- 执行策略:Executor将任务提交和执行解耦,使得执行策略的定制更加可控,包括:
- 在哪个线程以及何时执行;
- 任务以什么顺序执行(FIFO、LIFO、优先级);
- 等待和并发的线程有多少;
- 如何选择执行或拒绝的线程;
- 执行任务的前后应当进行的操作;
如果Executor的执行策略被定制,则是抽象为ExecutorService类;
- 线程池:一种ExecutorService类。相较于为每个任务创建一个新线程来执行,在线程池中执行任务能够重用已有线程来降低线程创建开销,并且提高了响应性,避免了为单个任务显式创建线程带来的稳定性问题。通过Executors中的newXXXThreadPool等静态工厂方法创建的各类不同的线程池ExecutorService(相对于Executor增加了管理其生命周期的功能),可以方便地添加例如信息统计、性能监视等功能,来管理任务的执行;
- 携带结果的任务Callable和Future:与Runnable的run方法返回void不同,Callable可以返回结果或抛出受查异常,从而满足返回计算结果任务的需要,例如数据库查询、网络资源获取等。同时,Future可以做到前两者不能完成的任务状态获取和延迟计算操作,例如任务取消、检查是否完成等等;
2、提前结束任务和线程
一个行为良好的软件与勉强运行的软件之间的主要区别是:前者可以完善的处理失败、关闭和取消等过程。这涉及到了如何在生命周期正常运行、中断和关闭的协调处理;
- java没有提供安全地终止线程和任务执行的技术,但是提供了中断这种协作机制让一个线程终止另一个线程。人为地制定一些协作式的机制来使终止请求和代码遵循:
- 约定的取消标志:通过设置volatile型的标志变量,约定让线程每次执行操作前都进行查看,以及时从操作中跳出;缺点:无法及时通知阻塞型操作;
- 线程内置的中断标志:每个线程都有boolean型的中断标志,通过线程内置的interrupt方法可以将其设为true,线程会在合适的取消点结束工作而不是立刻终止。中断是实现取消策略的最佳选择。阻塞操作会先隐式检测中断标志再进行,而不像约定的取消标志是显式地在操作完成后才查看的,因此避免了后者的局限;
- 线程中断的处理:1.传递异常,让该方法也成为可中断的阻塞方法,调用该方法者处理中断;2.恢复中断状态,调用interrupt方法让调用栈的上层代码去处理中断;
- 取消策略:在中断线程之前,应该了解其中断策略。外部通过调用自定义线程类指定的取消方法,来取消任务或其拥有的线程,而不要直接在外部调用interrupt方法,因为不清楚该类的取消策略;
- 专门的取消线程:将工作任务的执行与取消分开为两个线程,让专门的线程来执行指定工作线程的中断策略,可以避免工作任务不及时响应中断的不良效果。一般使用了限时的join来回收工作线程,因此无法判断回收时工作线程是正常返回还是超时返回;
- Future取消:在任务执行框架中submit提交工作任务,它会创建线程来执行工作,同时返回描述任务的Future,使用其中的cancel方法来及时取消对应任务。显然,这是上述方法的完善版本;
- 封装非标准的取消操作:
- 1.线程类重写interrupt:对于那些不可中断的阻塞操作,即不会响应中断标志的操作,通过重新interrupt方法关闭影响阻塞操作退出的相关操作,来达到取消任务的效果。例如在重写方法中取消socket连接,可以关闭socket的阻塞读操作;
- 2.Future把持任务取消:使用newTaskFor静态方法创建的Future,定制该Future改变其cancel行为;
取消与关闭等操作
3、任务执行框架的高级特性
4、提高单线程子系统的响应性
活跃性、性能与测试
1、避免一些使程序无法执行下去的活跃性故障
2、提高并发代码的性能和可伸缩性
3、测试并发代码的正确性与性能的技术
高级主题
1、显式锁
2、原子变量
3、非阻塞算法
4、如何开发自定义的同步工具类
分布式系统
设计理论
CAP理论
数据强一致性与服务高可用性不可兼得
BASE理论
BA:基本可用 – 服务基本可用但存在质量损失;
S:软状态 – 允许有限时间内数据中间状态的存在;
E:最终一致性 – 短时间内最终实现数据的一致性;
负载均衡
- 四层负载均衡 – 负载均衡器在传输层TCP第一次握手后只转发请求;
- 七层负载均衡 – 负载均衡器在应用层分别在两端建立连接,转发所有数据;
- 负载算法:轮询、随机、加权的轮询和随机、最少连接数、最快响应时间;
缓存机制
- 缓存更新:缓存与数据库的更新顺序;
- 缓存过期清理:主动删除(线程定期删除过期数据)与被动删除(再次访问时检查过期);
- 缓存淘汰机制:最近最少使用、最近使用次数最少;
- 缓存穿透:某些数据在缓存和数据库中都没有,就在缓存设置一个短期的特定的值,避免后续无效的访问数据库
- 缓存雪崩:大部分缓存同时失效导致大量数据库访问,可以设置不同的过期时间、使用同步锁限制数据库访问;
异步处理
避免等待RPC调用结果导致的长时间响应,将请求封装为消息放置在分布式消息队列中,先返回调用再推送调用结果;
高可用
- 限流:漏桶、令牌桶等算法限制接收的请求数量;
- 熔断:子服务不可用时,设置短期不可用标志,超时再尝试访问并自动恢复;
- 降级:并发过高时限制某些服务的功能;
容错机制
- 服务调用失败的不同处理;
- 分布式锁与状态机;
核心技术
- 分布式服务调用RPC框架:跨语言调用型(gRPC和Thrift)、服务治理型(Dubbo和Motan);
- 分布式消息队列;轻量级(Redis)、专业(ActiveMQ \ RabbitMQ)、海量消息处理(Kafka);
- 分布式缓存:MemCached、Redis;
- 分布式锁:Redis、Zookeeper;
开源框架高并发源码分析
Dubbo协议
Netty与Tomcat的线程模型
秒杀系统设计分析
- 概述:首先,使用缓存减少数据库访问,其次,根据将流量尽量拦截在上流的原则,使用相关的限流机制减少不必要的请求流量流入系统。当系统接受的请求数量较多时,使用分布式队列对流量进行削峰处理,避免高并发流量同时落在数据库的读写访问上。最后,需要一个后台服务来消费队列的请求,实现请求的异步处理;
- 结构:客户端通过各类浏览器发起请求到服务端,服务器端使用反向代理Nginx进行负载均衡,将请求分流。请求被分到API网关层,如果请求流量较大,由于RPC调用服务层压力,所以会进行限流或降级。服务层基于微服务实现,服务之间通过RPC相互调用,此处使用缓存来减少数据库的读访问,使用队列层来减少并发的数据库的写访问,因此需要使用队列层完成流量削峰和异步处理;
- 限流机制:在负载均衡器Nginx上进行相关设置来整体限流。在API网关层的限流有两种:基于URI和用户的限流,使用Filter或者AOP实现拦截每个API方法的请求并进行限流处理,可以使用类似于guava的工具包、java的信号量Semaphore和Redis计数来实现;
- 缓存的使用:前端静态页面的缓存一般可以通过Ngnix和CDN实现,后端可以使用Redis或者MemCache实现;
- 分布式锁的使用:一般可以基于数据库、Redis和Zookeeper实现,其中基于Redis的实现主要是利用了其单线程特性;
- 队列削峰与异步处理:一般可以通过RabbitMQ、Kafka和Redis的列表结构实现消息队列;