一種簡單易懂的 MyBatis 分庫分表方案

數(shù)據(jù)庫分庫分表除了使用中間件來代理請求分發(fā)之外末誓,另外一種常見的方法就是在客戶端層面來分庫分表 —— 通過適當(dāng)?shù)匕b客戶端代碼使得分庫分表的數(shù)據(jù)庫訪問操作代碼編寫起來也很方便嫁蛇。本文的分庫分表方案基于 MyBatis 框架芬膝,但是又不同于市面上常用的方案慧起,它們一般都是通過編寫復(fù)雜的 MyBatis 插件來重寫 SQL 語句惩淳,這樣的插件代碼會巨復(fù)雜無比疹吃,可能最終只有插件的原作者自己可以完全吃透相關(guān)代碼蹦疑,給項目的維護(hù)性帶來一定問題。本文的方案非常簡單易懂萨驶,而且也不失使用上的便捷性必尼。它的設(shè)計哲學(xué)來源于 Python —— Explicit is better than Implicit,也就是顯式優(yōu)于隱式,它不會將分庫分表的過程隱藏起來判莉。

很多分庫分表的設(shè)計在實現(xiàn)上會盡量將分庫分表的邏輯隱藏起來豆挽,其實這是毫無必要的。使用者必須知道背后確實進(jìn)行了分庫分表券盅,否則他怎么會無法進(jìn)行全局的索引查找帮哈?他怎么會無法隨意進(jìn)行多表的 join 操作。如果你真的將它當(dāng)成單表來用锰镀,到上線時必然會出大問題娘侍。

image.png

項目名稱叫:shardino,項目地址:github.com/pyloque/sha…

接下來我們來看看在本文的方案之下泳炉,數(shù)據(jù)庫操作代碼的形式是怎樣的

帖子表一共分出來 64 個表憾筏,不同的記錄會各自分發(fā)到其中一個表,可以是按 hash 分發(fā)花鹅,也可以按照日期分發(fā)氧腰,分發(fā)邏輯由用戶代碼自己來決定。在不同的環(huán)境中可以將分表數(shù)量設(shè)置為不同的值刨肃,比如在單元測試下分表設(shè)為 4 個古拴,而線上可能需要設(shè)置為 64 個。

@Configuration
public class PartitionConfig {

    private int post = 64;

    public int post() {
        return post;
    }

    public void post(int post) {
        this.post = post;
    }
}

帖子表又會被分配到多個庫真友,這里就直接取模分配黄痪。假設(shè)有 4 個帖子庫,帖子表總共分出來 64 個表盔然,分別是 post_0桅打、post_1、post_2 一直到 post_63愈案。那么 post_0油额、post_4、post_8 等分配到 0 號庫刻帚,post_1潦嘶、post_5、post_9 等分配到 1 號庫崇众,post_2掂僵、post_6、post_10 等分配到 2 號庫顷歌,post_3锰蓬、post_5、post_11 等分配到 4 號庫眯漩。

從配置文件中構(gòu)建 MySQLGroupStore 數(shù)據(jù)庫組對象芹扭,這個對象是我們執(zhí)行 MySQL 操作的入口麻顶,通過它可以找到具體的物理的 MySQL 主從數(shù)據(jù)源。

@Configuration
public class RepoConfig {

    @Autowired
    private Environment env;

    private MySQLGroupBuilder mysqlGroupBuilder = new MySQLGroupBuilder();

    @Bean
    @Qualifier("post")
    public MySQLGroupStore replyMySQLGroupStore() {
        MySQLGroupStore store = mysqlGroupBuilder.buildStore(env, "post");
        store.prepare(factory -> {
            factory.getConfiguration().addMapper(PostMapper.class);
        });
        return store;
    }
}

配置文件 application.properties 如下

mysql.post0.master.addrWeights=localhost:3306
mysql.post0.master.db=sample
mysql.post0.master.user=sample
mysql.post0.master.password=123456
mysql.post0.master.poolSize=10

mysql.post0.slave.addrWeights=localhost:3307=100&localhost:3308=100
mysql.post0.slave.db=sample
mysql.post0.slave.user=sample
mysql.post0.slave.password=123456
mysql.post0.slave.poolSize=10

