2024年1月

本文分享自华为云社区《
KubeEdge EdgeMesh v1.15 边缘CNI特性原理及功能详解
》,作者:达益鑫 |南开大学,刘家伟、吴锟 |DaoCloud,王杰章 |华为云

特性研发背景以及原理

KubeEdge EdgeMesh 边缘 CNI 特性针对于边缘容器网络复杂异构环境提供与云上一致的容器网络体验,包括:

1. 云边统一的容器网络资源管理分配

2.基于分布式中继及穿透能力的 PodIP 级别跨子网流量转发服务

cke_114.png

特性开发背景

EdgeMesh 致力于研究和解决边缘计算场景下网络连通、服务协同、流量治理等相关的一系列问题,其中在异构复杂的边缘网络环境内,不同物理区域的容器在面对动态变迁的网络环境以及短生命周期且跳跃变迁的服务位置,一部分服务可能需要使用 PodIP 进行跨网段通信,而主流的 CNI 方案主要针对于云上稳定且集成式的网络环境,并不支持跨子网流量的转发,致使这些 CNI 方案并不能够很好地适应边缘复杂异构的环境,最终导致容器即便在同一个集群内却没有享用一个平面的网络通信服务。

针对这个需求,EdgeMesh 进行逐步的优化。首先实现的是网络底层的中继以及穿透服务功能开发,即 EdgeMesh 高可用特性。在分布式网络场景当中,该特性能够为节点提供基于 Service 的中继流量以及网络穿透服务,但还没有完全支持对 PodIP 层次流量的转发和穿透能力。换句话说在云边混合的场景当中 ,EdgeMesh 能够为跨网段的 Service 流量提供穿透服务,但并没有涉足到容器网络的资源管理和 IP 层的连通性功能,仍旧依赖于集群原本的 CNI 方案。

这个局限性导致 EdgeMesh 无法为更加底层的网络 IP 流量提供服务,意味着 EdgeMesh 并不能够完全覆盖混合云边的场景需求,对于那些需要穿透和中继服务的 PodIP 流量,老版本的 EdgeMesh 爱莫能助。

那么为什么主流的 CNI 方案无法兼容边缘环境,EdgeMesh 又是如何理解这个需求的呢?

关键问题在于主流的 CNI 方案大多建立于至少三层可通的环境要求之下,具体来说 CNI 自身管理创建网络资源的时候,并不能够感知底层网络的状态,其管理能力高度依赖于 Kubernetes 系统提供的 Node IP 地址,而这个地址的连通性是由底层的网络设备来维持的,如果宿主机不能通过该 Node IP 访问到目标,那么容器网络的虚拟 IP 就更没有办法联通了,原理也将在下文详述。这也意味着 Kubernetes 系统所给的 Node IP 在边缘这样底层网络并不稳定的环境下无法保障稳定的底层通信服务。

EdgeMesh 基于上述理解,对这个需求进行细化的分析:

  • 边缘网络拓扑结构变动性强,且物理隔离场景较多,致使容器网络环境难以呈现出云上那样稳定的底层设施特征,比如边缘环境中自动驾驶场景或者是快递运输场景等。在这样的环境中,物理节点本身并不是绑定固定地址,甚至涉及到 5G 信号核心网注册切换等通信上的流程问题,使得 IP 地址和网络拓扑并不具备完全意义上的寻址能力,通俗说某个节点可能在上一时刻在 A 区域但是过了一会他就行驶到 B 区域了,这样动态变化切换的场景是以往 CNI 架构不曾考虑的。
  • CNI 的连通性是基于 Kubernetes 的 Node IP 维持的,更加深入理解这个结论:主流的 CNI 相信 Kubernetes 系统设置的 IP 地址是可以联通的,所以这些主流的 CNI 方案管理虚拟网络往往都使用这样的方法:宿主机操作系统中的一个切换装置,通过让自己作为虚拟网络资源和实际物理网络环境的中继;正如下图所示, CNI 只是通过 k8s 系统,在各个节点上创建了虚假的地址,通过信息协同的方式让拥有这些虚假地址的数据包可以正常在单机节点上传送给目标,但根本还是依赖于 Node IP 可以访问联通,毕竟如果数据包不能够正常到达目标节点,CNI 利用操作系统所作的虚假行为也就没有了意义。反过来说也无法超过于单机操作系统能够管理的网络连通性,多机分布式场景下完全只能够依靠 Kubernetes 系统。

cke_115.png

当然,也并非所有的主流 CNI 都只在本机的操作系统内做文章,更加深入解决连通性的 Calico 组件有提供基于 BGP 协议的跨机连接方案,但也围绕着云上数据中心架构展开的。与此相对应的边缘环境中, 节点的 IP 地址是存在着局域性、变动性、不确定性的,可以风趣地说 Kubernetes 系统被节点欺骗了。

  • 容器生命周期短暂且不确定,这个特性更像是此类问题的催化剂,使得边缘割裂场景下的网络连接更加难以使用固定的拓扑结构来进行规划管理。

系统设计思路

在本特性初阶段我们确定了此次项目实现的目标:是结合 EdgeMesh 已有的 P2P 功能来提供 CNI 特性, CNI 特性在目前阶段并不意味着开发出完全替代Flannel、Calico、Cilium 等已有架构的完全独立 CNI,而是作为多阶段逐步迭代实现的特性。

一方面从用户角度来说,如果直接替代云上已有的 CNI 架构,会带来额外的部署成本,也无法向用户保证新的架构有足够的价值可以完全覆盖老体系的场景和功能,以项目迭代开发的方式,优先实现一些能力,再慢慢优化、敏捷开发更能符合用户的利益。因而对于云上环境,我们倾向于直接兼容已有的 CNI 架构,使用它们提供的服务,但是补足他们不能够提供的 P2P 功能。

与此同时,现有的 CNI 方案并不能够为边缘复杂异构的环境提供统一的容器网络服务,所以相较于 Docker 或者是其他 CRI 提供的简单网络功能,我们更倾向于直接开发出自己的 CNI 来实现对边缘容器网络资源的管理。

而在上述的 CNI 需求之外,最重要的是如何与 EdgeMesh 的 P2P 服务结合起来;目前的开源社区中实现隧道的技术有:如 VPN,包含 IPSec、Wireguard等;如 P2P,包含 LibP2P 等,结合 EdgeMesh 应对的复杂异构多变边缘环境,我们选用了 LibP2P 技术进行开发。

在此之上需要判断哪些PodIP流量需要 P2P ,哪些流量仍旧使用原本的 CNI 网络服务;再来是将这两部分的功能解耦,以便后期的功能逐步迭代优化,主要的系统架构如下图所示:

cke_116.png

