• 《谷粒商城》总结篇


    一、压力测试

        通过压力测试,我们有希望找到使用其他测试方法难以发现的错误,如内存泄漏、线程不安全。有效的压力测试应用的关键条件有:重复、并发、随机变化。

        压力测试主要关注的性能指标有吞吐量、响应时间、错误率等。

         ● 吞吐量:单位时间内系统处理请求的数量。

         ● 响应时间:从客户端发起请求开始,到客户端接收到服务端的响应为止,整个过程耗费的时间。测试员通常会关注响应时间的平均值、中位数、最小值、最大值、第90百分位数、第99百分位数等等。

         ● 错误率:结果出错的请求占所有请求的比例。

    二、性能调优

    1 给服务分配更大的内存

        搭建分布式集群,理论上可以使内存无限大。

    2 引入Nginx实现动静分离

    2.1 动静分离的思想

        客户端向服务端请求的资源分为静态资源和动态资源,其中静态资源包括js文件、css文件、图片等,无论请求多少次,返回结果都是一样的。

        引入Nginx可以实现资源的动静分离:由Nginx服务(服务器或集群,下同)来处理对静态资源的请求,让业务服务专注于处理对动态资源的请求,这么做可以有效地提升系统的吞吐量。

    2.2 客户端请求的响应过程

        1. 客户端向服务端发送请求。

        2. Nginx服务(通过监听端口)首先收到来自客户端的请求,将静态资源返回给客户端。

        3. Nginx服务做反向代理,将动态请求转发给网关服务。

        4. 网关服务再根据路由规则,将请求转发给业务服务。

        5. 请求被处理后,给客户端返回响应。

    3 优化数据库查询

    1) 尽量避免在循环中操作数据库。

        可以在准备数据时一次查询所有可能用到的数据,再在服务层对数据进行过滤。

    2) 尽量不采用联表查询,而是采用单表查询+代码层组装的方式。

         ● 单表查询SQL更容易被复用,缓存利用率也较高。

         ● 联表查询时,因表结构变动导致SQL需要做同步修改的可能性较高。

         ● 两个大表做联查的效率可能很低。

    4 引入Redis实现缓存

    4.1 缓存技术的思想

        将一些经常使用数据(从数据库中查出后)保存在内存中,当再次想要获得这些数据时,就可以直接从内存中返回。

    4.2 读模式和写模式

         ● 读模式:当服务端收到一个查询请求时,会先去查缓存,如果缓存命中,就直接从缓存中返回查询结果;如果缓存不命中,就去查数据库,然后将查询结果写入缓存。

         ● 写模式:当服务端收到一个修改请求时,在修改数据库中的数据后,需要对缓存中的数据做同步修改。

    4.3 缓存过期

        我们在内存中创建缓存记录时,如果不对其设置超时时间,除非手动删除,这条记录将永远存在于内存中,很容易造成内存泄漏。

        实现缓存过期的方式有两种:被动方式和主动方式。两种方式需要结合起来使用。

         ● 被动方式:当服务端收到查询请求去查缓存时,如果查到的是一条已过期的缓存记录,就删掉这条记录。

         ● 主动方式:每隔一段时间,随机选取一些缓存记录进行查验,删除其中已过期的记录。

    4.4 缓存穿透、缓存雪崩和缓存击穿

         ● 缓存穿透:当客户端请求一些数据库中不存在的数据时,如果不把查询的空结果写入缓存,那么对这些数据的请求每次都会打到数据库,缓存就失去了意义。

            解决方法:将查询数据库得到的空结果写入缓存。

         ● 缓存雪崩:在高并发场景下,某一时刻,大量缓存记录同时失效,对这些缓存数据的请求会在一瞬间全部被打到数据库,数据库很可能会因瞬时压力过大而崩溃。

            解决方法:设置缓存记录的超时时间时,在设定时间的基础上增加一个随机时长。

         ● 缓存击穿:一些"热点"数据,在某个时刻,可能会被许多客户端同时请求,如果此时缓存记录正好失效,大量的查询请求会全部被打到数据库,可能导致数据库崩溃。

            解决方法:加锁。同一时间只允许一个线程进行查询,其他线程需要等待。

    4.5 缓存一致性问题

        缓存一致性问题指的是:数据库中的数据更新后,缓存中的数据要和数据库中保持一致。

        为什么这会是一个问题?

        数据库中的数据更新、缓存数据同步更新,两个步骤不能通过一个原子操作完成,这样在高并发场景就可能出现各种问题。

        从设计的角度来讲,我们放入缓存的数据不应该是对实时性、一致性要求很高的数据,所以我们只要做到下面两点就可以满足大部分业务场景了:

    1) 设置缓存超时时间。

    2) 数据库中的数据更新后删除缓存。

        如果一定要解决缓存一致性问题,业内通常有两种解决方案:

    1) 延时双删。

        删除缓存记录 => 更新数据库数据 => 等待1~5s => 再次删除缓存记录

         ● 为什么要删除缓存记录而不是直接更新?

            多个线程先后更新数据库数据后,它们不一定会按顺序更新缓存数据,而删除操作不会受到顺序影响。

         ● 更新数据库数据前为什么要删除缓存数据?

            防止数据库数据更新成功后,某些原因导致缓存数据删除失败,缓存数据失真。

         ● 更新数据库数据后为什么要删除缓存数据?

            更新数据库数据前,可能有其他线程查到了旧数据,在更新数据库数据后,又将旧数据写入了缓存。

         ● 为什么要等待1~5s?

            等待查到旧数据的线程向缓存写数据完成、等待数据库主从复制完成。

    2) 订阅数据库变更日志。

        当数据库中的数据被修改时,数据库生成一条变更日志(Binlog),我们可以订阅变更日志,拿到具体的变更操作,据此删除对应的缓存记录。

        目前比较成熟的订阅数据库变更日志的开源中间件:Canal。

        注意,无论是延时双删策略还是订阅数据库变更日志策略,都只能保证缓存的最终一致性,不能保证强一致性。对实时性要求较高的数据,就应该直接去查数据库。

    4.6 保证原子操作

        我们希望能够组合多个命令,让这些命令能够在一个原子操作内执行。

        Redis给出的解决方案是:支持执行Lua脚本。

    5 使用异步

    5.1 自定义线程池

        线程池的七个参数一般怎么设置?

         ● int corePoolSize,核心线程池大小。

            根据经验,假如服务器的CPU个数为N,

            对于CPU密集型的任务,将线程数设为N+1。

            对于IO密集型的任务,将线程数设为2N。

            对于计算和IO操作都比较多的任务,应考虑使用两个线程池,分别处理计算和IO操作。

            对于计算和IO操作都比较多且不可拆分的任务,采用算式 num=N×(任务总耗时/计算耗时) 来计算。

         ● int maximumPoolSize,最大线程池大小。

            与核心线程池大小保持一致,减少任务处理过程中创建和销毁线程的开销。

         ● long keepAliveTime,活跃时间。

            因为核心线程池和最大线程池大小保持一致,所以设多少都可以。

         ● TimeUnit unit,时间单位。

            因为核心线程池和最大线程池大小保持一致,所以设多少都可以。

         ● BlockingQueue workQueue,阻塞队列。

            必须是有界队列。

         ● ThreadFactory threadFactory,线程工厂。

            使用默认的Executors.defaultThreadFactory()就可以。

         ● RejectedExecutionHandler handler,拒绝执行处理程序。

            一般使用默认的AbortPolicy就可以。

            对于不容许任务失败的场景,使用CallerRunsPolicy。

            对于无关紧要的任务,处理异常的收益很低,可以使用DiscardPolicy。

            对于时效性比较强的任务,比如发布消息,可以使用DiscardOldestPolicy。

    5.2 Java内置线程池

        虽然建议使用自定义线程池,但在写一些测试代码的时候,使用Java内置的线程池还是很方便的。

        工具类Executors中提供了四种内置的线程池:

         ● SingleThreadPool:单线程线程池。

         ● FixedThreadPool:定长线程池。

         ● CachedThreadPool:可缓存线程池。

         ● ScheduledThreadPool:定时调度线程池。

        创建内置线程池示例:

        ExecutorService executorService = Executors.newFixedThreadPool(10);

    5.3 异步编排

        异步编排的关键点有两个:

    1) 开启一个新线程执行异步任务,可以使用线程池。

    2) 可以设置收束点,等待所有的异步任务完成后再往后执行。

    5.4 使用消息队列

        消息队列通常有三个使用场景:异步、解耦、流量控制(削峰)。

         ● 异步:对于页面请求,只需要将消息发送给消息队列,就可以立即返回。

         ● 解耦:消息的发送方和接收方可以是两个毫不相干的系统。

         ● 流量控制:消息接收方可以自定义接收消息的规则,在大流量下保证最后落到数据库的请求数不超出数据库的处理能力。

        交换器共有四种类型:direct、fanout、topic、headers。

         ● direct:交换器将消息发送给绑定的、队列名与路由键(Routing Key)完全一致的单个队列。

         ● fanout:交换器将消息发送给绑定的每个队列。

         ● topic:交换器将消息发送给绑定的、队列名与路由键匹配的队列。队列名由若干个单词组成,单词之间使用"."隔开。路由键可以使用两个通配符:"#"和"*","#"匹配0个或多个单词,"*"匹配一个单词。

         ● headers:在匹配规则上与direct相似,但是性能差很多,现在基本上不用了。

    5.5 消息可靠性问题

         ● 消息丢失。

            场景一:消息发出后,由于网络问题没有抵达服务器。

            场景二:经纪人收到消息后宕机。

            场景三:自动ack情况下,消费者收到消息后宕机。

            解决方法:

            1. 在数据库中记录生产者发送的每条消息,每条消息有唯一的ID进行标识。

            2. 开启确认回调,经纪人收到消息后向生产者发送一条确认ack,生产者收到确认ack后,变更消息状态为发送成功。定期扫描数据库,重发发送不成功的消息。

            3. 同样的,消费者消费消息后,向经纪人发送一条确认ack,经纪人收到确认ack后,再将消息从队列中删除。

         ● 消息积压。

            场景一:消费者宕机。

            场景二:发送者发送流量太大。

            解决方法:

            1. 上线更多消费者。

            2. 先将消息记录到数据库中,以后再慢慢处理。

         ● 消息重复。

            场景一:消息消费成功,但生产者没有收到确认ack,消息被重新发送。

            场景二:消费者消费消息成功,但经纪人没有收到确认ack,没有将消息从队列中删除,消息被重复消费。

            解决方法:

            1. 使用防重表,每条消息都有唯一的ID进行标识,消费者消费消息之前先查防重表,如果存在该消息的消费记录,就不再处理。

            2. 标识消息是否是第1次发送。

    5.6 延时队列

        1. RabbitMQ可以对队列和消息分别设置消息的超时时间(TTL),超时未被消费的消息称为死信。

        2. 我们可以创建一个没有任何消费者的队列,每条路由到该队列的消息,过一段时间后都会过期,成为死信。

        3. 我们可以控制将死信发送给一个交换器,该交换器被称为死信路由,死信路由将死信发送给某个指定的交换器,然后死信被路由到某个队列,再被消费。

    6 引入Elasticsearch实现快速检索

    6.1 倒排索引技术

        Elasticsearch是一个搜索与数据分析引擎,使用Elasticsearch可以实现对关键字的快速检索。

        为什么快呢?是因为Elasticsearch使用了倒排索引技术。

        每一条存入Elasticsearch的记录都被称为文档。用一个很长的二进制数表示一个关键字是否出现在了每个文档中,比如关键字“新能源”对应的二进制数可能是0100100011000001……,表示第2、第5、第9、第10、第16个文档中包含这个关键字。同样,假定“汽车”对应的二进制数是0010100110000001……,那么要找到同时包含“新能源”和“汽车”的文档,只需要将这两个二进制数进行布尔运算AND,结果为0000100000000001……,表示第5、第16个文档满足要求。

    6.2 Elasticsearch的实现原理

        1. 用户将数据提交给Elasticsearch。

        2. Elasticsearch使用分词器对用户提交的文档进行分词,将分词结果和权重一并保存。

        3. 用户使用Elasticsearch进行搜索时,根据权重对结果进行打分、排名,再将结果返回给用户。

    三、信息安全

        一切信息安全问题的核心都在于“权限”。最简单直接的保证信息安全的方法是对用户发送的每一个请求都做鉴权,只为有权限的用户服务。

        用户获得权限的方式——注册。

        用户身份认证的方式——登录。

    1 注册

    1.1 验证码

        验证码的作用是分辨注册用户是人还是机器,能有效防止恶意注册。

        常见的验证码形式有图片验证码和短信验证码。

    1.2 密码加密保存

        用户的密码通常不会在数据库中明文保存,在保存之前会对密码进行加密。

        加密算法可以选择:MD5盐值加密。

    2 登录

    2.1 登录校验

        1. 从客户端请求中获取用户输入的username和password。

        2. 根据username查询用户信息,如果查到的用户信息为null,说明用户未注册。

        3. 校验password。

        4. 校验通过后,使用session记录用户的登录状态,返回用户信息。

    2.2 记录用户的登录状态

        记录用户的登录状态有两个明显的好处:

    1) 用户再次进入系统时,不用再次登录。

    2) 处理用户的每一个请求时,都可以使用session中的身份信息进行鉴权。

    四、分布式

    1 分布式理论

    1.1 CAP定理

        在一个分布式系统中,一致性、可用性、分区容错性三个要素最多只能同时实现两个,不可能三者兼顾。

         ● 一致性(Consistency):在分布式系统中一条数据的所有备份,在同一时刻的值都是一样的。

         ● 可用性(Availability):集群可以响应客户端的读写请求,即使是在集群的一部分节点发生故障后。

         ● 分区容错性(Partition tolerance):大多数分布式系统分布在多个子网络,每个子网络叫作一个区。分区容错意味着:即使区间通信失败也不影响集群的使用。

    1.2 BASE理论

        一般来说,区间通信失败是无法避免的,因此必须保证分区容错性。因此我们必须在一致性或可用性上有所让步。

        BASE理论是对CAP定理的延伸,思想是:如果无法做到高可用,做到基本可用也是可以的,如果无法做到强一致,做到最终一致也是可以的。

        BASE具体指的是:

         ● 基本可用(Basically Available):当分布式系统出现故障的时候,允许损失部分可用性。比如响应时间增加、功能降级等。

         ● 软状态(Soft State):允许系统存在中间状态,该中间状态不会影响系统整体的可用性。

         ● 最终一致性(Eventual Consistency):系统中的所有数据副本经过一定的时间后,最终能够达到一致的状态。

    1.3 领导选举机制

        一个分布式集群只能有一个leader服务器,这个leader服务器是由所有服务器投票选举出来的。

        领导选举过程:

        1. 每台服务器都持有一张选票,选票信息包括myid(服务器的唯一ID)、支持的服务器的myid、事务日志中记录的最新一条事务的zxid(zxid是事务的唯一ID,zxid越大表示对数据进行修改的时间越靠后)等。

        2. 一开始,每台服务器支持的服务器都是自己。

        3. 服务器之间会进行通信,并进行选票pk,参与选票pk的服务器会比较持有选票中的最新一条事务的zxid,zxid比较大的一方赢下pk(没有zxid或zxid相同时比较myid),输的一方改票跟投(将持有选票中支持的服务器的myid、最新一条事务的zxid改成与赢的一方一致)。

        4. 选票pk不断进行,直到超过半数的选票支持某一台服务器时,该服务器成为leader,其它参与选举的服务器成为它的follower。

    2 分布式缓存

    2.1 分布式缓存的思想

        在分布式架构中,一台业务服务器对数据库中的数据进行修改后,无法对其他服务器上的缓存做同步更新,这样,其他服务器再处理请求时,很可能会返回修改前的数据。

        分布式缓存的思想是:创建一块公共的缓存区域,为所有的业务服务器提供缓存服务。

    2.2 分布式锁

        加分布式锁可能会出现哪些问题?怎么解决?

         ● 业务逻辑执行异常,导致锁无法被释放。

            将加锁操作放在try块中,解锁操作放在finally块中。

            即使解锁操作放在finally块中,也有可能因为服务器宕机而无法解锁,所以要给锁设置一个超时时间。这个超时时间一般设为业务平均执行时间的3~5倍。

            加锁和给锁设置超时时间两个行为必须放在一个原子操作当中,防止加锁成功后给锁设置超时时间失败。

         ● 业务逻辑执行期间,其他线程释放了锁。

            加锁的时候给锁附上一段验证信息,一般采用服务器ID+线程ID或者UUID。解锁时,必须校验当前线程,验证成功才能释放锁。

            校验和释放锁两个行为必须放在一个原子操作当中,防止校验通过后,锁正好到期,别的线程拿到锁,又被当前线程释放。

         ● 到了设置的超时时间业务还没有执行完。

            起一个守护线程,每隔一段时间检查一次业务逻辑有没有执行结束,如果没有,就重置锁的超时时间。这种机制被称为锁续命。

            如何判断业务逻辑有没有执行结束?

            看占有分布式锁的线程有没有加锁,如果有,说明业务逻辑还没有执行结束。请注意,这里的“加锁”是真正的加锁,在对象的markword中记录线程指针,区别于分布式锁,分布式锁本质上只是一套控制流。

         ● 分布式锁应该是可重入的。

            在加锁时维护当前的锁重入次数,第1次加锁时记为1,每次加锁时锁重入次数加1。解锁时,锁重入次数减1,当且仅当减1后的结果为0时,释放锁。

            在加锁时还要维护锁的超时时间,当发生锁重入时,比较重入的超时时间和维护的超时时间,取其中较大值作为新的超时时间,这样可以防止锁重入导致超时时间减小而提前过期。

    3 分布式事务

    3.1 分布式事务的核心组件

        分布式事务包含三个核心组件:

         ● Transaction Coordinator(TC):事务协调器。维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。

         ● Transaction Manager(TM):事务管理器。控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。

         ● Resource Manager(RM):资源管理器。控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支事务(即本地事务)的提交和回滚。

    3.2 分布式事务过程

        一个典型的分布式事务过程包括:

        1. 事务管理器向事务协调器申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的XID。

        2. XID在微服务调用链路的上下文中传播。

        3. 资源管理器向事务协调器注册分支事务,将其纳入XID对应的全局事务的管辖。

        4. 事务管理器向事务协调器发起针对XID的全局提交或回滚决议。

        5. 事务协调器调度XID下管辖的全部分支事务完成提交或回滚请求。

    3.3 分布式事务的最终一致性

        Zookeeper通过ZAB协议(Zookeeper Atomic Broadcast,Zookeeper原子广播)来保证分布式事务的最终一致性。

        ZAB协议的内容是:

        1. 所有的事务性请求必须由一台全局唯一的服务器来协调处理,这台服务器被称为leader服务器,其他服务器称为它的follower服务器。

        2. leader服务器负责将客户端发送过来的事务请求转换成事务,并分发给集群中所有的follower服务器。

        3. 每台follower服务器收到事务后会给leader服务器返回一个ack请求,当leader收到超过半数的follower的ack请求后,leader会再次向所有的follower服务器发送commit消息,要求提交事务。

    五、开发技巧

    1 JSON

        使用Maven引入fastjson。

    1) 将对象转换成JSON。

        String jsonData = JSON.toJSONString(myModel);

    2) 将JSON还原成对象。

        MyModel myModel = JSON.parseObject(jsonData, MyModel.class);

    2 日期处理

        推荐使用LocalDateTime。

    1) 获取今天的0:00:00。

        LocalDate today = LocalDate.now();  // 获取今天的日期
        LocalTime min = LocalTime.MIN;  // 获取一天的最小时间
        LocalDateTime start = LocalDateTime.of(today, min);  // 组装日期和时间
        String formatStart = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));  // 获取格式化的时间

    2) 获取后天的23:59:59。

        LocalDate today = LocalDate.now();  // 获取今天的日期
        LocalDate plus = today.plusDays(2);  // 将今天的日期向后推两天
        LocalTime max = LocalTime.MAX;  // 获取一天的最大时间
        LocalDateTime end = LocalDateTime.of(plus, min);  // 组装日期和时间
        String formatEnd = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));  // 获取格式化的时间

    3) Date数据转换成LocalDateTime类型。

        LocalDateTime localDateTime = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();

    4) LocalDateTime数据转换成Date类型。

        Date date = Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());

    3 ThreadLocal

        ThreadLocal是一个关于创建线程局部变量的类。通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。 而使用ThreadLocal创建的变量只能被当前线程访问,其他线程则无法访问和修改。

        1. 创建ThreadLocal对象。

            private static ThreadLocal infoThreadLocal = new ThreadLocal<>();

        2. 对外提供get和set方法。

            public static String getInfo(){
                return infoThreadLocal.get();
            }

            public static void setInfo(String info){
                infoThreadLocal.set(info);
            }

    4 拦截器

    4.1 实现拦截器

        1. 实现HandlerInterceptor接口。

        2. 重写preHandle方法,该方法将在执行业务方法之前执行。return true则放行,否则拦截。

        3. 可以重写postHandle方法,该方法将在执行业务方法之后执行。

        4. 启用拦截器。

    4.2 启用拦截器

    1) 方法一:使用Java配置类。

        @Configuration
        public class MyWebConfig implements WebMvcConfigurer {
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addInterceptor(new MyInterceptor()).addPathPatterns("/**");
            }
        }

    2) 方法二:在xml文件中配置bean。

       
           
           
       

    5 定时任务

    5.1 实现定时任务

        1. 开启定时任务。

            定时任务默认是线程同步的,一个定时任务完成后其他定时任务才会开启,开启异步后可以使用定时任务线程池异步执行定时任务,也可以不开启异步,手动创建异步任务,使用自定义线程池异步执行。

            @EnableAsync
            @EnableScheduling
            @Configuration
            public class SchedulerConfig{
            }

        2. 配置定时任务线程池,如果要使用自定义线程池,这一步可以跳过。

            spring.task.execution.pool.core-size=50
            spring.task.execution.pool.max-size=50

        3. 实现定时任务。

            定时任务的核心注解是@Scheduled,使用cron表达式控制定时任务的执行时间。

            @Component
            public class TestScheduler {
                @Async
                @Scheduled(cron = "0 0 0 * * ?")
                public void hello() {
                    System.out.println("hello world");
                }
            }

    5.2 Cron表达式

        网上有很多在线的Cron表达式生成器,只要对Cron表达式有个大概的印象就可以了。

        Cron表达式的基本规则如下:

    1) Cron表达式本身是一个长字符串,由七个短字符串组成,短字符串之间用空格隔开。

        七个字符串分别代表【秒 分 时 日 月 周 年】。其中年不被Spring支持,换言之,Spring中的Cron表达式只有六个短字符串。

    2) 特殊字符。

        , 表示枚举,cron="5,10 * * * * ?" 表示每分钟的第6秒(开始)和第11秒(开始)。

        - 表示范围,cron="0 3-5 * * * ?" 表示每小时的第4分钟第1秒、第5分钟第1秒、第6分钟第1秒。

        * 表示任意。

        / 表示步长,cron="0 0 0/2 * * ?" 表示每天0:00:00以后每经过两个小时的第1分钟第1秒。

        ? 用来防止日与周冲突,表示不指定,只能用在日和周两个位置,且不能同时使用。

        L 表示最后一个,cron="0 0 0 L * ?" 表示每个月的最后一天的0:00:00。

        W 表示工作日,cron="0 0 0 LW * ?" 表示每个月的最后一个工作日的0:00:00。

        # 表示第几个,cron="0 0 0 ? * 5#2" 表示每个月的第2个周四的0:00:00。

    3) 原生态的Cron表达式使用0-6表示周一到周日,但在Spring中,使用1-7来表示周一到周日。

  • 相关阅读:
    Google Earth Engine(GEE)——一个简单的多指数影像的加载和下载以北京市为例
    DevOps 必备的 Kubernetes 安全清单
    idea插件开发javax.net.ssl.SSLException: No PSK available. Unable to resume.
    电子器件系列44:环形线圈电感
    【达梦数据库】mysql函数改写达梦
    基于SSM高校教室管理系统毕业设计-附源码181523
    小咖啡馆也能撬动大生意
    【JavaEE进阶系列 | 从小白到工程师】JavaEE中的迭代器,并发修改异常与增强for循环,一文上手使用
    【直播精彩回顾】Redis企业级数据库及欺诈检测方案!
    关于安卓卡片式交互实现(recyclerview)
  • 原文地址:https://blog.csdn.net/qq_42082161/article/details/127914232