mysql.post1.master.addrWeights=localhost:3309
mysql.post1.master.db=sample
mysql.post1.master.user=sample
mysql.post1.master.password=123456
mysql.post1.master.poolSize=10

mysql.post1.slave.addrWeights=localhost:3310=100&localhost:3311=100
mysql.post1.slave.db=sample
mysql.post1.slave.user=sample
mysql.post1.slave.password=123456
mysql.post1.slave.poolSize=10

mysqlgroup.post.nodes=post0,post1
mysqlgroup.post.slaveEnabled=true

這里的數(shù)據(jù)庫組是由多個對等的 Master-Slaves 對構(gòu)成舱卡,每個 Master-Slaves 是由一個主庫和多個不同權(quán)重的從庫構(gòu)成辅肾,Master-Slaves 對的數(shù)量就是分庫的數(shù)量。

mysqlgroup 還有一個特殊的配置選項 slaveEnabled 來控制是否需要從庫轮锥,從而關(guān)閉讀寫分離矫钓,默認(rèn)是關(guān)閉的,這樣就不會去構(gòu)建從庫實例相關(guān)對象舍杜。

post_k 這張表后綴 k 我們稱之為 partition number新娜,也就是后續(xù)代碼中到處在用的 partition 變量,表明當(dāng)前的記錄被分配到對應(yīng)物理數(shù)據(jù)表的序號既绩。我們需要根據(jù)記錄的內(nèi)容計算出 partition number概龄,再根據(jù) partition number 決定出這條記錄所在的物理表屬于那個物理數(shù)據(jù)庫,然后對這個物理數(shù)據(jù)庫進(jìn)行相應(yīng)的讀寫操作饲握。

在本例中私杜,帖子表按照 userId 字段 hash 出 64 張表,平均分配到 2 對物理庫中互拾,每個物理庫包含一個主庫和2個從庫。

有了 MySQLGroupStore 實例嚎幸,我們就可以盡情操縱所有數(shù)據(jù)庫了颜矿。

@Repository
public class PostMySQL {

    @Autowired
    private PartitionConfig partitions;

    @Autowired
    @Qualifier("post")
    private MySQLGroupStore mysql;

    public void createTables() {
        for (int i = 0; i < partitions.post(); i++) {
            int k = i;
            mysql.master(k).execute(session -> {
                PostMapper mapper = session.getMapper(PostMapper.class);
                mapper.createTable(k);
            });
        }
    }

    public void dropTables() {
        for (int i = 0; i < partitions.post(); i++) {
            int k = i;
            mysql.master(k).execute(session -> {
                PostMapper mapper = session.getMapper(PostMapper.class);
                mapper.dropTable(k);
            });
        }
    }

    public Post getPostFromMaster(String userId, String id) {
        Holder<Post> holder = new Holder<>();
        int partition = this.partitionFor(userId);
        mysql.master(partition).execute(session -> {
            PostMapper mapper = session.getMapper(PostMapper.class);
            holder.value(mapper.getPost(partition, id));
        });
        return holder.value();
    }

    public Post getPostFromSlave(String userId, String id) {
        Holder<Post> holder = new Holder<>();
        int partition = this.partitionFor(userId);
        mysql.slave(partition).execute(session -> {
            PostMapper mapper = session.getMapper(PostMapper.class);
            holder.value(mapper.getPost(partition, id));
        });
        return holder.value();
    }

    public void savePost(Post post) {
        int partition = this.partitionFor(post);
        mysql.master(partition).execute(session -> {
            PostMapper mapper = session.getMapper(PostMapper.class);
            Post curPost = mapper.getPost(partition, post.getId());
            if (curPost != null) {
                mapper.updatePost(partition, post);
            } else {
                mapper.insertPost(partition, post);
            }
        });
    }

    public void deletePost(String userId, String id) {
        int partition = this.partitionFor(userId);
        mysql.master(partition).execute(session -> {
            PostMapper mapper = session.getMapper(PostMapper.class);
            mapper.deletePost(partition, id);
        });
    }

    private int partitionFor(Post post) {
        return Post.partitionFor(post.getUserId(), partitions.post());
    }

    private int partitionFor(String userId) {
        return Post.partitionFor(userId, partitions.post());
    }
}