上述系统设计将集群内的流量分为两类:

  • 同一网段流量: 指的是底层网络 NodeIP 可通信,Pod 容器流量通过 CNI 转换即可进行通讯。
  • 跨网段流量: 指的是底层网络不可直接访问, Pod 容器流量通过 CNI 转换封装也无法到达目标节点。

针对于不同的流量我们提供不同的流量传输方式:

  • 对于同一网段的流量:依旧使用主流 CNI 的方案,通过 CNI 转换地址的形式切换虚拟网络和真实网络,连通性依赖 Kubernetes 系统获取的 NodeIP。
  • 对于跨网段的流量: 将这部分流量拦截到 EdgeMesh 当中,通过中继或者是穿透的形式转发到目标的节点,此时 EdgeMesh 充当具有 P2P 功能的 CNI 插件,来完成虚拟网络的切换。

这样的设计需要解决亮点重要的问题:

  • 云边集群容器网络资源统一控制管理

边缘节点以及云上节点 CNI 共同管理容器网络资源,使得容器可以分配到集群唯一的 IP地址,且不论云边都可以通过这个 IP 地址进行通信。

  • 云边以及边边跨网段容器网络通信能力

兼容原本 CNI 的通信方式让需要 P2P 的流量跨网段传输。系统设计的关键抉择在于,是否需要将 P2P 的功能放置到 CNI 执行的关键路径当中,或者是将 P2P 的功能与 CNI 架构解耦。

针对于第一个问题,核心关键是 IPAM 插件的设计和兼容,这方面我们倾向于集成开源社区成熟的方案 Siderpool :

前言

Redis作为一种优秀的基于key/value的缓存,有非常不错的性能和稳定性,无论是在工作中,还是面试中,都经常会出现。

今天这篇文章就跟大家一起聊聊,我在实际工作中使用Redis的10种场景,希望对你会有所帮助。

1. 统计访问次数

对于很多官方网站的首页,经常会有一些统计首页访问次数的需求。

访问次数只有一个字段,如果保存到数据库中,再最后做汇总显然有些麻烦。

该业务场景可以使用Redis,定义一个key,比如:OFFICIAL_INDEX_VISIT_COUNT。

在Redis中有incr命令,可以实现给value值加1操作:

incr OFFICIAL_INDEX_VISIT_COUNT

当然如果你想一次加的值大于1,可以用incrby命令,例如:

incrby OFFICIAL_INDEX_VISIT_COUNT 5

这样可以一次性加5。

2. 获取分类树

在很多网站都有分类树的功能,如果没有生成静态的html页面,想通过调用接口的方式获取分类树的数据。

我们一般为了性能考虑,会将分类树的json数据缓存到Redis当中,为了后面在网站当中能够快速获取数据。

不然在接口中需要使用递归查询数据库,然后拼接成分类树的数据结构。

这个过程非常麻烦,而且需要多次查询数据库,性能很差。

因此,可以考虑用一个定时任务,异步将分类树的数据,直接缓存到Redis当中,定义一个key,比如:MALL_CATEGORY_TREE。

然后接口中直接使用MALL_CATEGORY_TREE这个key从缓存中获取数据即可。

可以直接用key/value字符串保存数据。

不过需要注意的是,如果分类树的数据非常多可能会出现大key的问题,优化方案可以参考我的另外一篇文章《
分类树,我从2s优化到0.1s
》。

3. 做分布式锁

分布式锁可能是使用Redis最常见的场景之一,相对于其他的分布式锁,比如:数据库分布式锁或者Zookeeper分布式锁,基于Redis的分布式锁,有更好的性能,被广泛使用于实际工作中。

我们使用下面这段代码可以加锁:

try{
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
} finally {
    unlock(lockKey);
}  

但上面这段代码在有些场景下,会有一些问题,释放锁可能会释放了别人的锁。

说实话Redis分布式锁虽说很常用,但坑也挺多的,如果用不好的话,很容易踩坑。

如果大家对Redis分布式锁的一些坑比较感兴趣,可以看看我的另一篇文章《
聊聊redis分布式锁的8大坑
》,文章中有非常详细的介绍。

4. 做排行榜

很多网站有排行榜的功能,比如:商城中有商品销量的排行榜,游戏网站有玩家获得积分的排行榜。

通常情况下,我们可以使用
Sorted Set
保存排行榜的数据。

使用
ZADD
可以添加排行榜的数据,使用
ZRANGE
可以获取排行榜的数据。

例如:

ZADD rank:score 100 "周星驰"
ZADD rank:score 90 "周杰伦"
ZADD rank:score 80 "周润发"
ZRANGE rank:score 0 -1 WITHSCORES

返回数据:

1) "周星驰"
2) "100"
3) "周杰伦"
4) "90"
5) "周润发"
6) "80"

5. 记录用户登录状态

通常下,用户登录成功之后,用户登录之后的状态信息,会保存到Redis中。

这样后面该用户访问其他接口的时候,会直接从Redis中查询用户登录状态,如果可以查到数据,说明用户已登录,则允许做后续的操作。

如果从Redis中没有查到用户登录状态,说明该用户没有登录,或者登录状态失效了,则直接跳转到用户登录页面。

使用Redis保存用户登录状态,有个好处是它可以设置一个过期时间,比如:该时间可以设置成30分钟。

jedis.set(userId, userInfo, 1800);

在Redis内部有专门的job,会将过期的数据删除,也有获取数据时实时删除的逻辑。

6. 限流

使用Redis还有一个非常常用的的业务场景是
做限流

当然还有其他的限流方式,比如:使用nginx,但使用Redis控制可以更精细。

比如:限制同一个ip,1分钟之内只能访问10次接口,10分钟之内只能访问50次接口,1天之内只能访问100次接口。

如果超过次数,则接口直接返回:请求太频繁了,请稍后重试。

跟上面保存用户登录状态类似,需要在Redis中保存用户的请求记录。

比如:key是用户ip,value是访问的次数从1开始,后面每访问一次则加1。

如果value超过一定的次数,则直接拦截这种异常的ip。

当然也需要设置一个过期时间,异常ip如果超过这个过期时间,比如:1天,则恢复正常了,该ip可以再发起请求了。

或者限制同一个用户id。

7. 位统计

比如现在有个需求:有个网站需要统计一周内连续登陆的用户,以及一个月内登陆过的用户。

这个需求使用传统的数据库,实现起来比较麻烦,但使用Redis的
bitmap
让我们可以实时的进行类似的统计。

bitmap 是二进制的byte数组,也可以简单理解成是一个普通字符串。它将二进制数据存储在byte数组中以达到存储数据的目的。

保存数据命令使用setbit,语法:

setbit key offset value

具体示例:

