尼恩說在前面
在40歲老架構師 尼恩的讀者交流群(50+)中,最近有小伙伴拿到了一線互聯網企業網易、美團、字節、如阿里、滴滴、極兔、有贊、希音、百度、美團的面試資格,遇到很多很重要的面試題:
多租戶設計,如何 技術選型?
什么是多租戶,如何做架構設計?
你們的多租戶,是怎么隔離的?
最近有小伙伴在面試騰訊的企業BG,又遇到了相關的面試題。小伙伴懵了, 當然,面試也就掛了。
小伙伴趕緊來求助尼恩,尼恩借助這個小伙伴的面試真題,給大家做一下系統化、體系化的微服務底層架構 梳理,使得大家可以充分展示一下大家雄厚的 “技術肌肉”,讓面試官愛到 “不能自已、口水直流”。
也一并把這個題目以及參考答案,收入咱們的 《尼恩Java面試寶典PDF》V149版本,供后面的小伙伴參考,提升大家的 3高 架構、設計、開發水平。
特別提示:尼恩的3高架構宇宙,持續升級。
《尼恩 架構筆記》《尼恩高并發三部曲》《尼恩Java面試寶典》的PDF,請到文末公號【技術自由圈】取
文章目錄
- 尼恩說在前面
- 一、SaaS多租戶簡介
- 什么是SaaS多租戶技術
- 1.1、什么是 SaaS多租戶
- 1.2、SaaS多租戶的優勢
- 1.3、多租戶模型
- 二、SaaS多租戶的數據隔離設計方案
- 2.1、三種數據隔離架構設計的對比如下:
- 2.2、MyBatis-Plus多租戶插件優雅實現數據隔離
- 三、MyBatisPlus實現多租戶功能
- 3.1、表及實體類添加租戶ID
- 3.2、application文件中添加多租戶配置和新增配置屬性類
- 3.3、編寫多租戶處理器實現TenantLineHandler接口
- 3.4、MybatisPlus配置類啟用多租戶攔截插件
- 運行sql實例:
- 3.5、特定SQL語句忽略攔截
- 參考文獻:
- 說在最后:有問題可以找老架構取經
- 尼恩技術圣經系列PDF
一、SaaS多租戶簡介
多租戶技術是一種軟件架構技術,它是在探討與實現如何于多用戶的環境下共用相同的系統或程序組件,并且仍可確保各用戶間數據的隔離性。它是為共用的數據中心內如何以單一系統架構與服務提供多數客戶端相同甚至可定制化的服務,并且仍可保障客戶的數據隔離。簡單來說是一個單獨的實例可以為多個組織服務。
多租戶是SaaS(Software-as-a-Service)下的一個概念,意思為軟件即服務,即通過網絡提供軟件服務。SaaS平臺供應商將應用軟件統一部署在自己的服務器上,客戶可以根據工作的實際需求,通過互聯網向廠商租用所需的應用軟件服務,按定購的服務多少和時間長短向廠商支付費用,并通過互聯網獲得SaaS平臺供應商提供的服務。
SaaS服務尤其利于一些中小企業,以低成本實現自己的軟件需求。
注意:請點擊圖像以查看清晰的視圖!
什么是SaaS多租戶技術
- 多租戶技術或稱多重租賃技術,是一種軟件架構技術,是實現如何在多用戶環境下(此處的多用戶一般是面向企業)共用相同的系統或程序組件,并且確保各用戶間數據隔離性。
- 在一臺服務器上運行單個應用實例,它為多個租戶(客戶)提供服務。從定義中我們可以理解:多租戶是一種架構,目的是為了讓多用戶環境下使用同一套程序,且保證用戶間數據隔離。多租戶的重點就是同程序下實現多用戶數據的隔離。
1.1、什么是 SaaS多租戶
- SaaS,是Software-as-a-Service的縮寫名稱,意思為軟件即服務,即通過網絡提供軟件服務。
- SaaS平臺供應商將應用軟件統一部署在自己的服務器上,客戶可以根據工作實際需求,通過互聯網向廠商定購所需的應用軟件服務,按定購的服務多少和時間長短向廠商支付費用,并通過互聯網獲得Saas平臺供應商提供的服務。
- SaaS服務通常基于一套標準軟件系統為成百上千的不同客戶(又稱為租戶)提供服務。這要求SaaS服務能夠支持不同租戶之間數據和配置的隔離,從而保證每個租戶數據的安全與隱私,以及用戶對諸如界面、業務邏輯、數據結構等的個性化需求。由于SaaS同時支持多個租戶,每個租戶又有很多用戶,這對支撐軟件的基礎設施平臺的性能、穩定性和擴展性提出很大挑戰。
多租戶是SaaS領域的特有產物,探究何為多租戶需回歸到對SaaS的理解上。
SaaS服務是指部署在云上的,客戶可以按需購買,并通過網絡請求就能獲取到的服務;也就是說,在這樣的場景下,會有N個客戶同時使用同一套SaaS服務。
那么對SaaS服務供應商來說,構建SaaS體系需要完成兩部分工作:上層服務+底層多租戶系統。
上層服務是供應商對外售賣的軟件服務,其可以為客戶創造價值、為公司帶來營收;而底層多租戶系統則是SaaS模式實現的具體方式,公司在對外售賣SaaS服務時,需要考慮如何實現客戶之間的數據隔離、服務的權限控制、計費管理等;因此需要引入多租戶概念來解決上述問題。
通過多租戶系統,公司可以更好的管理客戶和上層服務,客戶也可以更好的使用軟件服務。這也就是多租戶系統存在的意義了。
1.2、SaaS多租戶的優勢
- 開發和運維成本低
- 按需付費,節約成本
- 即租即用,軟件版本更新快
- 故障排查更及時
- 大數據和AI的能力支持更強大
1.3、多租戶模型
注意:請點擊圖像以查看清晰的視圖!
如圖所示,涉及主要模型有以下幾類:
(1)租戶:指一個企業客戶或是個人客戶,租戶之間數據與行為隔離,上下級租戶間通過授權實現數據共享。每個租戶只能操作歸屬或授權給該租戶的數據;
(2)組織:如果租戶是一個企業客戶,通常就會擁有自己的組織架構;
(3)用戶:租戶下的具體使用者,擁有用戶名、密碼、郵箱等賬號信息的自然人;
(4)角色:用戶操作權限的集合;
(5)員工:組織內的某位員工;
(6)解決方案:為了解決客戶的某類型業務問題,SaaS供應商一般都將產品和服務組合在一起,為客戶提供整體的打包方案;
(7)產品能力:能夠幫助客戶實現場景解決方案閉環的能力;
(8)資源域:用來運行1個或多個產品應用的一套云資源環境;
(9)云資源:SaaS產品一般都部署在各種云平臺上,例如阿里云、騰訊云、華為云等。對這些云平臺提供的計算、存儲、網絡、容器等資源,抽象為云資源。
二、SaaS多租戶的數據隔離設計方案
多租戶對于用戶來說,最主要的一點就在于數據隔離。
絕對不能出現:一個用戶登了A用戶單位的號,但是看到了B用戶單位的數據。因此,多租戶的數據庫設計方案和代碼實現就相當有必要考慮了。
目前開發者們普遍接受的SaaS多租戶設計方案,常見的大概就3種:即為每個租戶提供獨立的數據庫、獨立的表空間、按字段區分租戶,每種方案都有其各自的適用情況。
- 一個租戶獨立一個數據庫
一個租戶獨立使用一個數據庫,那就意味著我們的SaaS系統需要連接多個數據庫,這種實現方案其實就和分庫分表架構設計是一樣的,好處就是數據隔離級別高、安全性好,畢竟一個租戶單用一個數據庫,但是物理硬件成本,維護成本也變高了。
- 獨立的表空間
這種方案的實現方式,就是所有租戶共用一個數據庫系統,但是每個租戶在數據庫系統中擁有一個獨立的表空間。
- 按租戶id字段隔離租戶
這種方案是多租戶方案中最簡單的數據隔離方法,即在每張表中都添加一個用于區分租戶的字段(如tenant_id或org_id啥的)來標識每條數據屬于哪個租戶,當進行查詢的時候每條語句都要添加該字段作為過濾條件,其特點是所有租戶的數據全都存放在同一個表中,數據的隔離性是最低的,完全是通過字段來區分的,很容易把數據搞串或者誤操作。
2.1、三種數據隔離架構設計的對比如下:
隔離方案 | 成本 | 支持租戶數量 | 優點 | 缺點 |
---|---|---|---|---|
獨立數據庫系統 | 高 | 少 | 數據隔離級別高,安全性,可以針對單個租戶開發個性化需求 | 數據庫獨立安裝,物理成本和維護成本都比較高 |
獨立的表空間 | 中 | 較多 | 提供了一定程度的邏輯數據隔離,一個數據庫系統可支持多個租戶 | 數據庫管理比較困難,表繁多,同時數據修復稍復雜 |
按租戶id字段區分 | 低 | 多 | 維護和購置成本最低,每個數據庫能夠支持的租戶數量最多 | 隔離級別最低,安全性也最低 |
大部分公司都是采用第三種多租戶設計方案:按租戶id字段隔離租戶 架構設計實現多租戶數據隔離的。
因為這種方案服務器成本最低,但是提高了開發成本。
2.2、MyBatis-Plus多租戶插件優雅實現數據隔離
該系統只有一個數據庫,所有租戶共用數據表。
在每一個數據表中增加一列租戶ID,用以區分租戶的數據。
增刪查改時,一定要帶上租戶ID,否則就會操作到其他租戶的數據。因此,這里的設計一定要重點考慮。
我們要保證的就是一定不要忘記帶上租戶ID。一個很好的方案就是通過AOP的方案,隱式的為我們的每一個SQL帶上這個租戶ID。
推薦使用MyBatisPlus來操作數據庫的。它提供了插件的機制,我們可以通過攔截它提供的四大組件的某些對象,某些方法,來操作SQL,動態的為我們的SQL拼接上租戶ID字段。
當然,MyBatis-Plus高版本提供了更加方便的攔截器,并且已經將多租戶插件放入JAR包,我們只需稍加實現,并將該插件加入到MyBatis的攔截器鏈中,就可以不用再顯式的拼接租戶ID字段了,降低了出錯的概率。
三、MyBatisPlus實現多租戶功能
如果希望以最少的服務器為最多的租戶提供服務,并且租戶接受以犧牲隔離級別換取降低成本。可以采用方案三,即共享數據庫,共享數據架構,因為這種方案服務器成本最低,但是提高了開發成本。
所以MybatisPlus就提供了一種多租戶的解決方案,實現方式是基于多租戶插件TenantLineInnerInterceptor進行實現的。
在 MyBatis Plus 中,采用“共享數據庫,共享數據架構”方式實現多租戶。
MybatisPlus提供了租戶處理器( TenantId 行級 ),租戶之間共享數據庫,共享數據架構,通過表字段(租戶ID)進行數據邏輯隔離。
該種實現方式,需要我們在要實現多租戶的表中添加 tenant_id(租戶ID)字段,每次在對數據庫操作時都需要在 where 后面添加租戶判斷條件“tenant_id=用戶的租戶ID”。
然而,使用了 MyBatis Plus 后,我們就不需要每次都手動在 wehre 后面添加 tenant_id 條件。
注意事項:
- 多租戶 != 權限過濾,不要亂用,租戶之間是完全隔離的!!!
- 啟用多租戶后所有執行的method的sql都會進行處理.
- 自寫的sql請按規范書寫(sql涉及到多個表的每個表都要給別名,特別是 inner join 的要寫標準的 inner join)
<!-- Mybatis-Plus 增強CRUD -->
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.1</version>
</dependency><!-- Mybatis-Plus 擴展插件 -->
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-extension</artifactId><version>3.5.1</version>
</dependency>
TenantLineInnerInterceptor是MybatisPlus中提供的多租戶插件,其使用方法大致分為下面4步:
3.1、表及實體類添加租戶ID
應用添加維護一張tenant(租戶表),記錄租戶的信息,每一個租戶,有一個租戶ID。
然后,在需要進行隔離的數據表上新增租戶id,例如,現在有數據庫表(user)如下:
租戶ID一般用tenant_id
字段名 | 字段類型 | 描述 |
---|---|---|
id | Long | 主鍵 |
tenantId | Long | 租戶編碼 |
other | varchar(256) | 其他屬性 |
將tenantId用來隔離租戶與租戶之間的數據,如果要查詢當前服務商的用戶,SQL大致如下:
SELECT * FROM table t WHERE t.tenantId = 1;
3.2、application文件中添加多租戶配置和新增配置屬性類
(1)設置環境變量,配置攔截規則:
tenant.enable
: 可以設置是否開啟多租戶,tenant.ignoreTables
:需要進行租戶id過濾的表名集合。tenant.filterTables
:對多租戶的表設置白名單忽略多租戶攔截等。例如sys_user表結構中,沒有tenant_id多租戶字段,那么多租戶攔截器不攔截該表。
#多租戶配置
tenant:enable: truecolumn: tenant_idfilterTables:ignoreTables:- sys_app- sys_config- sys_dict_data- sys_dict_type- sys_logininfor- sys_menu- sys_notice- sys_oper_log- sys_role- sys_role_menu- sys_user- sys_user_roleignoreLoginNames:
(2)多租戶配置屬性類
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;/*** 多租戶配置屬性類** @author hege* @Date 2023-08-25**/
@Data
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {/*** 是否開啟多租戶*/private Boolean enable = true;/*** 租戶id字段名*/private String column = "tenant_id";/*** 需要進行租戶id過濾的表名集合*/private List<String> filterTables;/*** 需要忽略的多租戶的表,此配置優先filterTables,若此配置為空,則啟用filterTables*/private List<String> ignoreTables;/*** 需要排除租戶過濾的登錄用戶名*/private List<String> ignoreLoginNames;
}
3.3、編寫多租戶處理器實現TenantLineHandler接口
在 MyBatis Plus 中,提供了 TenantLineInnerInterceptor 插件和 TenantLineHandler 接口。
其中:
- TenantLineInnerInterceptor 插件用來自動向每個 SQL 的 where 后面添加判斷條件“tenant_id=用戶的租戶ID”。
- 而 TenantLineHandler 接口用來給 TenantLineInnerInterceptor 插件提供租戶ID、租戶字段名。
TenantLineHandler 接口定義如下:
public interface TenantHandler {/*** 獲取租戶 ID 值表達式,支持多個 ID 條件查詢* 支持自定義表達式,比如:tenant_id in (1,2) @since 2019-8-2* @param where 參數 true 表示為 where 條件 false 表示為 insert 或者 select 條件* @return 租戶 ID 值表達式*/Expression getTenantId(boolean where);/*** 獲取租戶字段名* @return 租戶字段名*/String getTenantIdColumn();/*** 根據表名判斷是否進行過濾* @param tableName 表名* @return 是否進行過濾, true:表示忽略,false:需要解析多租戶字段*/boolean doTableFilter(String tableName);
}
實現TenantHandler接口并實現它的方法,下面是一個例子:
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.NullValue;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;/*** 多租戶處理器實現TenantLineHandler接口** @author hege* @Date 2023-08-25*/
public class MultiTenantHandler implements TenantLineHandler {private final TenantProperties properties;public MultiTenantHandler(TenantProperties properties) {this.properties = properties;}/*** 獲取租戶ID值表達式,只支持單個ID值 (實際應該從用戶信息中獲取)** @return 租戶ID值表達式*/@Overridepublic Expression getTenantId() {//實際應該從用戶信息中獲取if(SecurityUtils.getTenantLoginUser()!=null){//SecurityUtils 從ThreadLocal里面的安全上下文 中獲取 用戶所歸屬的單位id(租戶id)Long tenantId = SecurityUtils.getLoginUser().getUser().getRootPartyId();if(tenantId!=null){return new LongValue(tenantId);}}return new LongValue(0);}/*** 獲取租戶字段名,默認字段名叫: tenant_id** @return 租戶字段名*/@Overridepublic String getTenantIdColumn() {//通過配置獲取return properties.getColumn();}/*** 根據表名判斷是否忽略拼接多租戶條件** 默認都要進行解析并拼接多租戶條件** @param tableName 表名* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租戶條件*/@Overridepublic boolean ignoreTable(String tableName) {//忽略指定用戶對租戶的數據過濾List<String> ignoreLoginNames=properties.getIgnoreLoginNames();//SecurityUtils 從ThreadLocal里面的安全上下文 中獲取 用戶名稱String loginName=SecurityUtils.getTenantUsername();if(null!=ignoreLoginNames && ignoreLoginNames.contains(loginName)){return true;}//忽略指定表對租戶數據的過濾List<String> ignoreTables = properties.getIgnoreTables();if (null != ignoreTables && ignoreTables.contains(tableName)) {return true;}return false;}
}
SecurityUtils 從ThreadLocal里面的安全上下文 中獲取 用戶名稱, 用戶所歸屬的單位id(租戶id)
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.List;
import java.util.stream.Collectors;/*** 安全服務工具類** @author hege*/
public class SecurityUtils {/*** 獲取多租戶用戶**/public static LoginUser getTenantLoginUser() {try {LoginUser loginUser = null;// 獲取安全上下文對象,就是那個保存在ThreadLocal里面的安全上下文對象,總是不為null(如果不存在,則創建一個authentication屬性為null的empty安全上下文對象)SecurityContext securityContext = SecurityContextHolder.getContext();// 獲取當前認證了的 principal(當事人) 或者 request token (令牌); 如果沒有認證,會是 null,該例子是認證之后的情況Authentication authentication = securityContext.getAuthentication();if(authentication!=null){if(authentication.getPrincipal()!=null){if (authentication.getPrincipal() instanceof LoginUser) {loginUser = (LoginUser) authentication.getPrincipal();}}}return loginUser;} catch (Exception e) {e.printStackTrace();throw new ServiceException("獲取用戶信息異常", HttpStatus.UNAUTHORIZED);}}}
3.4、MybatisPlus配置類啟用多租戶攔截插件
前面講到,在 MyBatis Plus 中,提供了 TenantLineInnerInterceptor 插件和 TenantLineHandler 接口。
其中,TenantLineInnerInterceptor 插件用來自動向每個 SQL 的 where 后面添加判斷條件“tenant_id=用戶的租戶ID”。
TenantLineInnerInterceptor 插件 調用 TenantLineHandler 接口用來給 提供租戶ID、租戶字段名。
使用 @Configuration 和 @Bean 注解配置 MyBatis Plus 的多租戶插件,
iimport com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;/*** Mybatis Plus 配置** @author hege*/
@EnableTransactionManagement(proxyTargetClass = true)
@Configuration
@EnableConfigurationProperties(TenantProperties.class)
public class MybatisPlusConfig {/*** 如果用了分頁插件注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor** @param tenantProperties* @return*/@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties tenantProperties) {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();if (Boolean.TRUE.equals(tenantProperties.getEnable())) {// 啟用多租戶插件攔截interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MultiTenantHandler(tenantProperties)));}// 分頁插件interceptor.addInnerInterceptor(paginationInnerInterceptor());// 樂觀鎖插件interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());// 阻斷插件interceptor.addInnerInterceptor(blockAttackInnerInterceptor());return interceptor;}}
配置好之后,不管是查詢、新增、修改刪除方法,MP都會自動加上租戶ID的標識,測試如下:
@Test
public void select(){List<User> users = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getAge, 18));users.forEach(System.out::println);
}
運行sql實例:
DEBUG==> Preparing: SELECT id, login_name, name, password, email, salt, sex, age, phone, user_type, status,organization_id, create_time, update_time, version,tenant_id FROM sys_user WHERE sys_user.tenant_id = '001' AND is_delete = '0' AND age = ?
驗證結果:
針對MybatisPlus提供的API、自定義Mapper中的statement均可正常攔截,會在SQL執行增刪改查的時候自動加上tenant_id。
3.5、特定SQL語句忽略攔截
如果在程序中,有部分SQL不需要加上租戶ID的表示,需要過濾特定的sql,或者對于一些超級管理員使用的接口,希望跨租戶查詢、免數據鑒權時,無需多租戶攔截。
怎么辦?
可以通過下面幾種方式實現忽略攔截:
- 方法1:使用MybatisPlus框架自帶的@InterceptorIgnore注解,以用在Mapper類上,也可以用在方法上
- 方法2:添加超級用戶賬號白名單,在自定義的Handler里進行邏輯判斷,跳過攔截
- 方法3:添加數據表白名單,在自定義的Handler里進行邏輯判斷,跳過攔截
使用MybatisPlus框架自帶的@InterceptorIgnore注解,以用在Mapper類上,也可以用在方法上, 下面是一個例子:
/*** 使用@InterceptorIgnore注解,忽略多租戶攔截 <br/>* 注解@InterceptorIgnore可以用在Mapper類上,也可以用在方法上** @param id* @return*/
@InterceptorIgnore(tenantLine = "true")
UserOrgVO myFindByIdNoTenant(@Param(value = "id") Long id);
參考文獻:
https://mp.weixin.qq.com/s/TR75wnxsXgFZ2ot1dOvX2w
https://mp.weixin.qq.com/s/CVTuEINWHCLue1oB7Yr3ng
https://mp.weixin.qq.com/s/Nl5Oll9GcF6JB8JvIb2YqA
https://zhuanlan.zhihu.com/p/420696556
https://blog.csdn.net/CSDN2497242041/article/details/132525117
說在最后:有問題可以找老架構取經
多租戶相關的面試題,是非常常見的面試題。
以上的內容,如果大家能對答如流,如數家珍,基本上 面試官會被你 震驚到、吸引到。
最終,讓面試官愛到 “不能自已、口水直流”。offer, 也就來了。
在面試之前,建議大家系統化的刷一波 5000頁《尼恩Java面試寶典PDF》,里邊有大量的大廠真題、面試難題、架構難題。很多小伙伴刷完后, 吊打面試官, 大廠橫著走。
在刷題過程中,如果有啥問題,大家可以來 找 40歲老架構師尼恩交流。
另外,如果沒有面試機會,可以找尼恩來幫扶、領路。
尼恩指導了大量的小伙伴上岸,前段時間,剛指導一個40歲+就業困難小伙伴,拿到了一個年薪100W的offer。
尼恩技術圣經系列PDF
- 《NIO圣經:一次穿透NIO、Selector、Epoll底層原理》
- 《Docker圣經:大白話說Docker底層原理,6W字實現Docker自由》
- 《K8S學習圣經:大白話說K8S底層原理,14W字實現K8S自由》
- 《SpringCloud Alibaba 學習圣經,10萬字實現SpringCloud 自由》
- 《大數據HBase學習圣經:一本書實現HBase學習自由》
- 《大數據Flink學習圣經:一本書實現大數據Flink自由》
- 《響應式圣經:10W字,實現Spring響應式編程自由》
- 《Go學習圣經:Go語言實現高并發CRUD業務開發》
……完整版尼恩技術圣經PDF集群,請找尼恩領取
《尼恩 架構筆記》《尼恩高并發三部曲》《尼恩Java面試寶典》PDF,請到下面公號【技術自由圈】取↓↓↓