從上面的代碼中可以看出所有的讀寫、創(chuàng)建嫉晶、刪除表操作的第一步都是計算出 partition number骑疆,然后根據(jù)它來選出目標(biāo)主從庫再進(jìn)一步對目標(biāo)的數(shù)據(jù)表進(jìn)行操作。這里我默認(rèn)開啟了autocommit替废,所以不需要顯式來 session.commit() 了箍铭。

mysql.master(partition)
mysql.slave(partition)

// 如果沒有分庫
mysql.master()
mysql.slave()

// 如果既沒有分庫也沒有讀寫分離
mysql.db()

// 操作具體的表時要帶 partition
mapper.getPost(partition, postId)
mapper.savePost(partition, post)

在對數(shù)據(jù)表的操作過程中,又需要將具體的 partition number 傳遞過去椎镣,如此 MyBatis 才能知道具體操作的是哪個分表诈火。

public interface PostMapper {

    @Update("create table if not exists post_#{partition}(id varchar(128) primary key not null, user_id varchar(1024) not null, title varchar(1024) not null, content text, create_time timestamp not null) engine=innodb")
    public void createTable(int partition);

    @Update("drop table if exists post_#{partition}")
    public void dropTable(int partition);

    @Results({@Result(property = "createTime", column = "create_time"),
            @Result(property = "userId", column = "user_id")})
    @Select("select id, user_id, title, content, create_time from post_#{partition} where id=#{id}")
    public Post getPost(@Param("partition") int partition, @Param("id") String id);

    @Insert("insert into post_#{partition}(id, user_id, title, content, create_time) values(#{p.id}, ${p.userId}, #{p.title}, #{p.content}, #{p.createTime})")
    public void insertPost(@Param("partition") int partition, @Param("p") Post post);

    @Update("update post_#{partition} set title=#{p.title}, content=#{p.content}, create_time=#{p.createTime} where id=#{p.id}")
    public void updatePost(@Param("partition") int partition, @Param("p") Post post);

    @Delete("delete from post_#{partition} where id=#{id}")
    public void deletePost(@Param("partition") int partition, @Param("id") String id);
}

在每一條數(shù)據(jù)庫操作中都必須帶上 partition 參數(shù),你可能會覺得這有點(diǎn)繁瑣状答。但是這也很直觀冷守,它明確地告訴我們目前正在操作的是哪一個具體的分表。

在 MyBatis 的注解 Mapper 類中惊科,如果方法含有多個參數(shù)拍摇,需要使用 @Param 注解進(jìn)行名稱標(biāo)注,這樣才可以在 SQL 語句中直接使用相應(yīng)的注解名稱馆截。否則你得使用默認(rèn)的變量占位符名稱 param0充活、param1 來表示蜂莉,這就很不直觀。

我們將分表的 hash 算法寫在實體類 Post 中混卵,這里使用 CRC32 算法進(jìn)行 hash映穗。

public class Post {
    private String id;
    private String userId;
    private String title;
    private String content;
    private Date createTime;

    public Post() {}

    public Post(String id, String userId, String title, String content, Date createTime) {
        this.id = id;
        this.userId = userId;
        this.title = title;
        this.content = content;
        this.createTime = createTime;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public Date getCreateTime() {
        return createTime;
    }

    public void setCreateTime(Date createTime) {
        this.createTime = createTime;
    }

    public int partitionFor(int num) {
        return partitionFor(userId, num);
    }

    public static int partitionFor(String userId, int num) {
        CRC32 crc = new CRC32();
        crc.update(userId.getBytes(Charsets.UTF8));
        return (int) (Math.abs(crc.getValue()) % num);
    }
}

代碼中的 partitionFor 方法的參數(shù) num 就是一共要分多少表。如果是按日期來分表淮菠,這個參數(shù)可能就不需要男公,直接返回日期的整數(shù)就行比如 20190304。

還有最后一個問題是多個帶權(quán)重的從庫是如何做到概率分配的合陵。這里就要使用到 spring-jdbc 自帶的 AbstractRoutingDataSource —— 帶路由功能的數(shù)據(jù)源枢赔。它可以包含多個子數(shù)據(jù)源,然后根據(jù)一定的策略算法動態(tài)挑選出一個數(shù)據(jù)源來拥知,這里就是使用權(quán)重隨機(jī)踏拜。

但是有個問題,我這里只需要這一個類低剔,但是需要引入整個 spring-boot-jdbc-starter 包速梗,有點(diǎn)拖泥帶水的感覺。我研究了一下 AbstractRoutingDataSource 類的代碼襟齿,發(fā)現(xiàn)它的實現(xiàn)非常簡單姻锁,如果就仿照它自己實現(xiàn)了一個簡單版的,這樣就不需要引入整個包代碼了猜欺。

public class RandomWeightedDataSource extends DataSourceAdapter {
    private int totalWeight;
    private Set<PooledDataSource> sources;
    private Map<Integer, PooledDataSource> sourceMap;