setbit user:view:2024-01-17 123456 1

往bitmap数组中设置了用户id=123456的登录状态为1,标记2024-01-17已登录。

然后通过命令getbit获取数据,语法:

getbit key offset

具体示例:

getbit user:view:2024-01-17 123456

如果获取的值是1,说明这一天登录了。

如果我们想统计一周内连续登录的用户,只需要遍历用户id,根据日期中数组中去查询状态即可。

8. 缓存加速

我们在工作中使用Redis作为缓存加速,这种用法也是非常常见的。

如果查询订单数据,先从Redis缓存中查询,如果缓存中存在,则直接将数据返回给用户。

如果缓存中不存在,则再从数据库中查询数据,如果数据存在,则将数据保存到缓存中,然后再返回给用户。

如果缓存和数据库都不存在,则直接给用户返回数据不存在。

流程图如下:

但使用缓存加速的业务场景,需要注意一下,可能会出现:缓存击穿、穿透和雪崩等问题,感兴趣的小伙伴,可以看看我的另一篇文章《
烂大街的缓存穿透、缓存击穿和缓存雪崩,你真的懂了?
》,里面有非常详细的介绍。

9. 做消息队列

我们说起队列经常想到是:kafka、rabbitMQ、RocketMQ等这些分布式消息队列。

其实Redis也有消息队列的功能,我们之前有个支付系统,就是用的Redis队列功能。

PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。

顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。对应channel发送消息后,所有订阅者都能收到相关消息。

在java代码中可以实现MessageListener接口,来消费队列中的消息。

@Slf4j
@Component
public class RedisMessageListenerListener implements MessageListener {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(pattern);
        RedisSerializer<?> valueSerializer = redisTemplate.getValueSerializer();
        Object deserialize = valueSerializer.deserialize(message.getBody());
        if (deserialize == null) return;
        String md5DigestAsHex = DigestUtils.md5DigestAsHex(deserialize.toString().getBytes(StandardCharsets.UTF_8));
        Boolean result = redisTemplate.opsForValue().setIfAbsent(md5DigestAsHex, "1", 20, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(result)) {
            log.info("接收的结果:{}", deserialize.toString());
        } else {
            log.info("其他服务处理中");
        }
    }
}

10. 生成全局ID

在有些需要生成全局ID的业务场景,其实也可以使用Redis。

可以使用incrby命令,利用原子性操作,可以执行下面这个命令:

incrby userid 10000

在分库分表的场景,对于有些批量操作,我们可以从Redis中,一次性拿一批id出来,然后给业务系统使用。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

本文介绍在
ArcMap
软件中,通过创建
模型构建器

ModelBuilder
),导出
地理坐标系

投影坐标系
之间相互
转换

Python
代码的方法。


GIS
领域中,矢量、栅格图层的投影转换是一个经常遇见的问题;而由于地理坐标系与投影坐标系各自都分别具有很多不同的种类,且二者之间相互转换涉及到很多复杂的参数,因此对于非专业的
GIS
二次开发从业者来说,这一转换很难用自己编写的代码来实现。那么,我们有没有什么比较快捷的方法,可以获取从某一坐标系转换为另一坐标系的代码呢?

这样的需求,可以在
ArcMap
软件中得到较为便捷的实现。例如,我们现在有一个北京市边界矢量数据
北京边界.shp
,其没有经过投影,地理坐标系为
GCS_WGS_1984
;而我们的需求是,想获取一个代码,这个代码可以对这一矢量数据进行投影,投影为
WGS 1984 UTM Zone 50N
坐标系。

话不多说,我们直接开始操作。

首先,我们需要完整地按照博客
ArcGIS模型构建器ModelBuilder的使用方法
中提及的方法,建立如下的一个模型。

接下来,在模型构建器窗口中选择“
Export
”→“
To Python Script
”,将模型导出为
Python
脚本。

随后,打开我们刚刚导出的
Python
脚本,就可以看到具体的代码。

具体代码为:

# -*- coding: utf-8 -*-
# ---------------------------------------------------------------------------
# p.py
# Created on: 2022-03-08 21:13:42.00000
#   (generated by ArcGIS/ModelBuilder)
# Description: 
# Used to convert the Beijing boundary data with the geographic coordinate system into a projected coordinate system (UTM-50).
# ---------------------------------------------------------------------------

# Import arcpy module
import arcpy

# Local variables:
北京边界_shp = "G:\\Python_Home2\\arcpy大作业\\北京边界.shp"
BeijingBoundaryPro = "G:\\Python_Home2\\Data\\BeijingBoundaryPro"

# Process: Project
arcpy.Project_management(北京边界_shp, BeijingBoundaryPro, "PROJCS['WGS_1984_UTM_Zone_50N',GEOGCS['GCS_WGS_1984',DATUM['D_WGS_1984',SPHEROID['WGS_1984',6378137.0,298.257223563]],PRIMEM['Greenwich',0.0],UNIT['Degree',0.0174532925199433]],PROJECTION['Transverse_Mercator'],PARAMETER['False_Easting',500000.0],PARAMETER['False_Northing',0.0],PARAMETER['Central_Meridian',117.0],PARAMETER['Scale_Factor',0.9996],PARAMETER['Latitude_Of_Origin',0.0],UNIT['Meter',1.0]]", "", "GEOGCS['GCS_WGS_1984',DATUM['D_WGS_1984',SPHEROID['WGS_1984',6378137.0,298.257223563]],PRIMEM['Greenwich',0.0],UNIT['Degree',0.0174532925199433]]", "NO_PRESERVE_SHAPE", "", "NO_VERTICAL")

不难看到,导出代码中的关键部分——
arcpy.Project_management()
函数涉及到非常多的参数;由此可以再一次验证,如果我们想手动写出地理坐标系与投影坐标系之间的转换代码,可以说是非常困难的。

那么,我们导出了代码,可以怎么应用呢?我们就继续以刚刚导出的这一代码为例进行进一步的操作——比如,对上述代码中的两个参数稍加以修改,并在最开始的部分添加一个新的参数,如下所示:

# -*- coding: utf-8 -*-
# @author: ChuTianjia

import arcpy

arcpy.env.workspace=arcpy.GetParameterAsText(0)
original_shp=arcpy.GetParameterAsText(1)
projected_shp=arcpy.GetParameterAsText(2)

arcpy.Project_management(original_shp,projected_shp,\
                         "PROJCS['WGS_1984_UTM_Zone_50N',\
GEOGCS['GCS_WGS_1984',DATUM['D_WGS_1984',SPHEROID['WGS_1984',6378137.0,298.257223563]],\
PRIMEM['Greenwich',0.0],UNIT['Degree',0.0174532925199433]],PROJECTION['Transverse_Mercator'],\
PARAMETER['False_Easting',500000.0],PARAMETER['False_Northing',0.0],PARAMETER['Central_Meridian',117.0],\
PARAMETER['Scale_Factor',0.9996],PARAMETER['Latitude_Of_Origin',0.0],UNIT['Meter',1.0]]",\
                         "", "GEOGCS['GCS_WGS_1984',DATUM['D_WGS_1984',SPHEROID['WGS_1984',6378137.0,298.257223563]],\
PRIMEM['Greenwich',0.0],UNIT['Degree',0.0174532925199433]]", "NO_PRESERVE_SHAPE", "", "NO_VERTICAL")

其中,
arcpy.env.workspace
代表当前工作空间,
original_shp
代表投影前的文件,在本文的例子中就是北京市边界数量数据文件,
projected_shp
代表投影后的文件,在本文中就是投影后北京市边界矢量数据的具体文件。通过这样的修改,就可以使用该代码,对任意一个原本地理坐标系为
GCS_WGS_1984
的图层进行投影,且投影坐标系为
WGS 1984 UTM Zone 50N

在这里还有一点需要注意,由于编写代码时,希望代码后期可以在
ArcMap
中直接通过工具箱运行,即用到
Python程序脚本新建工具箱与自定义工具
的方法;因此,代码中对于一些需要初始定义的变量,都用到了
arcpy.GetParameterAsText()
函数。大家如果只是希望在
IDLE
中运行代码,那么直接对这些变量进行具体赋值即可。关于
Python程序脚本新建工具箱与自定义工具
,大家可以查看
ArcMap将Python写的代码转为工具箱与自定义工具
详细了解。

本文出自本人写的书,谢绝转载,更勿抄袭。

本人有多年的Java面试官经验,经常要和一些包装项目经验的求职者打交道。当然平时也兼职做些Java面试辅导工作,最近也陆续帮一些在校生朋友成功找到Java工作。

在辅导在校生朋友找工作的过程中,本人发现,其实真有不少朋友,是跟着视频跑通了一个或多个学习项目,再背了些面试题和算法题,然后去找工作,对此本人也有专门的文章来说明。

虽然校招其实是没有商业项目要求的,换句话说,如果零项目经验再去找工作,其实也能成,但这样就和其它大多数在校生没差别了,事实上本人在辅导就业的过程中深有体会,如果求职者朋友能在面试过程中充分展示商业项目经验,一定能提升面试成功的可能。

这点其实很多在校生朋友也知道,所以不少人真会把学习项目包装成商业项目,或者干脆在没项目的前提下自己去编一个,所谓就是“吹牛不打草稿”,对此其实很多面试官是能轻易看出的,本人在面试官的经历中也经常听到些啼笑皆非的回答。

对此,本文先不说如何介绍商业项目经验,就先讲讲Java真实项目的开发流程和常用工具,这样,对于那些有真实Java项目经验的朋友,至少也知道该如何说。

1 接需求和前期设计

商业项目的需求一般来自两个途径,一类是本公司的产品设计人员提出需求,另一类是公司对接客户方需求的业务人员从客户方承接需求。

如果是公司自研的项目,比如某公司自研一个支付类的app,那么该公司的产品设计人员会设计出该app的界面和功能,然后交付程序员开发。如果是公司从客户方承接项目,那么该公司的项目开发团队是通过该公司的业务人员,从客户方获取界面、功能以及业务流程方面的需求。

在明确需求后,项目经理或架构师会根据业务需求点,在linux等操作系统的服务器上创建数据库服务器,并在其中创建数据库和数据表。在此基础上,项目经理或架构师会在服务器上搭建所需的基础设置,比如Redis等分布式组件、Nacos等微服务组件,或者是Git等代码管理组件。

随后,前端开发人员会用Vue.js等组件搭建前端框架,而后端架构师会用Spring Boot框架内加入控制器、服务层和整合MyBatis的数据服务层代码,同时整合Logback日志组件以及AOP切面组件,这样程序员就能在该框架内,通过加入必要的模板类代码,较为高效地实现各种基于增删改查的业务流程。

而在开发商业项目的过程中,程序员如果对功能点或业务流程有疑问,那么应当通过项目经理,向产品方或业务方确认,而不是用自己理解的方式来开发。

有些软件工程教科书会把开发项目的流程细分成“需求分析”、“概要设计”、“详细设计”以及之后的开发测试部署等环节,在“需求分析”、“概要设计”和“详细设计”等环节里,需要产出需求和设计文档,只有当所需文档通过评审后,才能进入下个环节,而且一旦需求发生变更,还需要及时更新相关的需求和设计文档。

但是在不少真实项目的开发场景里,由于开发工期较紧,项目组会用较为简单的文档来和产品或客户方确认需求,在中小型项目或者是外包型项目里,这种情况尤为明显。

比如在一个文档里画出客户所需的界面以及记录各业务的开发流程,而不会详细地按规范地编写文档。而且在需求变更后,也不会及时更新相关文档,甚至是有些需求和变动是直接从客户方这边获取到,然后通过邮件而不是文档来确认。

也就是说,在开发中小型项目或外包项目时,由于项目利润不高,项目经理会尽可能地压缩开发周期,所以,一般不会有太多的时间来按规范整理、评审和更新文档。相比之下,在开发学习项目时,一般会按部就班地按规范来写文档。

所以如果求职者给出项目规模较小,但说明在开发过程中用1个月的时间准备文档,而且项目开发流程完全符合教科书级别的软件开发规范,那么面试官还真可能会质疑该项目的真实性。

或者,如果有求职者说,在项目开发过程中,参与了接需求、设计数据表、绘制前端页面和开发后端代码等全部的工作,那么也是不大可信的。比如初级开发,在项目组里的开发工作一般是,在项目经理搭建好的Spring Boot框架里,参考现有的代码,完成开发工作。

2 敏捷开发模式

当下不少项目经理,尤其是需要赶工期的项目经理,是用敏捷模式来管理项目。在实践过程中,敏捷模式包含“每天例会”和“迭代开发”这两个实践要点。

在每天的例会里,项目组的每位成员需要说下当前任务的开发情况,如果遇到个人无法解决的问题就提出来,由组内成员帮助或协调解决,如果程序员完成开发了一个任务点,在例会中还可以演示一下,让相关人员确认下是否符合预期。

而迭代开发的含义是,项目经历根据开发周期和工作量,需求点合理地拆分到每个周期内,每个周期在完成任务后做一次发版。比如某项目开发周期是8个月,有50个功能点,那么项目经理能以“月”为单位设计8个迭代周期,并为每个周期制定如下表所示的工作任务。