    public RandomWeightedDataSource(Map<PooledDataSource, Integer> srcs) {
        this.sources = new HashSet<>();
        this.sourceMap = new HashMap<>();
        for (Entry<PooledDataSource, Integer> entry : srcs.entrySet()) {
            // 權(quán)重值不宜過大
            int weight = Math.min(10000, entry.getValue());
            for (int i = 0; i < weight; i++) {
                sourceMap.put(totalWeight, entry.getKey());
                totalWeight++;
            }
            this.sources.add(entry.getKey());
        }
    }

    private PooledDataSource getDataSource() {
        return this.sourceMap.get(ThreadLocalRandom.current().nextInt(totalWeight));
    }

    public void close() {
        for (PooledDataSource ds : sources) {
            ds.forceCloseAll();
        }
    }

    @Override
    public Connection getConnection() throws SQLException {
        return getDataSource().getConnection();
    }

    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        return getDataSource().getConnection(username, password);
    }
}

還需進(jìn)一步深入理解其實現(xiàn)代碼的可以將 shardino 代碼倉庫拉到本地跑一跑

git clone https://github.com/pyloque/shardino.git

里面有單元測試可以運(yùn)行起來位隶,運(yùn)行之前需要確保本機(jī)安裝了 docker 環(huán)境

docker-compose up -d

這條指令會啟動2對主從庫,各1主兩從开皿。

在本例中雖然用到了 springboot 涧黄,其實也只是用了它方便的依賴注入和單元測試功能,shardino 完全可以脫離 springboot 而獨(dú)立存在赋荆。

shardino 并不是一個完美的開源庫笋妥,它只是一份實現(xiàn)代碼的樣板,如果讀者使用的是其它數(shù)據(jù)庫或者 MySQL 的其它版本窄潭,那就需要自己微調(diào)一下代碼來適配了春宣。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市嫉你,隨后出現(xiàn)的幾起案子信认,更是在濱河造成了極大的恐慌,老刑警劉巖均抽,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件嫁赏,死亡現(xiàn)場離奇詭異,居然都是意外死亡油挥,警方通過查閱死者的電腦和手機(jī)潦蝇,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進(jìn)店門款熬,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人攘乒,你說我怎么就攤上這事贤牛。” “怎么了则酝?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵殉簸,是天一觀的道長。 經(jīng)常有香客問我沽讹,道長般卑,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任爽雄,我火速辦了婚禮蝠检,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘挚瘟。我一直安慰自己叹谁,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布乘盖。 她就那樣靜靜地躺著焰檩,像睡著了一般。 火紅的嫁衣襯著肌膚如雪订框。 梳的紋絲不亂的頭發(fā)上析苫,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天,我揣著相機(jī)與錄音布蔗,去河邊找鬼藤违。 笑死浪腐,一個胖子當(dāng)著我的面吹牛纵揍,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播议街,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼泽谨,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了特漩?” 一聲冷哼從身側(cè)響起吧雹,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎涂身,沒想到半個月后雄卷,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡蛤售,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年丁鹉,在試婚紗的時候發(fā)現(xiàn)自己被綠了妒潭。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡揣钦,死狀恐怖雳灾,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情冯凹,我是刑警寧澤谎亩,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站宇姚,受9級特大地震影響匈庭,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜空凸,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一嚎花、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧呀洲,春花似錦紊选、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至滓窍,卻和暖如春卖词,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背吏夯。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工此蜈, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人噪生。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓裆赵,卻偏偏與公主長得像,于是被迫代替她去往敵國和親跺嗽。 傳聞我的和親對象是個殘疾皇子战授,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容