迭代周期编号 工作任务 完成标志
1 完成创建数据表,完成搭建日志、Redis和Nacos等开发环境,完成3个功能点 相关组件搭建完成,成功发版,发版后的代码包含对应业务功能
2到7个周期 平均每个周期完成8个功能点,同时解决之前发现的线上问题 成功发版,发版后的代码包含对应业务功能,同时线上问题成功修复
8 全面测试,修复问题 成功发版,发版后的代码包含对应业务功能,同时线上问题成功修复

每个迭代周期有固定的发版日,比如是第四周的周五,当然在实际开发过程中,如果有待紧急上线的功能点或出现比较严重的线上问题,还可以再额外发版。而在每个迭代周期内,一般再会以“周”为单位来安排任务并管理代码,相关情况如下表所述。

时间段 工作任务 实践要点
该迭代周期开始后的1到2天内 项目经理会和产品或客户确认该周期内需要完成的任务点,同时确认该周期内需要解决的线上问题 项目经理用jira或禅道等工具,为每个程序员创建开发任务,同时不定期地为程序员分配修bug等任务
前三周 程序员根据所分配的任务开发功能点,或修复bug 完成后的代码应及时部署到测试环境,同时经过测试后的代码应及时提交到Git或其它代码管理工具上
最后一周 全面测试,修复问题 发版后的代码在经测试后,应当在Git等工具上打上标记,以便管理

上文提到的,在每个迭代周期结束后的发版,是发布到生产环境。比如某项目组做的是自研项目,那么一般会在买的或租的服务器上搭建对外提供服务的生产环境,发版后的项目,即能对外提供服务。如果做的是外包项目,那么发版的环境一般是客户方提供的服务器。

每次发版结束后,项目经理一般也会让产品或业务人员来确认功能点。而在迭代开发过程中,难免会遇到需求变更等情况,此时项目经理可能就不得不更改原有计划,在对应的迭代周期内添加任务点。通过这种迭代模式,项目经理能有效地拆分任务,并且能定期演示所完成的功能点。

3 开发、测试与测试环境

在每个迭代开发周期内,程序员会根据所分配的任务,在Spring Boot等框架内开发增删改查功能点。

项目开发完成后,最终是需要部署到生产环境,这点上文已经提到过。不过大多数项目组还会在生产环境之外,在另外一台服务器上搭建测试环境,在测试环境上,一般也会安装Redis和数据库等项目基础设置,程序员一般有权限部署或修改测试环境的代码和配置文件。

大多数学习项目更注重功能开发,一般没有测试和部署等环节,也不会专门搭建测试环境和生产环境,这也是商业项目和学习项目的一个重要差别。事实上大多数的学习项目一般是在Windows操作系统的开发主机上启动并运行,所以如果求职者只做过学习项目,一般在面试过程中是说不好测试和部署的流程。

在开发完成后,虽然项目组一般还会配置测试人员,但是程序员一般还需要通过单元测试、接口测试和功能测试等手段来确保代码的质量,上述测试动作一般是在测试环境上进行,相关测试的实践要点归纳如下。

1. Spring Boot的单元测试一般用Junit进行,程序员通过编写测试案例,来确保自己所开发的方法和模块的正确性,在不少项目里,还会专门引入Sonar等组件来确保单元测试代码的覆盖率。

2. 程序员在完成开发代码并通过单元测试初步确定功能正确后,可以把后端代码打包部署到测试环境。Spring Boot项目会通过控制器类定义对外服务的URL接口和测试,程序员可以通过Postman等工具,向测试环境发出携带参数的URL请求,从而确保每个接口的正确性。

3. 接口测试能确保后端接口的正确性,此时如果在前端代码里已经包含了调用后端接口方法,那么程序员可以通过操作前端页面的方式来验证功能的正确性。当然这部分的测试更多地应该是由测试人员来完成,但如果有些项目规模很小,没配置专门的测试人员,这块一般也是由程序员自行完成。

4 项目部署细节说明

在商业项目的开发过程中,一般都会在每个迭代开发周期的结束时把项目代码发布待生产环境上,这个动作也叫项目部署。

在项目部署前,项目经理往往会带领程序员和测试人员完成如下的动作。

1. 通过接口测试和功能测试,确保待发布业务功能点的正确性。

2. 明确本次发布对应的功能点,以及明确对应功能点的检查方式。

3. 明确待发布项目的代码分支,比如用Git的master分支来发布,在发布前若干天,除非有紧急修复等需求,应当禁止程序员再修改该分支的代码。

4. 明确发布时需要运行的数据库脚本以及待修改的项目配置文件。

下表归纳了发布当前的常规任务。

操作人员 工作任务
项目经理或运维人员 打包前后端代码,比如把后端项目打成jar包。并把功能包部署到生产服务上的指定路径,并启动项目。
项目经理或运维人员 根据事先准备好的数据库脚本,更新生产环境上的数据库,同时更新生产环境上的项目配置文件
项目经理或运维人员 如果有必要,通过脚本等方式导入数据,或者在生产服务器上做其它操作
测试人员和程序员 发布完成后,程序员到生产环境上验证自己所开发功能的正确性,同时测试人员通过测试,验证质量

后端Spring Boot项目的发布流程一般是,用Maven等工具把项目打成jar包,再把该jar包复制粘贴到生产环境上,再通过java -jar等命令启动该jar包,当然整个过程也可以用jenkins等自动化部署工具来完成。

在发布过程中,如果发现所开发的功能有bug,项目经理能根据实际情况决定是完成部署还是终止部署。如果要终止部署,就需要把生产环境上的代码、数据和配置参数回退到本次部署前的状态。发布完成后,项目经理也可以请产品设计人员或业务人员等提出需求的人员,到生产环境上通过实际操作,来确认所开发的功能符合预期。

5 监控系统,解决线上问题

在生产环境上,运维人员一般会搭建监控系统,用来监控系统运行的状态。常用的监控系统有newrelic, cat和zabbix,程序员一般不需要关心监控系统是如何搭建和如何运行的,但监控系统一旦发现问题,会通过邮件或手机短信等方式告知程序员,此时程序员就需要排查解决问题。

不管是哪种监控系统,一般都会提供如下表所示的监控服务。

监控维度 告警时机
服务器 比如某项目部署在3台服务器上,一旦有一台服务器宕机,且时间操作30秒,监控系统会告警。
慢查询 数据库的某SQL语句运行时间超过10秒,监控系统会告警
慢请求 某后端请求的处理时间超过10秒,监控系统会告警
CPU负载过高 某服务器的CPU负载率高于50,且持续时间超过2分钟,监控系统会告警
内存负载过高 某服务器的JVM虚拟机负载率高于75,且持续时间超过2分钟,监控系统会告警
异常数过多 比如在5分钟内,某服务器日志里出现Error或Exception等关键字的次数超过10次,监控系统会告警

在实践场景里,运维人员会在Zabbix等监控组件里设置多个告警条件,在其中配置告警的阈值,比如上文里提到的“某后端请求的处理时间超过10秒会告警”,其中告警阈值是10秒还是其它,这可以在监控组件内设置。

程序员在商业项目里一般会去解决实际的线上问题,而大多数的线上问题一般都是由监控系统发现。而程序员解决线上问题的一般步骤是,第一看系统日志或运行linux明确确认问题,第二找到该问题对应的代码或配置文件,同时明确修复步骤,第三在修复后测试并部署,并到生产环境上确认问题已经修复。

6 项目管理和部署工具

商业项目一般会用Maven或Gradle工具来管理系统,通过此类工具,程序员除了能引入该项目所需要的依赖包之外,还能设置编译项目时的工作任务。

具体地,如果使用Maven工具, 一般是通过mvn build命令来编译项目,如果是用Gradle工具,一般是用gradlew build命令来编译,在编译过程中,如果后端代码有语法问题,编译会失败。

在实际项目里,项目经理一般还会通过编写配置文件的方式,设置Maven或Gradle工具在编译时还要运行代码检查工具,从而确保项目代码的质量。比如可以通过Maven整合Sonar组件的方式,确保后端项目的Junit单元测试覆盖率要高于80%,否则就会编译失败。

而在上文提到的项目部署过程中,可以手动运行mvn build等命令把后端项目打成jar包,再把该jar部署到linux测试或生产环境上再启动,但是为了提升部署的效率,还可以用Jenkins等工具来部署。

具体在用jenkins等工具时,程序员可以通过编写配置文件,指定待部署项目的Git分支,打包命令和部署项目的服务器地址以及部署后启动项目的命令,这样在部署时就能通过点击Jenkins工具的菜单按钮自动完成部署动作。

7 代码管理工具

当下大多数项目是用Git来管理代码,当然依然有不少项目采用SVN等工具来管理代码。在每个迭代开发周期里,程序员在开发任务和修复bug前,需要从master等主分支上创建具体的开发分支,在开发分支上完成开发后,再把代码提交并合并到主分支,相关操作要点如下所述。

要点1:在上一个迭代周期结束时,主分支(master)上的代码已经发布到生产环境,同时会给此时的master主分支代码打上一个tag标签,比如是20230825标签,这样如果之后在生产环境上发布的代码有问题,可以回退到由该标签标志的Git分支。

要点2:项目组里的Git工具一般会安装在专门的服务器上,这台服务器也叫Git服务器。而程序员自己的主机上需要安装Git客户端,并通过git命令从服务器上拉取代码或向服务器推送代码,相关效果如下图所示。而在不少学习项目里, Git服务器和Git客户端会被安装在同一台Windows操作系统的主机上。

要点3,程序员在开发功能或修复bug时,一般需要用git pull命令,把处在远端Git服务器上的master代码拉到本地主机,并在主分支代码的基础上创建一个开发分支。比如小张创建了一个名为task_001的代码分支,小李创建了名为task_002的代码分支,他们分别在各自的分支上开发代码。此时请注意,这里的task_001等开发分支只是处在本地,并没有提交到Git服务器。

要点4,程序员在自己的分支上完成开发和测试后,可以通过git commit命令,把代码提交到本地(即程序员电脑的Git客户端)的task_001等开发分支上,并通过git push命令,把本地的task_001等开发分支推送到Git服务器。

要点5,程序员在把自己的开发分支推送(push)到Git服务器后,需要创建一个合并请求(pull request,简称pr),请求把task_001里的代码合并到主分支里。在创建合并请求时,一般需要指定由其它程序员评审(Review)代码,经过评审后,task_001里包含的实现新功能的代码就能被合并(merged)到主分支上。

要点6,如果有多人同时修改同一个文件里的同一段代码,那么在通过合并请求(pull request,简称pr)把分支代码合并到主分支时,就会出现代码冲突(conflict)的现象。此时相关人员就要一起讨论,在确保功能正确的前提下修改代码,从而解决代码冲突的情况。

也就是说,如果程序员参与过真实的项目,那么应该有上述Git等代码管理工具的实践经验,相比之下,在学习项目的开发过程中,由于不涉及到多人协同开发,一般会直接在主分支上开发代码,而不会有创建开发分支、把开发分支合并到主分支、代码评审和和解决冲突等动作。

8 Java项目开发的常用组件

在商业项目里,一般也是用到IDEA集成开发工具,也是用到Spring Boot框架,但是和学习项目相比,商业项目一般还会用到如下表所示的项目基础设施组件。

功能效果 商业项目 学习项目
输出日志 用logback或log4j组件向控制台和文件里输出日志 一般仅会用System.out.println语句向控制台输出日志
安全防护 通过Spring Security组件实现身份验证和鉴权 不大会实现此类功能,或仅用Spring Security组件的基本功能
展示API 通过Swagger组件展示API,以供前端等人员调试 一般也会用到Swagger组件
提升数据库性能 会综合使用索引、Redis,再视情况使用MyCat分库组件 一般仅用到Redis单机版节点
监控项目 Zabbix或Cat等 一般不会用到

从上表里大家能看到,后端商业项目所用的一些组件,在开发学习项目时根本用不到,也就是说,如果大家能在面试中,结合场景和使用细节正确地说明日志等组件的使用方式,其实也能从侧面证明自己的商业项目开发经验。

9 测试类工具

在实际项目里,程序员一般也会用到Postman和Jmeter等测试工具,其中Postman一般用在接口测试(也叫API测试)场景,而Jmeter一般用在自动化测试或性能测试等场景。

比如某仓库管理系统后端项目,在控制器层里通过@urlmapping等注解定义了如下表所示的实现增删改查功能的接口,其中分别用到了POST、DELETE、PUT和GET类型的http动作,当该项目部署到测试环境或生产环境后,程序员可以通过Postman工具发请求的方式来测试各API接口的正确性。

动作 URL请求 http动作 参数说明
查询指定id的库存 /stock/{id} GET {id}表示待查询的库存数据id
查询所有库存信息 /stocks GET 返回所有库存,无需参数
创建新的库存数据 /stock POST 在请求体(Body)里传入待插入的库存数据
修改指定id的库存 /stock/{id} PUT {id}表示待修改的库存数据id,而具体待修改的库存数据会通过请求体(Body)传入
删除指定id的库存 /stock/{id} DELETE {id}表示待删除的库存数据id

比如通过Postman工具,发出了POST类型的/stock请求,并在该请求的参数里设置了待插入的库存信息,如果看到该请求的Http返回码是200,那么能说明该POST接口工作正常。再如,如果用Postman工具发出DELETE类型的/stock/{id}请求,并且正确地输入了待删除库存的参数,如果看到返回码是500,那么就说明该接口的代码有问题,程序员就需要介入并排查。

在项目部署前,一般需要确保该项目所有的接口都正常工作,如果该后端项目已经在控制器里定义了20多个接口,那么手动测试的工作量会比较大。

在此类场景里,可在Jmeter里加入所有API接口,并设置每个接口正常工作的标准,比如返回码是200就算工作正常,那么就可以在每次部署前通过Jmeter自动发起针对所有接口的调用,并能通过返回结果自动确认接口的正确性,这就是用Jmeter进行自动化测试的一般步骤。

如果某后端项目对接口的性能有要求,比如客户方要求每个接口的响应时间应该小于2秒,那么可以通过Jmeter,设置同时发起100个请求,再通过查看平均返回时间来分析接口的性能,如果有问题再进行性能调优工作。

10 数据库服务器及其客户端组件

在实际项目里,一般也会在一台服务器上搭建MySQL或Oracle等数据库服务器,而程序员在本机开发时,是通过Navicat或MySQL WorkBench等客户端组件连接到数据库服务器。

事实上不少项目组同样会在生产环境的数据库服务器之外,再搭建测试环境的数据库服务器,而且为了节省资源,会在同一台测试服务器上搭建数据库环境、上文提到的Git服务器以及Redis缓存组件等项目开发的基础设施。

程序员在开发功能的过程中,除非是排查产线问题,否则一般不会操作生产环境的数据库服务器,而是使用测试环境的数据库,在使用时一般会有如下的实践要点。

1. 在一个后端项目里,一般会包含两个配置文件,分别用于编写指向生产环境和测试环境的配置参数,而后端代码在启动时,能通过传入参数指定是连向生产环境还是测试环境。

2. 程序员在本机开发代码时,一般不会在自己的主机上再搭建数据库服务器,而是通过修改配置文件的方式,让本地运行的代码指向测试数据库。而后端代码被放置到测试环境上测试时,一般也是连向测试服务器。

3. 如果程序员要调试数据库方面的问题,比如要调试一句查询的SQL语句,一般是在本地,通过Navicat等客户端工具连上测试数据库,再其中调试。

4. 在生产环境数据库服务器上的任何操作,比如要创建索引或更改表结构,一般会先在测试环境的数据库服务器上验证,验证成功后再到生产数据库上执行。

相对应地,如果某程序员在面试时,向面试官说,在开发项目时,MySQL等数据库服务器是搭建在本地主机,或者是说,开发项目时数据库服务器不分生产环境和测试环境,只用一套环境,那么该程序员所说的项目,一般是指学习项目,而不是商业项目。

11 linux连接组件

商业项目一般是部署并运行在linux操作系统的服务器上,具体是指,后端Spring Boot项目打成的jar包会被部署到linux服务器上并启动,数据库服务器和Redis服务器,以及其它必要的项目基础设施(如分布式组件或微服务组件),一般也是部署并运行在linux服务器。

也就是说,程序员在开发项目时,会在本地Windows操作系统上,通过
SecureCRT
等组件,在输入目标服务器IP地址、连接用户名和密码之后,以命令终端或文件目录的形式连接到测试或生产环境。

程序员在把后端代码放置到测试服务器后,可通过Postman等工具发起请求测试API接口,如果发现问题,可用
SecureCRT
组件登到测试服务器,找到对应的日志文件,把该日志文件下载到本地再排查。

或者是在以命令端的方式登录到linux服务器之后,通过linux命令打开日志文件并排查问题。项目中常用的linux命令如下表1-8所示。

命令 含义和使用场景
cd /opt/abc 进入到指定路径
Pwd 查看当前路径
cp 复制文件
ls 通过cd命令进入到指定路径后,用该命令查看当前路径里的文件,确认是否存在待分析的日志文件按
vi 打开(日志等)文件,打开后可通过/keyword的方式查找字符,以确认和排查问题

12 总结与预告

商业项目经验是Java求职者技能的最好背书,所以在面试过程中证明自己的商业项目经验尤为重要,但不少Java程序员,尤其是商业项目经验不多的程序员,在面试过程中往往表现得像零商业项目的求职者一样,无法有效地通过项目细节来证明自己的经历。

本章系统讲述了Java商业项目的开发流程流程以及项目开发时经常会用到的组件和工具,在之后的章节,将会在此基础上,具体给出如何证明商业项目经验的详细说辞和面试准备技巧。

前言

大流量情况下的库存是老生常谈的问题了,在这里我整理一下mysql和redis应对扣除库存的方案,采用jmeter进行压测。

JMETER设置

库存初始值50,线程数量1000个,1秒以内启动全部,一个线程循环2次,共2000个请求

MySQL方案

初始方案

    <update id="decreaseStock">
        UPDATE stock
        SET stock_num = stock_num - 1
        WHERE id = #{id}
    </update>

这种情况下,在并发条件肯定会出现超卖的

image-20240109153257263

进行修改:

    <update id="decreaseStock">
        UPDATE stock
        SET stock_num = stock_num - 1
        WHERE id = #{id} AND stock_num >= 1
    </update>

增加
AND stock_num >= 1
条件,即可避免超卖。

image-20240109153241745

相关代码:

    @PostMapping(value = "/decreaseStock/{id}")	
    public ResponseEntity<Object> decreaseStock(@PathVariable("id") Integer id) {
        int result = stockService.decreaseStock(id);
        return result == 1 ? new ResponseEntity<>("decreaseStock successfully", HttpStatus.OK) : new ResponseEntity<>("decreaseStock failed", HttpStatus.OK);
    }

压测情况:
image-20240109170237724

根据Throught可知一秒可以处理200个事务(TPS)

如果说系统的并发量不高,则可以以这种方案进行防止库存超卖,但要注意,在可重复读隔离级别情况下,
如果where的条件字段没有索引的话,进行update语句会使整个表被锁住,如果这里使用的where条件不是主键id而是product_name,那么需要给这个字段加索引。

在RR可重复读隔离级别下,如果where条件没有命中索引,那么会基于next-key lock(记录锁和间隙锁的组合)对整个表的所有记录加上这个锁,进行全表扫描,这个时候其他记录想要更新就会被阻塞。

但是不一定是有了索引就不会锁住整个表,这是由优化器决定的,可以使用Explain语句来查看当前语句是走的索引还是全表扫描,如果优化器走的还是全标扫描,可以使用
force index([index_name])
强制使用某个索引。

改进

在MySQL情况下还能有其他方案来提升性能吗,在不借助Redis的情况(曾经面试招银网络被问了这道题)

我当时给出的回答是,把单个商品的库存比如50个库存,拆分成好几份,一份10个,5份库存,由于秒杀情况下流量很大,可以把这五份库存分别放到五个数据库里面,这样性能至少是原先方案的5倍,那么还会出现新的问题,就是有些问题,负载均衡上的问题,可能会出现某些库里还存在库存,但是请求却没有打进这个数据库,而是打到库存已经没有的数据库里面。我当时的想法是再搞个库存表,这个库存表采集各个商品的总库存以及商品在各个分库里面的库存数量,然后再写个服务,包含负载均衡的算法,将用户的请求平均打到各个分库去,当某个分库的库存达到0的时候,去通知该服务,服务将这个库剔除,使新的请求不会转发过去。实际这种情况也是存在问题的,高并发下库存为0的库来不及被剔除,也会导致请求被打到库存0的库。

Redis方案

将库存暂时放到Redis,然后从Redis进行库存扣减,能大大提升性能

压测结果:

image-20240109170010201

可见性能几乎是MySQL的10倍了,但是这样子在Redis里面会导致超卖

要确保Redis不超买,需要先查询当前的数量,如果大于0则进行扣减,并且查询和扣减需要为原子性,这里就需要借助lua脚本,将这两次操作写到一起。

加了Lua脚本的代码:

    private static final String LUA_DECRESE_STOCK_PATH = "lua/decreseStock.lua";

    @PostMapping(value = "/decreaseStockByRedis/{id}")
    public ResponseEntity<Object> decreaseStockByRedis(@PathVariable("id") Integer id) {

        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(LUA_DECRESE_STOCK_PATH)));
        redisScript.setResultType(Long.class);
        
        // 执行Lua脚本
        Long result = (Long) redisTemplate.execute(redisScript, Collections.singletonList(id));

        // 返回结果判断
        return (result != null && result == 1) ? new ResponseEntity<>("decreaseStock successfully", HttpStatus.OK) : new ResponseEntity<>("decreaseStock failed", HttpStatus.OK);
    }

lua脚本放在resource/lua/decreseStock.lua

local key = KEYS[1]

-- 检查键是否存在
local exists = redis.call('EXISTS', key)
if exists == 1 then
    -- 键存在,获取值
    local value = redis.call('GET', key)
    if tonumber(value) > 0 then
        -- 如果值大于0,则递减
        redis.call('DECR', key)
        return 1  -- 表示递减成功
    else
        return 0  -- 表示递减失败,值不大于0
    end
else
    return -1  -- 表示递减失败,键不存在
end

Redis同步库存到MySQL

但是在Redis扣减了库存,总需要同步到MySQL里面

@PostMapping(value = "/decreaseStockByRedis/{id}")
    public ResponseEntity<Object> decreaseStockByRedis(@PathVariable("id") Integer id) {

        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(LUA_DECRESE_STOCK_PATH)));
        redisScript.setResultType(Long.class);

        // 执行Lua脚本
        Long redisResult = (Long) redisTemplate.execute(redisScript, Collections.singletonList(id));
        int dataBaselResult = 0;
        if (redisResult == 1) {
            dataBaselResult = stockService.decreaseStock(id);
        }
        // 返回结果判断
        return (dataBaselResult == 1 && redisResult == 1) ? new ResponseEntity<>("decreaseStock successfully", HttpStatus.OK) : new ResponseEntity<>("decreaseStock failed", HttpStatus.OK);
    }

直接按照上述代码来写,删Redis后同时将库存同步到MySQL,相当于使用了Redis性能又没有提升。

其实选择了Redis来进行库存扣减,那么MySQL的库存并不需要去实时进行更新,只需要库存达到最终一致性即可,即先对Redis的库存进行更新,然后再异步同步到MySQL的库存。

如果使用spring的异步线程来解决,会不会出现同步MySQL失败导致数据最终不一致呢,在流量很多的情况下,系统本身就处于压力大的情况,再使用异步线程会占用额外的资源,最好的方法是引入MQ,把库存的同步信息交给MQ,MQ再交到消费系统,进行减库存的操作,由MQ保证消息被消费,实现最终一致性。

部分代码如下,由MQ product发出,再由consumer进行消费:

    private final DecreaseStockProduce decreaseStockProduce;

    @PostMapping(value = "/decreaseStockByRedis/{id}")
    public ResponseEntity<Object> decreaseStockByRedis(@PathVariable("id") String id) {

        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(LUA_DECRESE_STOCK_PATH)));
        redisScript.setResultType(Long.class);

        // 执行Lua脚本
        Long redisResult = (Long) redisTemplate.execute(redisScript, Collections.singletonList(id));
        if (redisResult == 1) {
            // 发送消息
            try {
                DecreaseStockEvent decreaseStockEvent = DecreaseStockEvent.builder()
                        .id(id)
                        .build();
                SendResult sendResult = decreaseStockProduce.sendMessage(decreaseStockEvent);
                if (!Objects.equals(sendResult.getSendStatus(), SendStatus.SEND_OK)) {
                    log.error("消息发送错误,请求参数:{}", id);
                }
            } catch (Exception e) {
                log.error("消息发送错误,请求参数:{}", id, e);
            }
        }

        // 返回结果判断
        return (redisResult == 1) ? new ResponseEntity<>("decreaseStock successfully", HttpStatus.OK) : new ResponseEntity<>("decreaseStock failed", HttpStatus.OK);
    }

MQ [TIMEOUT_CLEAN_QUEUE] broker busy问题

这里直接压测会报下面的错误,并且这个时候查看redis库存已经减到0,到是MySQL只减到了37

针对MQ [TIMEOUT_CLEAN_QUEUE] broker busy问题,需要去修改MQ的broker.conf文件

image-20240666666165139373

针对TIMEOUT_CLEAN_QUEUE broker busy问题,需要去修改MQ的broker.conf文件,上述的201ms超时了,我这里将等待时间改为400,并且将线程数设置为64,这个线程数可以根据实际压测情况进行调整。

# 发消息线程池数量
sendMessageThreadPoolNums=64
# 拉消息线程池数量
pullMessageThreadPoolNums=64
waitTimeMillsInSendQueue=400

现在再进行压测,发现tps能跑到1000,相比直接入库mysql的200已经是提升很大了。

虽然性能提高,也实现库存的同步,但这个性能下还是会存在一些问题:

比如MQ消息发送失败、或者MySQL库存扣减失败,并且实际情况还有订单的生成和库存之间的一致性也要考虑。

对于上述这些问题,可以查看我的另外一篇博客:

RocketMQ事务消息在订单创建和库存扣减的使用 - Scotyzh - 博客园 (cnblogs.com)