diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..d3eb82c Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index d092ed8..1acebcf 100644 --- a/README.md +++ b/README.md @@ -5,47 +5,37 @@ 投稿

+这是我学习Java的知识总结,我会按照下面的技术栈一步步完善整个知识体系。 -**文章首发公众号**。如果有帮助到大家,希望点个**Star**!让我有持续的动力,感谢🤝
+分享给正在学习Java的你们,希望可以帮助你们少走一些弯路,一起学习进步! -每周至少一篇的更新频率,没链接的是之后打算写的。 - -:pencil2: [写作自述](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486050&idx=1&sn=1105d28b8d3553715f425419ec9d8d18&chksm=cf8479a7f8f3f0b19425e08a00332bbce4e5333843cfd6dd6f35e892139810d86b778cd57d55#rd) +**文章首发公众号,每周至少一更。如果有帮助到大家,希望点个Star!让我有持续的动力,感谢🤝**
### :star: Java ### - [深入详解ThreadLocal](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486776&idx=1&sn=f4425cb88bc5393e4d5125f5fd08ed68&chksm=cf847efdf8f3f7ebc79c5bcd3c47f1fc2f83abf119c2b22782cc90a1c69f606a95a4051dab53#rd) - - [使用Optional优雅避免空指针异常](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486914&idx=1&sn=b2b0f2c41b8168fbfcf1df21a3e00acb&chksm=cf847e07f8f3f711de06cb9269ba41541ec9399a56963768add081031566bf7fa49cbb6f7fa0#rd) - - [我画了35张图就是为了让你深入 AQS](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486172&idx=1&sn=b39cccd87dcd21176597dce0b15f7232&chksm=cf847919f8f3f00f86219d44cd95badee969d754aec89e644992437f2e8e0f7ad784695b4d90#rd) - - [一个 static 还能难得住我?](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486175&idx=1&sn=041c85c052c11d2d15243994bc46d90a&chksm=cf84791af8f3f00c90a18b29d1fa47c9bcd713651514fc5ce4a9f82d656fe637bb21d45c42be#rd) - - [原来这才是动态代理!!!](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486178&idx=1&sn=9610c1a0fa1df4c69558408ab2a3fcae&chksm=cf847927f8f3f0315b0c86f9b577926820c3d264d605149f850b597fcd17fafe432d82aaffcf#rd) - - [synchronized 的超多干货!](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486181&idx=1&sn=4cb9340ba2f19ccb19ccec0c54d61b86&chksm=cf847920f8f3f036cd752455290a97f6584f8a4ce9662d1102515dd5ed967c94e14cec7a767d#rd) - +- [ExecutorCompletionService详解](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487958&idx=1&sn=2ace7ac53d596cd909d1d1c7e96fbff2&chksm=cf846213f8f3eb05c9de1fab2c609f4774ca86497ad5542a26aae5928efd808bfd865738aa4f#rd) +- [CompletableFuture深度解析](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488046&idx=1&sn=2bb0b6dc4576278ff2e7f9b917cb6fe8&chksm=cf8461ebf8f3e8fd013d08c5028d41281444b1ac1d60f1706c841c4b444a4235d82c84644b9b#rd) +- [面试官:响应式编程和虚拟线程怎么选?看完这篇不再被问倒](https://mp.weixin.qq.com/s/V7H_hyjycT3n1Rr7FBOrYQ) ### :page_facing_up: JVM ### - [面试官:JVM是如何判定对象已死的?学JVM必会的知识!](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486087&idx=1&sn=c6f1a9932961095ffdf2aef8a789e115&chksm=cf847942f8f3f0549c798671fe804c93378586b4fc547cce14db2359852ff0723a3aab64a187#rd) - - [GC的前置工作,聊聊GC是如何快速枚举根节点的](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486168&idx=1&sn=9eef35ec701b5c2f8097641b7e69ae71&chksm=cf84791df8f3f00b1e85039f31b17e00bf9cb624bbee638efeca110e51df6c6b6ba6363705ee#rd) - - [GC面临的困境,JVM是如何解决跨代引用的?](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486242&idx=1&sn=83d4ace26fea86b0f16e93e25b3cdadf&chksm=cf8478e7f8f3f1f17a65a7fc0d25237e8f25b90f300085bb5a7e8128f7d80f5ba1a02e5a6c2f#rd) - - [昨晚做梦面试官问我三色标记算法](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486265&idx=1&sn=1464f25915c2c09ef65b784985b76fa3&chksm=cf8478fcf8f3f1ea80715ae949c1b4aec988368ead269c746d38244ae62028948a199f099d14#rd) - - [深入解析CMS垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486628&idx=1&sn=984b273af7d1d0398517a2f5442ffb38&chksm=cf847f61f8f3f677372a5ebc9f81403a8324be1bed49bf92e763882715c943324de4f1b0139a#rd) - - [深入解析G1垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486736&idx=1&sn=5e0710485783c3bcc4854a10412b9a40&chksm=cf847ed5f8f3f7c3826fa8c67bc76ce8dd218a725ee04f54cdafa27e14d190f5c92332589ae2#rd) - - [深入解析ZGC垃圾回收器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486746&idx=1&sn=7257ecf8c36509d06be359e3889400f2&chksm=cf847edff8f3f7c96edc667051d9ef70537000202c1ec77699fa5e30e46c2c8ddabd122297f3#rd) ### :hammer: MySQL ### -- [MySQL双写缓冲区(Doublewrite Buffer)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484456&idx=1&sn=b5154c5eb26b969655c1b430792e0cb6&chksm=cf8477edf8f3fefbe0c95c2074a461ab12c01926654d995ad7844cba332fda7744da6b47ddc5#rd) +- [深入解析 MySQL 双写缓冲区](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487013&idx=1&sn=beae861ca0f148e010d4170d14f67fdd&chksm=cf847de0f8f3f4f631273fbc7b9739239772cf90ad94fe78e83eb006d6a700ba2f00faffac09#rd) - [再深入一点|binlog和relay-log到底长啥样?](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486183&idx=1&sn=adc83df6c78e53ed1aefec7edc40ed63&chksm=cf847922f8f3f034beb08fc0a6fa2df8acb64902adff6927b71b5582e54444baa5c7265f7db8#rd) @@ -53,29 +43,35 @@ - [听说你对explain 很懂?](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486188&idx=1&sn=4ebf475e7287e4b9cc0e37fdff0c18af&chksm=cf847929f8f3f03fba7173a17f8a04a677db9af91355cba552f5156b7fc9424ccf0fd87f8488#rd) -- [MMR(Multi-Range Read Optimization)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484466&idx=1&sn=29b6a9adfa2fee52e6391509d1b8c73f&chksm=cf8477f7f8f3fee1ea1793924cf8475f7581a2770f8804a54a60c61f57aac4ce64dff723c308#rd) +- [深入浅出MySQL MRR(Multi-Range Read)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487041&idx=1&sn=66921cd6949db1389a0f02b3764b250f&chksm=cf847d84f8f3f4925b6506aeabe55308c85a68cb1fb8bf09aa99eca721d881246700bd9851a4#rd) - [拿捏!隔离级别、幻读、Gap Lock、Next-Key Lock](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486190&idx=1&sn=c274fbc3daed3d1ac3a1ce5bd0009b68&chksm=cf84792bf8f3f03d07e2855570164cbfc0f0a7fbb0bba1fd50c8b7b2155c555c4438b625f395#rd) -- [MySQL中的Join 的算法(NLJ、BNL、BKA)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484480&idx=1&sn=e75482a0fd8a866d9a9565aa9e659009&chksm=cf847785f8f3fe93195de380f7cff3efc8950a2cb49127a669f6b14a30e2fa5c13285e905f6b#rd) +- [深入理解MySQL中的Join算法](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487068&idx=1&sn=042ab289718dbdaaea1b62854610efb7&chksm=cf847d99f8f3f48fd0aa04eeb2f6932bc826770f80911eec2fc571bdc7a50abc387714488d72#rd) -- [MySQL分区表详解](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484856&idx=1&sn=ffb350c8b1e74667fe15a5e808faec57&chksm=cf84767df8f3ff6b30ff91dd14a6f802eaac01076dcc8b988a18f1a529f21a6266d34e34c4d7#rd) +- [MySQL分区表详解](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487126&idx=1&sn=d81d7fa7b2befa0637bc9df5f4292915&chksm=cf847d53f8f3f445c92c1ae37478e47be947829a70b68d1e0f7f7d74af2d7ee15e6fca657845#rd) - [缓存和数据库一致性问题,看这篇就够了](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486196&idx=1&sn=e9dcd1824583546aada0096e457afda0&chksm=cf847931f8f3f02780828e9fb2b2f36d018d74583fb7091bdbe6b7565bdfa10a396b4bfa9965#rd) +- [全网最详细MVCC讲解,一篇看懂](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487999&idx=1&sn=8abdf89c27bbedd788d6ea260cb981c3&chksm=cf84623af8f3eb2cd63c4f6f80fda0822d14c3bbf49487f254377f13963ccaf89b1d04344128#rd) + +- [六个案例搞懂间隙锁](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488470&idx=1&sn=2a2b56e35ac6e1bae83e1a7eadc743f1&chksm=cf846013f8f3e9051b5e0d3636bcf135b994d77039409f92b7faeccdedddb6d5cfda6eaf0984#rd) + ### :envelope: Redis ### - [Redis类型(Type)与编码(Encoding)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486922&idx=1&sn=98b7e28fc9ed20b69dc236605dfd1c34&chksm=cf847e0ff8f3f7197ece7d7b96c7fa82328d7e66b969a37246ac51f4c4dd21056540b046cbe6#rd) + +- [Redis性能优化:理解与使用Redis Pipeline](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486953&idx=1&sn=76365046920ead36714bbdf64300739b&chksm=cf847e2cf8f3f73ab5dc16d82817bde96a5ba5f16903896bae2943773df87a11153c612eeeb9#rd) -- [布隆过滤器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484400&idx=1&sn=8d480b6b87ee2330e1e5f181fbf5f71a&chksm=cf847035f8f3f923699cd0b3c9137aa6bd596abd0242abe73abeee3179392794538e87e2dc42#rd) +- [布隆过滤器:原理与应用](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487003&idx=1&sn=c98c8a0643ae56ac0d81572aeabcc279&chksm=cf847ddef8f3f4c86f14b317375e395124f9278e5dbd7daec854a8a342f77992f1e6b9775249#rd) -- [Redis跟MySQL的双写问题](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484390&idx=1&sn=de37dc02c20f3b471404c507c3741550&chksm=cf847023f8f3f935233feb3c575c7798e41d695347f11f502a75f25ba02b479ad152572c666e#rd) +- [探索 Redis 与 MySQL 的双写问题](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486966&idx=1&sn=1aa2fc4d096242a8b725e01d45327a0c&chksm=cf847e33f8f3f72529da952b0621f7faf1756e5fd24e50c0d1896d98eab097e5bbf74aa218dd#rd) - [Redis内存碎片:深度解析与优化策略](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486935&idx=1&sn=0b41d8807b6f0cdd06172f587884aa7a&chksm=cf847e12f8f3f70469ee692017388360a767175c9a3cbe482f2d93232c52540e43e5c8c8034e#rd) -- [Redis中的BigKey问题:排查与解决思路](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484415&idx=1&sn=39cb685de9880bbe8fb108518cd5d54d&chksm=cf84703af8f3f92ca802e8e567a1f9bcdd038574113fe7aa68091db9f32cd86f6957862ee83a#rd) +- [Redis中的Big Key问题:排查与解决思路](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487157&idx=1&sn=9cc48fd498f6633fdc49c11f7cd6b88f&chksm=cf847d70f8f3f466319083703cff3623d0ec92a6d47d9594c4b0547d9489805dfefba2ecd179#rd) -- [非看不可的Redis持久化](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484435&idx=1&sn=e02f552e3d943787fdd5442ab49eb95a&chksm=cf8477d6f8f3fec05b7b8441cc19898bf9a4c4e9ffaa72827eba2e20735a0f37f840c4e2e495#rd) +- [超详细!彻底说明白Redis持久化](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488849&idx=1&sn=d15830cf43364a8c9e3c9292d53cce05&chksm=cf846694f8f3ef825cb84436e2c931d9e22c2cdf810a546a3edb301a539168e0ce33c868d115#rd) - [深度剖析:Redis分布式锁到底安全吗?看完这篇文章彻底懂了!](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486194&idx=1&sn=59c36ccae0a67063e4b29aba5084ffe0&chksm=cf847937f8f3f0211b989c65ff07c8b142ddd7752592f018488cb852a9062b587bbe8b2b3d3e#rd) @@ -84,28 +80,40 @@ - [Redis最佳实践:7个维度+43条使用规范,带你彻底玩转Redis | 附实践清单](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486200&idx=1&sn=52dc758e32d138efcba25a7a47aec23d&chksm=cf84793df8f3f02b497d68f6f9407f7681b7eda3b2c88c87ac0f0411a7d0df4cf2ec8b9ba781#rd) - [颠覆认知——Redis会遇到的15个「坑」,你踩过几个?](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486202&idx=1&sn=5fee614b5272fb9e3522f446bddc6132&chksm=cf84793ff8f3f02961bdccd2310d052231bc3023cd609afe71d7e648bcda48aa2c57eed65371#rd) -### :lock:Elasticsearch ### -- [学好Elasticsearch系列-核心概念](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485450&idx=1&sn=b23b362f8baac883e6a64b0cb05b184d&chksm=cf847bcff8f3f2d98ef829ff3f7c8cee59600b6b2c683564e2ab6af2c0547fc67b8ccbc837e9#rd) - -- [学好Elasticsearch系列-索引的CRUD](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485479&idx=1&sn=eb2b57e78d1f08c398558b2f23063df0&chksm=cf847be2f8f3f2f4567bd65048aba533355c70733bef9a14580ffcfadd01678acf01d62a92f4#rd) - -- [学好Elasticsearch系列-Mapping](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485492&idx=1&sn=e33d0689502b043723b0c2e4f0660a1d&chksm=cf847bf1f8f3f2e75fc2a8dd4542572f2dc7e4706f4817d2f9cd2ae18d3f28a1455da7873207#rd) - -- [学好Elasticsearch系列-Query DSL](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485520&idx=1&sn=97803ad983c80a90158b5b9efabcc8b7&chksm=cf847b95f8f3f2839fec2550df3dccb55e91b5cadcfc11ea2e9e25b27a882aceb5dca5fe2b96#rd) - -- [学好Elasticsearch系列-分词器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485544&idx=1&sn=cfa20adbb5c7328ea0cab85966d95c02&chksm=cf847badf8f3f2bbefd1b9e893cccf10a24c2a83f8052b613c62c999566e4c8616fded236552#rd) - -- [学好Elasticsearch系列-聚合查询](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485562&idx=1&sn=dd965afcd5697a152dae32d46e3996cd&chksm=cf847bbff8f3f2a91c7c75af03809359f7c9963c32e1da19b79939ccc283c46b3cf8038bd917#rd) - -- [学好Elasticsearch系列-索引的批量操作](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485594&idx=1&sn=71da9fcc473e3891b19c37af782ae7cb&chksm=cf847b5ff8f3f249e7f42ccac125982aa3abd8617bb5cc5466b2c5d381dae51f4d76b9f11d3c#rd) - -- [学好Elasticsearch系列-脚本查询](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485648&idx=1&sn=a0b075e6c2bad836a4c4eb6cacbaff5a&chksm=cf847b15f8f3f2033b5e18b8376b14205902898fb14c40a955a10619a74b1d35552a046dd7d1#rd) +### :lock: Elasticsearch ### + +- [一起学Elasticsearch系列-核心概念](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487646&idx=1&sn=381af0374eb1d512046315164a541211&chksm=cf84635bf8f3ea4d650dc00e277d273d9a1e7358ae884b3cfedaf9d172cb1f44df7775bf355a#rd) +- [一起学Elasticsearch系列-索引的CRUD](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487642&idx=1&sn=ea2cc5a3e0be25a0a81abe860b183f09&chksm=cf84635ff8f3ea49944ad35ee9bba7e60bf3f9c9c72d83c3d103403739d58a49fe2ff4779314#rd) +- [一起学 Elasticsearch 系列 -Mapping](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487661&idx=1&sn=40c8a1a43c172c8500b975c6e1b35b39&chksm=cf846368f8f3ea7e451022e0e72b1a9d57c2641d56f9d0158fb8849d2806294e3dc7e3f71cde#rd) +- [一起学Elasticsearch系列-Query DSL](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487687&idx=1&sn=9622f7220358daab3a5dddd9fef3d2b7&chksm=cf846302f8f3ea147dac58d003d20495ce1feeec910423e59d917272e890590d7b769ec71510#rd) +- [一起学 Elasticsearch 系列-分词器](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487710&idx=1&sn=bce31911a3259f77cd5f5874c255e74c&chksm=cf84631bf8f3ea0dd8c2b816950f1fe7f04f529abf02512ca778b8e2fa2b66b117f15d6d44bb#rd) +- [一起学Elasticsearch系列-聚合查询](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487728&idx=1&sn=f4e43d386925b7cec5115fb388e00843&chksm=cf846335f8f3ea2371df0bfbb16b1f6c2dfb553d90762edd9bec9c2b9839f6043de4625c1a36#rd) +- [一起学Elasticsearch系列-脚本查询](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487750&idx=1&sn=b7b7bdcc8736d4bfc2092bd5c3511084&chksm=cf8462c3f8f3ebd526b06d283b723844d548299957e4a8d1c5cf60e1a6fa70b5e97477f7cca8#rd) +- [一起学Elasticsearch系列-索引的批量操作](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487779&idx=1&sn=54d06ae4f6a1aa62702cc61349e763b2&chksm=cf8462e6f8f3ebf026fe41fc0c8fb77b743687f0460a5eae970c2af95264e3556764911b2f1d#rd) +- [一起学Elasticsearch系列-模糊搜索](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487791&idx=1&sn=5878be01f10e3834445c64cb6351a872&chksm=cf8462eaf8f3ebfcb0a49c57d68721448d649b1dfcd91f3af549ef1192c95fb6a00d25c386db#rd) +- [一起学Elasticsearch系列-搜索推荐](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487818&idx=1&sn=ee11f629be04cf427193d0fee36bc6e2&chksm=cf84628ff8f3eb99baaa38cfcd126a5c11143a5e14e8571e33b1ddada1403001a7c955f1486f#rd) +- [一起学Elasticsearch系列 -Nested & Join](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487856&idx=1&sn=d13fbb78f8093ef1ac85f4f4b4abe543&chksm=cf8462b5f8f3eba3ad95ad2cead38c8413565262917cb2a10c5f7c5287189f600a93014ffcca#rd) +- [一起学Elasticsearch系列-深度分页问题](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487873&idx=1&sn=37c93764bd0ddad6c5d7a7b01d9eca3a&chksm=cf846244f8f3eb529197122f0bc15256c7cf865d1c2f7b23d2fee8dcd1259a5d02e2fc12da54#rd) +- [一起学Elasticsearch系列-并发控制](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487882&idx=1&sn=9afba52dddff1e9a7269bc9dc23e4893&chksm=cf84624ff8f3eb597c445f21a71df24bc334d58b1108432fcf5acf92498cfb8a22fee07af059#rd) +- [一起学Elasticsearch系列-写入原理](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487898&idx=1&sn=b9960c61fb853619d1e52258cb5819a0&chksm=cf84625ff8f3eb492810d73713742b8459688fe7f9ef803354734bd9aceb7cdbc550a0ba6a17#rd) +- [一起学Elasticsearch系列-写入和检索调优](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487903&idx=1&sn=45211a9d2b39c8433208f95af3ff3922&chksm=cf84625af8f3eb4c542e4b4082064e5abd2f2e21806a8f3e15409e02660afd820a4e5b854a9b#rd) +- [一起学Elasticsearch系列-索引管理](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487926&idx=1&sn=3e7a6e6de02de000657b142d4bee5e82&chksm=cf846273f8f3eb656a9be4fa060dc23f1aba6ac3ca0b41087511a5787fe8b523a4c1db9bda15#rd) +- [一起学Elasticsearch系列-Pipeline](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488897&idx=1&sn=2d4adc6d38813efa5a66a4f7c2763375&chksm=cf846644f8f3ef52577656d9a6d9cf5dbaef7f6dc61d21df181c61279237380f9ddd111fad69#rd) + +### :mag_right: DDD ### + +- [熬夜整理的2W字DDD学习笔记](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247489048&idx=1&sn=6dc6f884fe7ca0e07d86aaa6e37ba4ab&chksm=cf8465ddf8f3eccbae516e3405dff032dfcc0c2820b187f4188caca2e63bed88d99fbb11b8c1#rd) -### :date:框架 ### +### :birthday: Spring ### + +- [如何优雅地Spring事务编程](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247489087&idx=1&sn=60d6e29a87753beb69754bb633cbfe6e&chksm=cf8465faf8f3ececad33d8f42a7644e44eb05081c613761b39fc4bf46425058e9e3e5bbcf52b#rd) + +### :date: 框架 ### - [本地缓存无冕之王Caffeine Cache](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486885&idx=1&sn=37c7a9461402bd97822295cf51361777&chksm=cf847e60f8f3f776eb3b477decfbac55dc8b7ae1cf607ef68fbee89dbe02d40a800a92fabec7#rd) +- [响应式编程不只有概念!万字长文 + 代码示例,手把手带你玩转 RxJava](https://mp.weixin.qq.com/s/r0DJiOxR8wnZZ6tIKrSPzg) -### :fire:架构设计 ### +### :fire: 架构设计 ### - [高并发系统设计之负载均衡](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486811&idx=1&sn=5422c62878ee1ddcc6ee1da45deb78d7&chksm=cf847e9ef8f3f7889c94fe93796c87083ebb47680ef13b40a35f5127c293e5d44fd3621abd57#rd) @@ -115,22 +123,67 @@ - [搞懂异地多活,看这篇就够了](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247486192&idx=1&sn=6c82786cf2403486d81f375be684f228&chksm=cf847935f8f3f023a167ea3272a35979ee623dd0207acb70a0898148a1b18a15df01a49e54d8#rd) -### :dash:编程语言 ### +- [12306技术内幕](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247489137&idx=1&sn=9f5857733a5bed241902fb4c4421adf3&chksm=cf8465b4f8f3eca2d7d65af8cd8a1b3e9b1b3b56d482eb09cdd5fdd0ae3b7744a3994c495cbf#rd) -- [Scala初学者指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484574&idx=1&sn=85ac7b748ec8f22e3e8f8f42efca02d1&chksm=cf84775bf8f3fe4d2779871e106e1946293bb57d6f85ad44aa1f20fe467e9402a26807a532b4#rd) - -- [Groovy初学者指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484641&idx=1&sn=7243e662d2b0a811f1be745777c30420&chksm=cf847724f8f3fe329f4414fb3fa9c262e0d2985f6996b64b519b79b1c6ae3f65fb894d27bb76#rd) +- [业务幂等性设计的六种方案](https://mp.weixin.qq.com/s/HZAkGPNrC05aeHabhqT-zA) -### :eyes:大数据 ### +### :dash: 编程语言 ### -- [Spark入门这篇就够了(万字长文)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484731&idx=1&sn=033b31376869f2046219dfe28707e43d&chksm=cf8476fef8f3ffe86a1910e5948afddba464e6cc186adb3ecb8ca62f612a4110b71ada2370cc#rd) - -- [一篇文章带你入门HBase](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247484823&idx=1&sn=4f8204e007c2201962cd707fc5668242&chksm=cf847652f8f3ff443ef516bf490656b21891eee42382970435a25451948daf89c2d72cfad96f#rd) - -- [全网最详细4W字Flink入门笔记(上)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485174&idx=1&sn=4cbd4ce941458fa576febb5021d1942f&chksm=cf847533f8f3fc25fa6e9c0c6de69b68b3f424aad52e6846dca8641d11a8be7ea8614682ad64#rd) +- [Scala语言入门:初学者的基础语法指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487245&idx=1&sn=d089e22890f1f7449b7cf34e3cf2f6ed&chksm=cf847cc8f8f3f5deb39556f4229bafb6f1498906dc1d75040f90817bf0396117a7c2cdb498f9#rd) +- [Groovy 初学者指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487066&idx=1&sn=da9e3a9aff377d383e34e537e2f55666&chksm=cf847d9ff8f3f489011f26a784302ee68b9c1d7d57d52bc2c924a7c9b1a5f528ef2a417114c0#rd) +- [自研 DSL 神器:万字拆解 ANTLR 4 核心原理与高级应用](https://mp.weixin.qq.com/s/nFiEqhi1B_SxrZGCAqLgLw) + +### :satellite: 设计模式 ### + +- [一文搞懂设计模式—策略模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488583&idx=1&sn=2e4758ee1b48dc5884289d5ecb841491&chksm=cf846782f8f3ee946d514e6267a326facb6880002fb663ffdbc3347e1d89fab32b8c0f5764d9#rd) -- [全网最详细4W字Flink入门笔记(下)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247485365&idx=1&sn=c99d1e392440cad85342fdc950afc7f9&chksm=cf847470f8f3fd6632b34a42d8008f94430902630e4f5c8fb6ead1475facdc6e4ab39a5f828b#rd) +- [一文搞懂设计模式—责任链模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488600&idx=1&sn=dd004bacfe0262fcc0fd1d72ae506b7a&chksm=cf84679df8f3ee8bf7117c5bed745470475c1a35ee96fe2bb99e093d895b7b776ecf51bb31c2#rd) + +- [一文搞懂设计模式—单例模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488615&idx=1&sn=1f0f92f6180856dbf206706c071438a3&chksm=cf8467a2f8f3eeb4e24e6c42f7f8cd265c03939ffa7742d1445ed7dddc6b11bb63aa6b0eb95e#rd) + +- [一文搞懂设计模式—观察者模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488718&idx=1&sn=9486e6b494c666e805e321a74bac5591&chksm=cf84670bf8f3ee1da0e7f26963e007da8fa77e7190596c9a6f6296cf2cc70af2ec4c066b5906#rd) + +- [一文搞懂设计模式—门面模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488730&idx=1&sn=0f63b2f54615d0647d4be22964dadea2&chksm=cf84671ff8f3ee0900b2cd3174ab8e66131308049648cc4e65d25215a54cc457cea1e73c8698#rd) + +- [一文搞懂设计模式—工厂方法模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488747&idx=1&sn=025c94f5b66b4dae3ab6f62dc043a083&chksm=cf84672ef8f3ee38bfce967135b5d23a93c0d59ae502b06b3c85ac629e336d28a21f1e13784f#rd) + +- [一文搞懂设计模式—模板方法模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488758&idx=1&sn=40385dc2cd049ab06c6403c47e1d5efa&chksm=cf846733f8f3ee2596eb459b9114e654e5ffd0ca1565931b742c65879a8603fc75c74880be58#rd) + +- [一文搞懂设计模式—适配器模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488766&idx=1&sn=78bf2e392394bb28272cceb3dd300618&chksm=cf84673bf8f3ee2d8b6308d272e8cd3a0173bfd540639cd7c17c4c40f6bc3e25561a0889930d#rd) + +- [一文搞懂设计模式—装饰器模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488773&idx=1&sn=287d0323b84eec284a56edceb1e5840e&chksm=cf8466c0f8f3efd6e8a8e5270ed10cf991c7b27a93521359274c2120d9e45e29b637e948650f#rd) + +- [一文搞懂设计模式—代理模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488866&idx=1&sn=d293faa2c918d85cc9cd8f6bc408b5a1&chksm=cf8466a7f8f3efb11ae68d7f7712bb22ceafae6f9d0529c235a1b1e02d183296c400211910b2#rd) + +- [一文搞懂设计模式—享元模式](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488879&idx=1&sn=2cb5edc9bbb088fd6de2bf9d46cb76cf&chksm=cf8466aaf8f3efbce988a6bf96035714e5125ba8ddc6cfe02871ca73e61c814271608576ff13#rd) + + +### :eyes: 大数据 ### + +- [Spark入门指南:从基础概念到实践应用全解析](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487398&idx=1&sn=077859e1109e07b1469d242ec2b8091a&chksm=cf847c63f8f3f575e50012ef3667d9724998f07e32ebd27b6e3a37c5bdf2251d02e89030cff0#rd) + +- [HBase入门指南](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487105&idx=1&sn=2ee82c9b239aa502bd3dffcf320b3f93&chksm=cf847d44f8f3f452e1b8ac83b9f62f380e349615b67da92343539d4014077c2ad9e787e256cc#rd) + +- [全网最详细4W字Flink全面解析与实践(上)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487459&idx=1&sn=a1826b2d592fff29b5e11a374468796a&chksm=cf847c26f8f3f53073cc24584264fa2752a26c98bbd31c86bcf519296789eff05d72904d27ac#rd) + +- [全网最详细4W字Flink全面解析与实践(下)](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247487535&idx=1&sn=736f1adda56cc550191f17e7111598b5&chksm=cf8463eaf8f3eafc38819e342705df1884683e03d5d39e9df876834ab0a84f61cc55923a5a03#rd) + +### :watch:AI + +- [深度解析Skills:从Prompt到能力复用的技术革命](https://mp.weixin.qq.com/s/Se6_L1PbhlEUGaBSY8sZsQ) +- [为什么ChatGPT能听懂你说的话?Embedding技术揭秘](https://mp.weixin.qq.com/s/CoHcpXIaamdfmXCf-3qlgw) +- [RAG详解:让大模型看见你的私有知识](https://mp.weixin.qq.com/s/mAC3DeqPLM41LyfGh2QjUw) +- ReAct:让大模型学会边想边做 +- 10分钟掌握 JSON-RPC 协议,面试加分、设计不踩坑 + + +### :jack_o_lantern: 其他 ### +- [良心推荐!几款收藏的神级IDEA插件分享](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488457&idx=1&sn=f771ccebb84f226e7302b89caa5c056b&chksm=cf84600cf8f3e91aab4564d91feacb8822b53a2b3a79547439d64d2c0b7b293435a1ae79f994#rd) +- [实战Arthas:常见命令与最佳实践](https://mp.weixin.qq.com/s?__biz=Mzg4Nzc3NjkzOA==&mid=2247488559&idx=1&sn=4b5003cb33446ab4a6173285fe9d83d3&chksm=cf8467eaf8f3eefc033de8f63cba9f0d7b2b5eb0ccfb5209f458a9ab447367b34954f296638b#rd) +- [Maven实战](https://mp.weixin.qq.com/s/ErtWrRNzjJcR2ettUhAxsQ) +- [不用Mockito写单元测试?你可能在浪费一半时间](https://mp.weixin.qq.com/s/NICubD9Yq0pn6qwpVIznfg) +- [用好PowerMock,轻松搞定那些让你头疼的单元测试](https://mp.weixin.qq.com/s/rWIjqJKBQOe72RWW6qyJmA) ### :bulb: 资源 ### diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..1b0a35f Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/md/.DS_Store b/docs/md/.DS_Store new file mode 100644 index 0000000..e79a8fe Binary files /dev/null and b/docs/md/.DS_Store differ diff --git "a/docs/md/AI/10\345\210\206\351\222\237\346\216\214\346\217\241 JSON-RPC \345\215\217\350\256\256\357\274\214\351\235\242\350\257\225\345\212\240\345\210\206\343\200\201\350\256\276\350\256\241\344\270\215\350\270\251\345\235\221.md" "b/docs/md/AI/10\345\210\206\351\222\237\346\216\214\346\217\241 JSON-RPC \345\215\217\350\256\256\357\274\214\351\235\242\350\257\225\345\212\240\345\210\206\343\200\201\350\256\276\350\256\241\344\270\215\350\270\251\345\235\221.md" new file mode 100644 index 0000000..f4328dd --- /dev/null +++ "b/docs/md/AI/10\345\210\206\351\222\237\346\216\214\346\217\241 JSON-RPC \345\215\217\350\256\256\357\274\214\351\235\242\350\257\225\345\212\240\345\210\206\343\200\201\350\256\276\350\256\241\344\270\215\350\270\251\345\235\221.md" @@ -0,0 +1,291 @@ +分布式系统中,不同服务之间需要一种可靠的方式来通信。远程过程调用(RPC)是一种常见的选择,而 **JSON-RPC** 是其中比较简单的一种。 + +这篇文章介绍 JSON-RPC 2.0 协议的核心内容,包括消息格式、错误处理和实际应用场景。 + +## JSON-RPC 概述 + +### 什么是 JSON-RPC + +JSON-RPC 是一种无状态、轻量级的远程过程调用协议,使用 JSON 作为数据格式。它的设计目标就是简单——协议规范只有几页文档。 + +JSON 作为数据交换格式,几乎所有主流编程语言都有良好的支持,包括 JavaScript、Python、Java、C#、Go 等。这使得 JSON-RPC 能够轻松实现跨语言调用。 + +协议本身不绑定传输层,可以跑在 HTTP、WebSocket、TCP Socket 等各种消息传输环境上。开发者可以根据业务需求选择合适的传输方式。 + +### JSON-RPC 的发展历程 + +JSON-RPC 有两个主要版本:1.0 和 2.0。 + +1.0 版本最早提出了基于 JSON 的 RPC 概念,但在规范性方面有所欠缺。2010 年发布的 2.0 版本做了重要升级,加入了批量调用支持、统一的错误对象结构。两个版本通过 `jsonrpc` 字段来区分。 + +### JSON-RPC 的核心特点 + +JSON-RPC 有几个值得注意的特点: + +- **简洁性**:规范文档很短,请求和响应结构清晰明了。 +- **跨语言支持**:任何能解析 JSON 的语言都可以实现 JSON-RPC 客户端或服务端。 +- **无状态设计**:每个请求独立,协议不维护会话状态。这种设计简化了服务端的实现,也更容易水平扩展。 +- **批量处理能力**:2.0 版本支持在单个请求中包含多个 RPC 调用,服务端返回结果数组。 +- **双向通信**:通过长连接和通知机制,JSON-RPC 也支持服务端主动向客户端推送消息。 + +## 协议规范详解 + +### 协议约定与术语 + +JSON-RPC 规范中使用了 RFC 2119 定义的关键字:MUST、MUST NOT、SHOULD、SHOULD NOT、MAY。这些术语描述了实现者必须、应该或可以遵循的行为规范。 + +**客户端(Client)**:发起请求的实体,负责构造请求对象并处理响应。 + +**服务端(Server)**:接收请求并返回响应的实体,处理请求并生成响应。 + +同一个实现可以同时扮演客户端和服务端。比如在对等网络中,两个节点可能既向对方发起请求,也响应来自对方的请求。 + +### 数据类型系统 + +JSON-RPC 继承自 JSON 的类型系统,包含六种数据类型: + +基本类型:String(字符串)、Number(数值)、Boolean(布尔值)、Null(空值) + +结构化类型:Object(对象)、Array(数组) + +这些类型名称首字母必须大写,包括 True 和 False。 + +成员名称(字段名)在客户端与服务端之间交换时必须区分大小写。函数、方法、过程这三个术语可以互换使用,都指向可以被调用的可执行单元。 + +## 消息格式深度解析 + +JSON-RPC 2.0 定义了三种核心消息类型:**请求对象(Request Object)**、**响应对象(Response Object)** 和 **通知对象(Notification Object)**。 + +![JSON-RPC 协议架构图](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJucC530h617fWMXjibDO23lvibicuCxEXCdcsKHGNhBnZhzPspUYW6eOsKqibJAuMQq0FJPcBU8hLRYp2r5pvM1QYTic7YVY1icmJNp6E/640?wx_fmt=jpeg&from=appmsg) + +### 请求对象结构 + +请求对象是客户端向服务端发起 RPC 调用的载体: + +```json +{ + "jsonrpc": "2.0", + "method": "subtract", + "params": [42, 23], + "id": 1 +} +``` + +- **jsonrpc 字段**:协议版本标识,值必须是字符串 "2.0"。 +- **method 字段**:要调用的方法名称,区分大小写。以 "rpc." 开头的方法名预留给 JSON-RPC 内部扩展,如 `rpc.subscribe`、`rpc.notify`。 +- **params 字段**:方法参数,可以是数组(按顺序)或对象(按名称)。参数可选,不需要时可以省略。 + +```json +// 索引数组参数:参数顺序与服务端预期一致 +{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1} + +// 命名对象参数:参数名需与服务端方法签名匹配 +{"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 2} +``` + +- **id 字段**:客户端生成的唯一标识符,用于关联请求和响应。id 可以是字符串、数值或 null。建议避免使用 null,因为 JSON-RPC 1.0 的通知机制使用 null,可能引起混淆。使用数值作为 id 时,应避免小数。 + +### 响应对象结构 + +响应对象是服务端返回给客户端的执行结果: + +```json +// 成功响应 +{ + "jsonrpc": "2.0", + "result": 19, + "id": 1 +} + +// 错误响应 +{ + "jsonrpc": "2.0", + "error": { + "code": -32601, + "message": "Method not found", + "data": "详细错误信息" + }, + "id": 1 +} +``` + +响应对象必须包含 `result` 或 `error` 之一,不能同时包含两者。`id` 必须与对应请求中的 id 保持一致。 + +如果请求本身存在错误(如无效的 JSON 格式或非法请求结构),导致服务端无法确定原始 id 时,响应中的 id 必须为 null。 + +### 通知机制 + +通知是一种不需要响应的请求,通过省略 `id` 字段来标识: + +```json +{ + "jsonrpc": "2.0", + "method": "update", + "params": [1, 2, 3, 4, 5] +} +``` + +通知适用于日志记录、事件发布、进度通知等场景。客户端只负责发送消息,不需要处理响应。 + +由于没有响应机制,客户端无法得知通知是否被成功处理。在批量请求中,通知请求不会产生对应的响应。 + +![JSON-RPC 请求响应流程图](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJucUozOcnGcQKXkibKzibw9n2Yyf1xLH4FJpDIxV30EulNkB9l96ccmaaOuJFoMnJ7ygPCdSzoic9ib4fWDsTib3xDcEkAPCRd0Xq9Nc/640?wx_fmt=jpeg&from=appmsg) + +## 错误处理机制 + +### 错误对象结构 + +JSON-RPC 2.0 定义了标准化的错误对象: + +```json +{ + "code": -32603, + "message": "Internal error", + "data": { "details": "数据库连接失败" } +} +``` + +- **code 字段**:整数错误码,标识错误类型。 +- **message 字段**:简短的人类可读错误描述,通常是一句话。 +- **data 字段**:可选,携带错误的附加信息,可以是字符串、对象或任何有效的 JSON 值。 + +### 预定义错误码 + +JSON-RPC 2.0 在 -32768 到 -32000 范围内预留了预定义错误码: + +| 错误码 | 名称 | 说明 | +|--------|------|------| +| -32700 | Parse Error | 解析错误,服务端收到的 JSON 格式无效 | +| -32600 | Invalid Request | 无效请求,发送的不是有效的请求对象 | +| -32601 | Method Not Found | 方法不存在或不可调用 | +| -32602 | Invalid Params | 参数无效 | +| -32603 | Internal Error | JSON-RPC 内部错误 | + +![JSON-RPC 错误码分类图](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJueGqym82lnicBr54sHc2g1ichCou4Ut4IxOjEdtYTh2DkmyexPEibbLhpt0pjsXhv1ibzmRlriayZ9EzH6cCXNkF9hH2jPv4zWM9Sx0/640?wx_fmt=jpeg&from=appmsg) + +-32000 到 -32099 范围内的错误码保留给服务端自定义使用。应用程序也可以定义自己的错误码(通常为负数且绝对值小于 32767)。 + +### 错误处理建议 + +- **合理使用 data 字段**:code 和 message 提供错误的基本分类,data 可以包含调试信息、堆栈跟踪或业务相关的详细信息。生产环境中注意控制敏感信息暴露。 +- **统一错误处理中间件**:在服务端统一拦截和处理错误,将业务异常转换为标准化的 JSON-RPC 错误格式,避免暴露内部实现细节。 +- **区分错误类型**:某些错误(如参数校验失败)是客户端问题,可以提示用户修正;另一些错误(如数据库故障)需要服务端介入处理。 + +## 批量调用与性能优化 + +### 批量请求机制 + +JSON-RPC 2.0 支持在单个请求中发送多个 RPC 调用,服务端返回相应的响应数组。这一机制在高并发场景下可以减少网络往返次数。 + +```json +// 批量请求 +[ + {"jsonrpc": "2.0", "method": "sum", "params": [1, 2, 4], "id": "1"}, + {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, + {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, + {"jsonrpc": "2.0", "method": "get_data", "id": "9"} +] +``` + +对应的批量响应: + +```json +[ + {"jsonrpc": "2.0", "result": 7, "id": "1"}, + {"jsonrpc": "2.0", "result": 19, "id": "2"}, + {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"} +] +``` + +通知请求(没有 id 字段)不会产生响应。响应数组中各元素的顺序与请求顺序无关,客户端通过匹配 id 来关联请求和响应。 + +### 批量调用的边界情况 + +规范对批量调用中的边界情况有明确定义: + +如果批量请求本身不是有效的 JSON 或不是包含至少一个值的数组,服务端应返回单对象响应而非数组。空数组 `[]` 被视为无效请求。 + +如果批量请求中的所有请求都是通知,服务端不需要返回任何响应。其他情况下,即使某些请求处理失败,响应数组中仍应包含对应请求的错误响应。 + +### 性能优化策略 + +- **连接复用**:使用持久化连接(如 HTTP Keep-Alive 或 WebSocket)可以避免频繁建立和销毁连接的开销。 +- **批量聚合**:将多个相关的小请求合并为一个批量请求,减少网络往返次数。 +- **压缩传输**:启用 HTTP 压缩(如 gzip)可以减少大型 JSON 响应体的传输体积。 +- **异步处理**:使用异步调用和通知机制,提高客户端的并发处理能力。 + +## 与 RESTful API 的对比分析 + +### 设计理念差异 + +JSON-RPC 和 RESTful 代表了两种不同的 API 设计哲学。JSON-RPC 是过程导向的,关注的是"做什么操作";RESTful 是资源导向的,关注的是"操作什么资源"。 + +以用户操作为例: + +```json +// JSON-RPC: 直接调用方法 +{"jsonrpc": "2.0", "method": "createUser", "params": {"name": "张三", "email": "zhang@example.com"}, "id": 1} + +// RESTful: 操作资源 +POST /users +{"name": "张三", "email": "zhang@example.com"} +``` + +### 通信模式对比 + +| 维度 | JSON-RPC | RESTful | +|------|----------|---------| +| 语义抽象 | 方法调用 | 资源操作 | +| URL 角色 | 仅作为端点地址 | 表示资源路径 | +| HTTP 方法 | 仅使用 POST | 充分利用 GET/POST/PUT/DELETE | +| 状态管理 | 可维护会话状态 | 倡导无状态设计 | +| 灵活性 | 更紧凑,适合精确控制 | 更灵活,适合通用场景 | + +![JSON-RPC 与 RESTful 对比图](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJueaJVicMJDwZKiaS2VpmMiaHBJHSwAVicCeNnvQh04vN83DzoS13GDfFiaGjJaoDLMlLmmoMryX1tq47GkWELOsCc4ELyUAzDrQHaRU/640?wx_fmt=jpeg&from=appmsg) + +### 选型建议 + +**适合使用 JSON-RPC 的场景**: + +- 内部服务间通信,接口相对稳定 +- 需要精确的方法语义和参数控制 +- 对带宽敏感,需要紧凑的消息格式 +- 团队对 RPC 模式更熟悉 + +**适合使用 RESTful 的场景**: + +- 公开 API,需要良好的可发现性 +- 资源概念明确的 CRUD 操作 +- 需要利用 HTTP 缓存机制 +- 追求 API 的自我描述能力 + +## 安全性考量 + +### 常见安全威胁 + +JSON-RPC 的简洁设计虽然降低了实现复杂度,但也带来了一些安全考量: + +- **数据泄露**:JSON-RPC 通常通过 HTTP 传输,未加密的通信可能被中间人攻击截获。 +- **方法枚举**:如果服务端没有正确限制可调用方法,攻击者可能通过枚举方法名发现未公开的接口。 +- **拒绝服务**:恶意客户端可能发送大量请求或构造超大的批量请求,耗尽服务端资源。 +- **注入攻击**:不安全的参数处理可能导致 SQL 注入、命令注入等安全漏洞。 +- **重放攻击**:截获的有效请求可能被攻击者重放,造成非预期的重复操作。 + +![JSON-RPC 安全威胁与防护措施图](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJudMYKanyJLWVo2sp97eTXSv4zoxibQoarTicX5cyLqhkI0GvgFMJrEfKjOUicOxYX91x7WgDicwfLNUIECoXtJyG69IUUCmqic3dYoo/640?wx_fmt=jpeg&from=appmsg) + +### 安全加固措施 + +- **传输层加密**:使用 HTTPS 协议,确保通信内容的机密性和完整性。 +- **身份认证与授权**:实现身份验证机制(如 JWT、OAuth 2.0),在方法层面实施细粒度的授权控制。 +- **输入验证**:对所有参数进行严格的类型检查和值域验证。 +- **速率限制**:实施请求速率限制,防止恶意高频访问。 +- **方法白名单**:仅暴露必要的方法,对未公开的方法返回 -32601 错误。 +- **日志与监控**:记录详细的请求日志,监控异常模式,及时发现潜在攻击。 + +## 结语 + +JSON-RPC 2.0 协议设计简洁,在分布式系统通信中有其适用场景。规范本身很短,学习和实现成本都不高,JSON 格式的请求/响应便于调试和日志记录。 + +当然,JSON-RPC 也有局限性。它缺少内置的元数据机制,类型系统相对简单。在需要高度标准化、复杂类型系统或强类型安全的场景中,可以考虑 gRPC、Thrift 等方案。 + +如果你的业务场景需要简单、透明的服务间通信方式,JSON-RPC 是一个值得考虑的选择。 diff --git "a/docs/md/AI/RAG\350\257\246\350\247\243\357\274\232\350\256\251\345\244\247\346\250\241\345\236\213\347\234\213\350\247\201\344\275\240\347\232\204\347\247\201\346\234\211\347\237\245\350\257\206.md" "b/docs/md/AI/RAG\350\257\246\350\247\243\357\274\232\350\256\251\345\244\247\346\250\241\345\236\213\347\234\213\350\247\201\344\275\240\347\232\204\347\247\201\346\234\211\347\237\245\350\257\206.md" new file mode 100644 index 0000000..2204101 --- /dev/null +++ "b/docs/md/AI/RAG\350\257\246\350\247\243\357\274\232\350\256\251\345\244\247\346\250\241\345\236\213\347\234\213\350\247\201\344\275\240\347\232\204\347\247\201\346\234\211\347\237\245\350\257\206.md" @@ -0,0 +1,223 @@ +在把大语言模型用到实际业务时,开发者很快会遇到一个问题:通用模型很难满足特定场景的需求。 + +主要卡在三个地方: + +- **知识过时**:模型的训练数据有截止日期,问它最近发生的事基本白问。 + +- **幻觉严重**:模型经常一本正经地胡说八道,在需要准确性的场景这是致命的。 + +- **数据安全**:企业不可能把内部文档上传给第三方,但不上传模型又不会。 + +![LLM三大困境与RAG解决方案](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJudbKhlmkZLd5XKUVSnNkl3bujP3THiaj7nwxBfEQM7gv6pzW6hfUczv207EwEF1YLWqySM8LvDu9NkES0QYNmUcreQs8OKJnNkk/640?wx_fmt=jpeg&from=appmsg) + +基于这个问题,**RAG(检索增强生成)** 出现了。它的思路很简单:不把数据交给模型,而是让模型"看到"它需要的部分。 + +用户提问时,系统从私有知识库中检索相关片段,把这些片段和问题一起发给大模型。模型结合真实信息来回答,而不是靠"记忆"里不知道哪来的东西生成。 + +## 为什么需要 RAG + +### 知识的局限性 + +大语言模型的知识完全来自训练数据。GPT-4、文心一言、通义千问这些主流模型,训练数据主要来自网络公开数据。这带来两个问题: + +- **时效性**:模型的"知识"被定格在训练截止时间点。GPT-4 的知识库可能停在 2023 年 12 月,之后的新事件、新政策、新技术,它无法直接给出准确答案。 +- **私有领域缺失**:企业的产品规格文档、内部流程规范、医疗机构的诊断指南、法律机构的判例汇编——这些数据从没出现在公开网络上,通用大模型对此一无所知。不借助外部手段,模型在这些领域的回答质量会大打折扣。 + +### 幻觉问题 + +大语言模型在生成文本时,实际上是在计算下一个 token 出现的概率分布。这种机制导致模型在面对不确定性问题时,经常编造看似合理实则错误的答案——学名叫"幻觉"(Hallucination)。 + +更麻烦的是,模型不具备某一方面知识时,它不会选择"不知道",而是倾向于根据训练数据中学习到的语言模式,自信满满地瞎编。在需要高度准确性的生产环境中,这绝对不可接受。 + +### 数据安全 + +对企业来说,数据安全是生死攸关的议题。没人愿意承担核心商业机密泄露的风险,因此几乎没有企业愿意将私有数据上传到第三方平台进行模型训练或推理。 + +这意味着,完全依赖通用大模型自身能力的应用方案,不得不在**数据安全**和**应用效果**之间做取舍。传统方案要么保数据安全牺牲模型能力,要么提升模型能力却承担数据泄露风险。 + +## RAG 的破局思路 + +RAG 提供了第三条路:不把数据交给模型,而是让模型"看到"它需要的那部分数据。 + +用户提问时,系统从私有知识库中检索相关片段,把这些片段作为上下文提供给大模型。大模型在回答时,既能"参考"检索到的真实信息,又能结合自身的语言理解能力生成流畅的回答。数据始终留在本地,模型获得了"感知"私有知识的能力。 + +## RAG 的技术架构 + +RAG 的完整工作流程分为两个阶段:**数据准备阶段(离线索引)** 和 **应用阶段(在线推理)**。 + +![RAG技术架构双阶段工作原理](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJueHibHwJ6ZFH6K6I8x44e1VARCV7smKAkvTULFGnAj2NBRpelQ2naywe7Vs3HbMxiappYKwibR2ugK3nPjE9lc29cwnDkibsv9znyI/640?wx_fmt=jpeg&from=appmsg) + +### 数据准备阶段:构建知识的向量化索引 + +数据准备是离线过程,目标是将私有数据转化为可高效检索的向量形式。流程包含四个环节: + +**数据提取**。从多种格式的原始文件中提取纯文本内容,包括 PDF、Word、HTML、数据库记录等。技术挑战在于处理各种格式解析、特殊字符清理、无用内容过滤。常用 LangChain 的 DocumentLoaders 来统一处理不同来源的数据。 + +**文本分割(Chunking)**。直接影响检索质量。需要综合考虑两个因素:一是 embedding 模型对 token 长度的限制;二是语义完整性对检索效果的影响。 + +固定长度分割实现简单,但容易切断语义边界,导致检索时丢失关键上下文。语义分割通过识别句子边界或段落结构来进行切分,能更好地保留语义完整性,但实现复杂度更高。业界常用策略是设置合适的 chunk size(如 512 tokens)和 overlap(如 50-100 tokens),在保证语义完整性的同时避免边界效应。 + +**向量化(Embedding)**。将文本转化为高维向量,让语义相似的文本在向量空间中具有相近的位置关系。常见的 embedding 模型: + +| 模型名称 | 特点 | 适用场景 | +|---------|------|---------| +| OpenAI text-embedding-3 | API 调用,稳定可靠 | 通用场景 | +| BGE (BAAI) | 开源、支持中英文 | 自部署场景 | +| M3E (MokaAI) | 开源、多语言支持 | 中文场景 | +| ERNIE-Embedding | 百度自研、中文优化 | 国产化需求 | + +**数据入库**。将向量及其关联的原始文本、元数据写入向量数据库。主流选择包括 FAISS、Chroma、Milvus、Weaviate 和 Qdrant 等。这些数据库采用近似最近邻(ANN)算法,能够在海量向量中快速找到与查询向量最相似的结果。 + +### 应用阶段 + +![向量检索与语义相似度原理](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJuesUIaribicHT0rTxkbrQVoPAbfJIycGBozVzdCic9N7vHOeQ52UBibGibMZq7wpvZzo5Yibm7uTY0uickG7TPITaicaBfvLgYUxsMDuh8/640?wx_fmt=jpeg&from=appmsg) + +用户提出问题时,RAG 系统进入在线推理阶段,包含四个关键步骤: + +**查询向量化**。使用与索引阶段相同的 embedding 模型,将用户的自然语言问题转化为语义向量。这个向量的质量直接决定后续检索的准确性。 + +**信息检索(Retrieval)**。通过计算查询向量与所有存储向量的相似度(如余弦相似度、欧氏距离),找出 top-k 个最相关的文档片段。现代向量数据库通常采用 HNSW(Hierarchical Navigable Small World)等算法,在保证检索精度的同时实现毫秒级响应。 + +**上下文构建**。将检索到的相关片段与原始问题组装成完整的提示模板。一个典型的 RAG 提示模板: + +``` +【任务描述】 +假如你是一个专业的客服机器人,请参考【背景知识】中的内容,准确回答用户的问题。 + +【背景知识】 +{检索到的相关文档片段} + +【用户问题】 +{用户原始问题} + +【回答要求】 +- 仅根据【背景知识】中的内容回答 +- 如果【背景知识】中没有相关信息,请明确告知 +- 回答要简洁、准确、有条理 +``` + +**答案生成(Generation)**。大语言模型接收包含问题和上下文的提示后,结合检索到的真实信息生成最终答案。由于有明确的参考资料作为约束,模型产生幻觉的概率大大降低。 + +## 高级 RAG 技术 + +基础 RAG 流程能工作,但在实际应用中往往面临检索质量不足、生成效果不稳定等问题。业界发展出一系列高级 RAG 技术来优化系统性能。 + +![高级RAG技术优化策略对比](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJufpH5DVibhlnBgSiceYJdadIKkOibWVROYFz7bJ1g8jWxt7HsU7g5iaiazNLNwL9KANAZicnA4Ht1gOmty2EA9RzUly8WuZRPNAJ6xvE/640?wx_fmt=jpeg&from=appmsg) + +### 搜索索引的演进 + +**平面索引(Flat Index)** 直接计算查询向量与所有存储向量的相似度。实现简单,但数据规模达到数万条时,线性扫描的计算开销变得不可接受。 + +**向量索引** 采用近似最近邻算法来解决效率问题。FAISS 库提供了多种索引类型: + +- **IVF(Inverted File Index)**:通过聚类将向量分组,检索时只搜索最相关的聚类中心,显著减少计算量。 +- **HNSW(Hierarchical Navigable Small World)**:构建多层图结构,实现对数级别的检索复杂度。 +- **PQ(Product Quantization)**:对高维向量进行压缩,大幅降低内存占用。 + +**分层索引** 应对大型知识库。系统维护两个层级的索引:一个由文档摘要组成,用于快速过滤;另一个由文档块组成,用于精确检索。这种设计在保证检索召回率的同时,大幅提升检索效率。 + +### 混合搜索 + +单纯的向量检索在处理精确术语匹配时往往表现不佳。比如用户搜索"如何重置密码",向量检索可能无法准确识别"重置"和"修改"之间的细微差别。 + +**混合搜索** 结合传统关键词检索(如 BM25、TF-IDF)和现代语义向量检索。两种检索结果通过 **Reciprocal Rank Fusion(RRF)算法** 进行融合:对不同检索方法的结果按排名赋分,综合排名最高的文档被选中。 + +关键词检索确保精确匹配的召回,语义检索捕捉同义词和语义关联。两者互补,能够应对更广泛的查询类型。 + +### 内容增强 + +**语句窗口检索器(Sentence Window Retriever)** 采用"小块索引、大窗口上下文"的策略。文档中的每个句子单独嵌入向量索引以保证检索精度,但检索后扩展上下文窗口,额外获取前后各 k 个句子,提供更完整的语义信息给大模型。 + +**自动合并检索器(Parent Document Retriever)** 采用层级分割策略:文档被递归分割为较小的子块,每个子块与较大的父块存在引用关系。检索时获取相关子块,当多个相关子块指向同一父块时,自动升级为使用父块作为上下文。这种设计让系统同时获得精确检索和宏观语义。 + +### HyDE:假设性答案增强检索 + +**HyDE(Hypothetical Document Embeddings)** 是一种逆向思维的方法:不直接用问题检索,而是先用大模型根据问题生成一个假设性的答案,然后用这个假设答案去检索相关文档。 + +前提假设是:假设答案与真实文档在语义空间中可能更接近,因为两者都是"回答性"的文本。HyDE 在处理抽象概念或模糊查询时表现尤为出色。 + +### 查询转换 + +用户的原始问题往往不够"检索友好"。**查询转换** 系列技术利用大模型的推理能力来优化查询: + +**查询分解(Query Decomposition)** 将复杂问题拆分为多个简单子问题。例如,"LangChain 和 LlamaIndex 哪个更适合做 RAG 开发?"这个问题难以直接检索,但可以拆分为"LangChain 做 RAG 开发的优缺点"和"LlamaIndex 做 RAG 开发的优缺点"两个子问题,分别检索后再综合回答。 + +**Step-back Prompting** 生成更通用的查询来获取高层次上下文,与原始查询的检索结果一起输入模型,实现"由面到点"的推理。 + +**查询重写(Query Rewriting)** 使用大模型改写问题表达,尝试用不同的表述方式检索,提高召回率。 + +### 重排与过滤 + +检索返回的 top-k 结果可能存在质量问题:相关性参差不齐、信息冗余、噪声干扰。**重排(Reranking)** 技术应运而生。 + +**交叉编码器重排** 将查询和每个候选文档一起输入专门的交叉编码模型,输出精细化的相关性评分。这种方法比向量相似度计算更准确,但计算开销更大,通常用于对初始检索结果的二次筛选。 + +**基于元数据的过滤** 利用文档的元数据(时间、来源、类别等)进行条件筛选,快速排除不相关的结果。 + +## RAG 融合 + +**RAG 融合(RAG Fusion)** 通过 LLM 生成多个变体查询来增强检索效果。单一查询可能无法覆盖用户问题的所有方面,通过让 LLM 生成多个不同角度的查询,可以从知识库中召回更丰富、更多样化的相关信息。 + +![RAG融合多查询增强检索流程](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJuc67J0PJ9BS3D17Cpd1P5U8WdfgtJeJbbbWPbBeSgcdSlmh9ScsrzuvribWCs5Vq2CeZibDYj7LMM4MIA23uVmOL5ShHgW6y6XjI/640?wx_fmt=jpeg&from=appmsg) + +### 技术流程 + +1. **多查询生成**:使用 LLM 根据用户原始问题生成 n 个相关查询。 +2. **并行向量搜索**:用所有生成的查询分别进行向量检索。 +3. **结果融合排序**:应用 RRF 算法对所有检索结果进行综合排名。 +4. **上下文注入**:将融合后的相关文档注入提示模板。 +5. **答案生成**:LLM 基于融合后的上下文生成最终答案。 + +### 优势 + +**多样性增强**:不同查询从不同角度切入问题,最终结果涵盖更广泛的视角,减少单一视角带来的偏差。 + +**鲁棒性提升**:某个查询因表述偏差导致检索不佳时,其他查询可以弥补缺陷,提升整体系统的稳定性。 + +**语义纠偏**:LLM 生成的查询往往是对原始问题的语义扩展,能够捕捉隐含的语义关联。 + +### 注意事项 + +**延迟增加**:额外的 LLM 调用会引入额外延迟,在延迟敏感的场景中需要权衡。 + +**专业术语处理**:如果知识库包含大量内部术语或行话,LLM 可能因不了解这些术语而产生无关查询,此时需要针对性优化提示词。 + +**成本考量**:额外的 LLM 调用意味着额外的 token 消耗,需要评估 ROI。 + +## 评估体系 + +RAG 系统的质量评估是工程实践中的重要环节。业界通常采用 **RAG 三元组** 评估框架: + +- 检索内容相关性(Context Relevance):评估检索到的文档与用户问题的相关程度。高相关性意味着检索阶段工作良好,能够召回真正有用的信息。常用指标包括召回率(Recall)和精确率(Precision)。 +- 答案基于性(Answer Groundedness):衡量 LLM 的回答是否基于检索到的上下文,而非依赖自身知识或产生幻觉。这一指标直接反映 RAG 机制的有效性。 +- 答案相关性(Answer Relevance):评估生成的回答是否有效解决了用户的问题。高相关性意味着即使检索和基于性都良好,最终答案也能真正满足用户需求。 + +![RAG三元组评估体系框架](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJufflU7WwiaiazhWnSjPLY8kU2awrtDxAuibQEqkzKtZOrm97tXkiaxzl5y9UdjsaMNFDULnsF2YEfGd1V7x2lbpzNibG0mJs9qFYhYw/640?wx_fmt=jpeg&from=appmsg) + +### 评估框架与工具 + +**RAGAs** 是当前流行的 RAG 评估框架,提供标准化的评估流程和指标计算方法。 + +**LangSmith** 是 LangChain 官方提供的评估平台,支持自定义评估器、运行时监控和调试追踪。 + +**Truelens** 由 LlamaIndex 团队推出,专注于 RAG 系统的可观测性和评估。 + +## 发展趋势 + +RAG 技术正在快速演进,几个方向值得关注: + +**端到端优化**。传统 RAG 将检索和生成视为独立环节,但最新研究开始探索联合优化的可能性。Meta AI 提出的 **RA-DIT** 技术同时微调 LLM 和 Retriever,让两个组件在学习过程中相互适应,在知识密集型任务上取得了显著提升。 + +**多模态 RAG**。随着多模态大模型的发展,RAG 的边界正在从纯文本扩展到图像、视频、音频等多种模态。未来的 RAG 系统需要能够处理跨模态的知识检索和生成。 + +**主动学习与持续优化**。结合用户反馈和评估结果,构建自适应优化机制,让 RAG 系统能够从实际使用中持续学习和改进。 + +**轻量化与边缘部署**。随着模型压缩技术的发展,更小、更快的 LLM 将成为 RAG 系统的新选择。Mistral Mixtral、Microsoft Phi-2 等小参数模型的崛起,为 RAG 在边缘设备上的部署开辟了新的可能性。 + +## 结语 + +RAG 技术通过将检索能力与大语言模型的生成能力相结合,为 LLM 的实际应用提供了一条切实可行的路。它解决了知识时效性、私有数据访问和幻觉抑制等核心问题。 + +当然,RAG 不是万能药。检索质量、响应延迟、系统复杂度等挑战依然存在。开发者需要根据具体场景权衡利弊,选择合适的技术组合。 + +至于未来会怎样,让时间来检验。 diff --git "a/docs/md/AI/ReAct\357\274\232\350\256\251\345\244\247\346\250\241\345\236\213\345\255\246\344\274\232\350\276\271\346\203\263\350\276\271\345\201\232.md" "b/docs/md/AI/ReAct\357\274\232\350\256\251\345\244\247\346\250\241\345\236\213\345\255\246\344\274\232\350\276\271\346\203\263\350\276\271\345\201\232.md" new file mode 100644 index 0000000..d43addd --- /dev/null +++ "b/docs/md/AI/ReAct\357\274\232\350\256\251\345\244\247\346\250\241\345\236\213\345\255\246\344\274\232\350\276\271\346\203\263\350\276\271\345\201\232.md" @@ -0,0 +1,286 @@ +传统聊天机器人相信大家都用过——你问一句,它答一句。线性,简单,但遇到复杂问题就露馅了。比如问"特斯拉股价相比去年涨了多少",它要么瞎编,要么说"我无法获取实时信息"。 + +2022年,Google Research提出了ReAct框架。它要解决的就是这个问题:**让大模型像人一样,一边想一边做,做完再看结果,接着想下一步**。 + +## ReAct的核心原理 + +### 先看个例子 + +想象你在一个陌生城市旅行。 + +早上醒来,你想:今天天气怎么样?要不要带伞? + +你打开天气APP看了一眼——有阵雨。 + +于是你调整计划:上午去博物馆躲雨,晚上再去看夜景。 + +这个"想→做→看→再想"的过程,就是ReAct在做的事。 + +### 三个阶段 + +ReAct由三个部分构成: + +**思考(Thought)**:分析当前问题,决定下一步做什么。比如"用户问的是某国人口,我需要查数据"。 + +**行动(Action)**:调用外部工具。输出格式类似`search(query="某国人口")`。 + +**观察(Observation)**:工具返回结果,成为下一轮思考的依据。比如`{"population": "1.4亿"}`。 + +循环往复,直到任务完成。 + +![ReAct核心循环机制](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJucTmlsa0o2IQt2CkKl8g09J9KBMphfOBZPooZcexoNRkgWToSwH6X0AKARVcZpB6r4pzBVlbNUrIEOibKCcY1pzUPEViaibkm9AN8/640?wx_fmt=jpeg&from=appmsg) + +### 什么时候停下来 + +两种方式: + +- **硬限制**:设个最大迭代数,比如`max_iterations=10`,到了就强制结束。 +- **条件触发**:模型觉得自己很有把握了,或者连续失败好几次,就主动收手。 + +## 技术实现 + +### 工具怎么设计 + +三个原则: + +**原子性**:一个工具只做一件事。计算器就做计算,搜索就做搜索,别搞大而全。 + +**强契约**:用JSON Schema定义清楚输入输出格式: + +```json +{ + "name": "get_weather", + "description": "查询指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "城市名称,如北京、上海" + } + }, + "required": ["location"] + } +} +``` + +**安全第一**:删数据、转账这类敏感操作必须有权限控制。 + +### 提示词怎么写 + +ReAct提示包含四个部分: + +- 要求模型展示思考过程。 +- 告诉它有哪些工具可以用。 +- 提示它在每个操作后重新评估。 +- 设定循环退出条件。 + +### 从提示词到原生工具调用 + +早期做法是在提示词里格式化输出: + +``` +思考:我需要查询北京的天气 +行动:get_weather(location="北京") +观察:{"temperature": "25℃", "condition": "晴"} +``` + +问题很明显:模型可能"废话连篇",格式也可能乱套。 + +后来有了**原生工具调用**,直接微调模型让它输出结构化JSON。稳定多了,还能并行执行多个工具。 + +![工具调用流程图](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJue56U8IQnzVrFPBwEROrkwv4psGgPBNQX33hzADRj5LQ9iax14uSWMGQ2NyFB5n0mCk7Y3MOhCiaYFSNDlkU9mCvtSeuWbpEklqA/640?wx_fmt=jpeg&from=appmsg) + +### 在Java中使用ReAct:LangChain4j示例 + +如果你是Java开发者,可以用 LangChain4j 快速实现 ReAct。核心思路是:用 `@Tool` 注解定义工具,框架自动处理推理-行动-观察的循环。 + +**第一步:定义工具** + +```java +import dev.langchain4j.agent.tool.Tool; + +public class WeatherTools { + + @Tool("获取指定城市的实时天气信息") + public String getWeather(String location) { + // 实际项目中调用天气API + return switch (location) { + case "北京" -> "晴天,15℃,空气质量良好"; + case "上海" -> "多云,18℃,有轻度雾霾"; + default -> "未找到该城市的天气信息"; + }; + } + + @Tool("查询股票实时价格") + public String getStockPrice(String stockCode) { + // 实际项目中调用股票API + return "股票" + stockCode + "当前价格:168.5元,涨幅+2.3%"; + } +} +``` + +**第二步:创建助手接口** + +```java +import dev.langchain4j.service.AiServices; + +public interface Assistant { + String chat(String userMessage); +} +``` + +**第三步:构建并使用** + +```java +import dev.langchain4j.model.openai.OpenAiChatModel; + +public class Main { + public static void main(String[] args) { + // 配置模型 + OpenAiChatModel model = OpenAiChatModel.builder() + .apiKey("your-api-key") + .modelName("gpt-4") + .build(); + + // 构建助手,绑定工具 + Assistant assistant = AiServices.builder(Assistant.class) + .chatLanguageModel(model) + .tools(new WeatherTools()) // 注册工具 + .build(); + + // 提问——框架会自动执行ReAct循环 + String answer = assistant.chat("北京今天天气怎么样?适合户外运动吗?"); + System.out.println(answer); + } +} +``` + +**运行时发生了什么?** + +当用户问"北京天气怎么样"时,LangChain4j 内部会: + +1. **思考**:模型分析问题,决定调用 `getWeather` 工具。 +2. **行动**:执行 `getWeather("北京")`。 +3. **观察**:得到 "晴天,15℃,空气质量良好"。 +4. **再思考**:模型结合天气数据,判断适合户外运动。 +5. **最终答案**:返回完整回答。 + +整个过程对开发者透明,你只需定义工具,剩下的交给框架。 + +**两种模式的区别** + +| 模式 | 适用场景 | 特点 | +|-----|---------|------| +| 函数调用(推荐) | OpenAI、Claude等支持工具调用的模型 | 稳定可靠,直接输出结构化调用 | +| 文本解析 | 开源模型、不支持工具调用的模型 | 通过提示词让模型输出 `Action: xxx` 格式,再解析 | + +LangChain4j 会根据模型类型自动选择模式。如果你的模型支持函数调用,优先用这个——更稳定,不容易出错。 + +## 为什么ReAct管用 + +![ReAct与CoT对比](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJufm0JRAfdqE7An7PmO7eAHQZrGw3aJp2YvibZRxMmybR0tSKHtHRaic9V7KtG9o53Bvzygz0jeG2PpOz0VfHBvFiaffNmiaV8I3l6Q/640?wx_fmt=jpeg&from=appmsg) + +### 减少胡说八道 + +假设用户问:"今天A股涨得最多的是哪只?" + +普通模型可能直接猜"茅台"——反正训练数据里有。 + +ReAct模型会: +1. 想到:我得查实时数据。 +2. 调用股票API。 +3. 看结果:涨最多的是XXX。 +4. 根据真实数据回答。 + +不靠记忆,靠查证。 + +### 出了问题能追溯 + +用ReAct,系统可以展示完整的推理链条。客服答错了?直接看:它问了什么、查了什么、最后怎么答的。开发者定位问题快多了。 + +传统模型只能两手一摊:"抱歉,我不知道为什么会那样。" + +### 能随机应变 + +比如订餐场景:用户说"帮我订个餐厅,要近、评分高、适合商务"。 + +ReAct会: +1. 获取用户位置。 +2. 搜附近高评分餐厅。 +3. 筛选适合商务的。 +4. 发现首选满了,自动推荐备选。 + +脚本做不到这种灵活调整——它只会报错或者返回固定结果。 + +### 能组合多个工具 + +写一份市场分析报告?ReAct可以协调搜索工具查数据、代码工具做分析、图表工具可视化、写作工具生成报告。模拟的是一个真实分析师的工作方式。 + +## 工程实践 + +![工程实践要点](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJufkaL7MkpwaCsPwqoNpkEoJmlsHpr5icUzDtjIYRRS52lbNPuBkD83dVzYHUDGKXdrfibDXHuKxcQj0RpwawcD7jUicWMB0fiaLrxQ/640?wx_fmt=jpeg&from=appmsg) + +### 避免死循环 + +智能客服常见问题:用户问"客服电话多少",系统没有这个功能,模型就一直绕圈。 + +解决办法: + +**硬限制**: + +```python +max_iterations = 10 +for i in range(max_iterations): + # 执行循环 + if i >= max_iterations: + return "处理超时,请稍后重试" +``` + +**循环检测**:同一个操作重复3次,直接判定无法回答,退出。 + +**系统提示引导**:告诉模型"最多试3次,还是不行就说不知道"。 + +### 上下文管理 + +长对话容易爆窗口。比如分析50份简历,每份都查背景信息,上下文直接撑满。 + +处理方式: + +**截断**:只保留最近10轮对话,早期内容丢掉。 + +**摘要**:把前面的分析压缩成一句话,比如"20份简历分析完成,12份通过初筛"。只留结论,不留过程。 + +省Token,效果差不多。 + +### 并行调用 + +问:"对比北京、上海、深圳国庆期间的天气和机票价格。" + +串行做法:查北京天气→等,查上海天气→等,查深圳天气→等,查北京机票→等……6次等待。 + +并行做法:三个城市天气一起查,三张机票一起查。2次等待搞定。 + +性能差距明显。 + +### 错误处理 + +订机票时支付API返回"网络超时",怎么办? + +**分级处理**: +- 网络超时:等1秒重试,最多3次 +- API限流:换备用支付渠道 +- 余额不足:提示充值 +- 系统维护:告知稍后再试 + +**回退机制**:主渠道失败自动试备用渠道,都失败了保存订单状态,让用户晚点再试。 + +## 结语 + +ReAct让AI从被动应答变成主动规划。通过把推理和外部工具结合起来,AI能处理远超传统聊天机器人的复杂任务。 + +它的核心其实很朴素:**像人一样做事**。想一下、做一下、看结果、接着想。 + +挑战也不少。循环控制、上下文管理、错误处理,都需要认真对待。但随着技术成熟,基于ReAct的AI智能体正在各个场景里发挥作用。 + +如果你在搞AI应用,ReAct值得深入了解。 diff --git "a/docs/md/AI/\344\270\272\344\273\200\344\271\210ChatGPT\350\203\275\345\220\254\346\207\202\344\275\240\350\257\264\347\232\204\350\257\235\357\274\237Embedding\346\212\200\346\234\257\346\217\255\347\247\230.md" "b/docs/md/AI/\344\270\272\344\273\200\344\271\210ChatGPT\350\203\275\345\220\254\346\207\202\344\275\240\350\257\264\347\232\204\350\257\235\357\274\237Embedding\346\212\200\346\234\257\346\217\255\347\247\230.md" new file mode 100644 index 0000000..f14955d --- /dev/null +++ "b/docs/md/AI/\344\270\272\344\273\200\344\271\210ChatGPT\350\203\275\345\220\254\346\207\202\344\275\240\350\257\264\347\232\204\350\257\235\357\274\237Embedding\346\212\200\346\234\257\346\217\255\347\247\230.md" @@ -0,0 +1,196 @@ +ChatGPT、Claude这些AI助手能理解我们说的话,还能给出像样的回答。做到这点,靠的是Embedding技术。 + +没有它,大语言模型根本没法处理文字输入。Embedding把人类语言变成数字,让机器能"读懂"。 + +## 什么是Embedding + +Embedding就是把词语、句子变成一串数字。听起来简单,但背后的想法很有意思。 + +我们说"北京"这个词时,脑子里会想到:城市、首都、政治中心、文化古都。这些概念连在一起,构成我们对"北京"的理解。Embedding做的,就是把这种理解映射到数学空间里。 + +每个词变成一个向量——一组数字。有意思的是,语义相近的词,向量也靠得近。"北京"和"上海"的向量距离很近,都是城市。但"北京"和"苹果"就离得远,语义完全不同。 + + + +这就让机器能通过距离来理解语义,不是简单匹配关键词。 + +## 怎么把词变成数字 + +举个通俗的例子。描述几个水果: + +- 苹果:红色的、中等大小、甜度适中、脆的、圆形的。 +- 香蕉:黄色的、细长的、很甜、软的、弯曲的。 +- 西瓜:绿色的、很大的、甜度中等、多汁的、圆形的。 + +用数字表示这些特征,比如给每个特征打分(0到1): + +- 苹果:[0.9(红色),0.5(大小),0.7(甜度),0.8(脆度),1.0(圆润)]。 +- 香蕉:[0.1(红色), 0.3(大小),0.9(甜度),0.2(脆度), 0.2(圆润)]。 +- 西瓜:[0.1(红色),0.95(大小),0.6(甜度),0.3(脆度),1.0(圆润)]。 + +真实的Embedding要复杂得多,通常是几百到几千维。但思路就是这样:用数字刻画特征。 + +模型怎么学会这些特征?它看上下文。就像通过朋友圈了解一个人,模型通过观察一个词周围经常出现什么词,理解这个词的含义。 + +比如"银行": +- "我去银行存钱"——周围是"存钱",指金融机构。 +- "他坐在银行边钓鱼"——周围是"钓鱼",指河岸。 + +模型通过阅读海量文本,学会根据上下文判断词义。语义相近的词,向量也接近。 + +## 怎么衡量向量的相似度 + +用余弦相似度。计算两个向量的夹角: + +- 夹角小,相似度接近1,语义相似。 +- 夹角大,相似度接近0,语义不相关。 + +余弦相似度关注向量的方向,不是长度。在自然语言里,概念的相似性更多体现在"方向"上。 + + + +## 向量和张量的关系 + +机器学习里有个更广的概念——张量(Tensor),就是N维数组: + +- 0维张量是标量(单个数字)。 +- 1维张量是向量(一维数组)。 +- 2维张量是矩阵(二维数组)。 +- 更高维张量表示更复杂的数据结构。 + +Embedding向量是一维张量。在大语言模型里,虽然每个词的Embedding是向量,但批量处理时会组织成矩阵或更高维张量。 + +## 技术演进 + +Embedding技术从简单到复杂,走了一段路。 + +### Word2Vec:早期的尝试 + +Word2Vec是早期代表,关注单个词的向量化。思路是:通过上下文学习词的语义。 + +模型观察大量文本,学习哪些词经常出现在相似的语境中。"猫"和"老虎"都会出现在"动物园"、"宠物"这些上下文里。通过统计学习,这些词在向量空间中位置靠近。 + +这揭示了一个特性:语义相似性可以通过上下文分布的相似性来捕捉。 + +但Word2Vec有局限:每个词只有固定向量,处理不了一词多义。"苹果"可以指水果,也可以指科技公司,Word2Vec把它们映射到同一个向量。 + +### 自注意力机制:突破 + +Transformer引入的自注意力机制(Self-Attention)是重大突破。模型生成某个词的向量时,能同时考虑句子中所有其他词。 + +两个优势: + +- **长距离依赖**:传统序列模型里,词与词的依赖关系随距离增加而减弱。自注意力机制能直接计算句子中任意两个词的关联强度,不管它们离多远。这帮助模型理解复杂的句法和语义。 +- **动态上下文表示**:Word2Vec给每个词分配固定向量,自注意力机制根据上下文生成不同向量。"我吃了一个苹果"和"苹果公司发布了新产品",两个"苹果"向量完全不同。 + +### BERT:双向理解 + +BERT(Bidirectional Encoder Representations from Transformers)实现了双向上下文理解。预训练时同时考虑一个词左右两侧的所有词。 + +以"很长"这个词组为例: +- "这条河很长"——指河流长度。 +- "他当了很长时间的厂长"——指时间持续。 + +BERT根据不同上下文为"很长"生成不同向量。Embedding技术从静态表示迈向动态、上下文感知的语义理解。 + + + +## Embedding在LLM里的作用 + +Embedding是大语言模型(LLM)运转的基石。 + +### LLM内部怎么工作 + +用户向ChatGPT输入问题时,系统内部经历几个步骤: + +- **第一步:Tokenization(分词)**:分词器把文本拆成token。一个token可能是一个词、一个字,也可能是一个词组。"请写一首关于秋天的诗"会被拆为["请"、"写"、"一首"、"关于"、"秋天"、"的"、"诗"]。 +- **第二步:Embedding Lookup(向量查询)**:每个token有唯一ID。LLM内部维护巨大Embedding矩阵,类似字典。模型看到token ID,在矩阵中查找对应向量。 +- **第三步:Position Encoding(位置编码)**。模型要知道每个token在句子中的位置。给每个token加上位置编码向量,保留顺序信息。 +- **第四步:向量处理与生成**:语义向量和位置编码结合,形成最终输入。这些向量经过Transformer多层网络计算,生成输出。 + +Embedding把人类可读的语言变成机器可计算的数字。没有这一步,推理、理解、生成都无从谈起。 + + + +### 理解和推理的数学基础 + +Embedding的重要性不只体现在输入阶段。在向量空间里,复杂语义操作通过数学运算实现: + +- 语义相似性:向量余弦相似度度量。 +- 语义关系:向量运算捕捉("国王" - "男人" + "女人" ≈ "女王")。 +- 语义组合:向量加权求和。 + +这些数学操作让LLM进行推理和生成,不是简单模式匹配。 + +## RAG框架里的Embedding + +除了在LLM内部,Embedding在实际应用中也很重要,特别是在RAG(Retrieval-Augmented Generation,检索增强生成)框架中。 + +### RAG是什么 + +RAG把大语言模型和可搜索的外部知识库结合。核心想法:让模型访问训练时没见过的新信息,提升回答准确性和时效性。 + +传统LLM应用里,模型知识来自训练数据。GPT-4的训练数据截止到2023年,之后发生的事它不知道。RAG通过连接外部知识库,让模型实时获取最新信息。 + +### Embedding在RAG里的作用 + +在RAG框架中,Embedding连接外部知识库与大语言模型。工作流程: + +- **知识库准备**:把外部文档(PDF、网页等)分割成文本块。用Embedding模型把每个文本块转换为向量,存储在向量数据库。文本内容变成可计算的数学表示。 +- **查询与检索**:用户提问时,系统把查询语句转换为查询向量。在向量数据库中通过余弦相似度计算,找出最相似的top-k个文本块。这是语义检索,不是关键词匹配。 +- **生成**:检索出的文本块和用户查询一起传给大语言模型。模型基于这些信息生成回答。 + + + +### 模型一致性原则 + +RAG里有个原则必须遵守:导入数据和查询时,必须用同一个Embedding模型。 + +不同模型把相同文本映射到不同向量空间。导入和查询用不同模型,就像用英语语法规则理解中文句子,匹配会失败。保持模型一致,检索才准确。 + +### Embedding质量影响RAG效果 + +Embedding模型性能直接决定RAG效果。高质量模型能准确捕捉文本语义,检索出最相关的信息。模型性能不佳会: + +- 检索不准确:返回的内容相关性不高。 +- 遗漏关键信息:没检索到有用的信息。 +- 引入噪音:检索出不相关内容,干扰模型判断。 + +选合适的Embedding模型,是RAG系统成功的关键。 + +## 向量数据库 + +Embedding技术广泛应用后,专门存储和检索高维向量的向量数据库出现了。这类数据库的核心能力是相似性搜索,根据向量距离查找最相似的向量。 + +### 两类向量数据库 + +- **专用向量数据库**:完全为向量检索构建,采用高级索引算法(HNSW、IVF)在海量数据中实现毫秒级查询。代表产品有Pinecone、Milvus、Weaviate。优势是检索快、性能优化好,适合大规模向量检索。 +- **集成向量检索功能的通用数据库**:传统关系型或文档型数据库,通过插件或内置功能支持向量检索。代表产品有Elasticsearch(dense_vector字段)、PostgreSQL(pgvector插件)、Redis。优势是同时处理结构化数据和向量数据,适合混合检索场景。 + + + +### Elasticsearch的语义检索 + +Elasticsearch通过dense_vector字段和kNN(最近邻)搜索功能,把Embedding转换、存储和检索封装在一起。用户可以直接把Elasticsearch作为RAG框架的向量存储: + +- 导入文档时,配置处理管道让Elasticsearch自动调用模型把文本转换为向量并存储。 +- 查询时,系统自动把查询转换为向量,执行相似性搜索。 + +这降低了技术门槛,开发者不用单独部署向量数据库,就能实现语义检索。 + +## Embedding的价值 + +Embedding技术把语言变成数学,让计算机能"理解"人类语言。 + +从技术演进看,Embedding从简单的词向量发展到上下文感知的动态表示。从Word2Vec到BERT,再到如今的大语言模型,每次技术突破都伴随着Embedding能力提升。 + +从应用看,Embedding在LLM内部把自然语言转化为数学表示。在RAG等应用中,Embedding实现从关键词匹配到语义检索,大幅提升信息检索准确性。 + +未来,Embedding还会承担更多: + +- 多模态融合:把文本、图像、音频映射到统一的向量空间,实现跨模态理解和生成。 +- 知识图谱构建:通过向量表示构建大规模知识网络,支持复杂推理和决策。 +- 个性化推荐:基于用户行为和偏好的向量表示,实现精准个性化服务。 +- 隐私保护计算:在向量空间进行加密计算,保护数据隐私同时实现智能分析。 + +理解Embedding的原理和应用,有助于更好地使用AI工具,也为探索AI技术未来提供视角。在AI时代,Embedding将继续连接人类智慧与机器能力。 \ No newline at end of file diff --git "a/docs/md/AI/\346\267\261\345\272\246\350\247\243\346\236\220Skills\357\274\232\344\273\216Prompt\345\210\260\350\203\275\345\212\233\345\244\215\347\224\250\347\232\204\346\212\200\346\234\257\351\235\251\345\221\275.md" "b/docs/md/AI/\346\267\261\345\272\246\350\247\243\346\236\220Skills\357\274\232\344\273\216Prompt\345\210\260\350\203\275\345\212\233\345\244\215\347\224\250\347\232\204\346\212\200\346\234\257\351\235\251\345\221\275.md" new file mode 100644 index 0000000..8ac1184 --- /dev/null +++ "b/docs/md/AI/\346\267\261\345\272\246\350\247\243\346\236\220Skills\357\274\232\344\273\216Prompt\345\210\260\350\203\275\345\212\233\345\244\215\347\224\250\347\232\204\346\212\200\346\234\257\351\235\251\345\221\275.md" @@ -0,0 +1,428 @@ +## 从Prompt到Skills的转变 + +2023年到2024年是"Prompt工程"的黄金时期。到了2025年底,AI圈开始频繁讨论一个新概念——**Skills(技能)**。 + +GitHub上Skills相关仓库获得上万star,各行各业的专业人士开始分享自己封装的Skills。Skills到底是什么?它为什么能引发如此关注? + +### Skills的本质:模块化能力包 + +**Agent Skills是模块化的能力包,包含指令、元数据和可选资源(脚本、模板),让AI Agent在需要时自动加载和使用**。 + +Skills就像AI助手的"工作手册库"。它不是每次对话都要重新输入的临时指令,而是一套可以长期保存、随时调用的能力模块。 + +### 从"带新人"到"给手册" + +要理解Skills,先看传统AI交互的问题。 + +想象你在公司带一个新人。他聪明、理解能力强,但不熟悉规矩。 + +- Prompt方式就像你每次都口头交代任务:"今天写一段公众号开头"、"把这个语气改得更克制"、"按我的结构写一页PPT"。这适合一次性指令,但一旦关闭对话,所有指令就消失,下次得从头教。 +- Rules或记忆机制相当于在工位贴一张"公司行为守则",只能管态度和格式这类宽泛要求。 +- MCP和工具调用更像是给他的电脑装一堆软件和API,他能调用外部工具,但不知道什么时候该用、怎么组合。 + +**Skills**改变了这一局面。它就像给新人一本完整的公司内部SOP手册——不是长到让人窒息的Word文档,而是一个知识库文件夹,里面有规范、脚本、模板、参考资料。AI会在需要时自己翻阅,按需加载。 + +### Skills的物理形态 + +很多人问:"这不就是Prompt吗?"实际上两者在形态上有本质区别: + +- **Prompt**:一段文本(通常是Markdown格式)。 +- **Skills**:一个文件夹结构,包含多种资源。 + +一个标准的Skill目录: + +``` +skill-name/ +├── SKILL.md # 核心指令文件(必需) +├── scripts/ # 可执行脚本(可选) +├── references/ # 参考文档(可选) +├── templates/ # 模板文件(可选) +└── assets/ # 其他资源(可选) +``` + +![Skills标准目录结构](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJud8uodFl0KLYh73KXlAj4vTFdRKbvQ5Ytow677A2VdzOTwFTSkAoOxG8TSw2T6AVAiabPSQTtUNRjTbNp2zHU5f7op2QnOlia3P8/640?wx_fmt=jpeg&from=appmsg) + +**SKILL.md是唯一必需的文件**,它采用YAML前导格式(类似简历开头的个人信息区),包含元数据和详细指令。这种设计让Skills不仅能承载知识,还能承载工具和流程。 + +## 渐进式披露架构 + +### 为什么"一次性塞进所有信息"行不通? + +Skills采用了**渐进式披露(Progressive Disclosure)**架构。这个概念在移动互联网时代曾是用户体验设计的核心原则之一。 + +打开一个APP,如果它一次性把所有功能、设置、选项都堆在你面前,你会怎样?认知负荷爆炸,不知所措。 + +人的瞬时记忆区非常有限,一瞬间只能接受最多7±2个信息块。AI也是如此——受限于Token窗口,对话越长,模型越"笨"。Token在Agent架构上寸土寸金。 + +传统做法:每次对话都把完整指令塞进上下文。一个详细的PDF处理工作流可能需要3000+ tokens。如果同时处理Excel、写代码、生成报告,上下文窗口很快爆满。 + +### 三层加载机制 + +Skills通过三层渐进式加载解决这个问题: + +**第一层:元数据——目录索引** + +这是Skills的"封面",包含技能名称和一句话描述。 + +- **加载时机**:每次对话开始时。 +- **Token消耗**:约100 tokens/Skill。 +- **作用**:让AI知道有哪些Skills可用,何时该用。 + +你可以安装数十个Skills,几乎没有性能损失。AI就像看图书馆的目录,知道有哪些书,但不必都翻开。 + +**第二层:指令——详细手册** + +当AI通过元数据判断某个任务需要特定Skill时,它会读取完整的SKILL.md文件。 + +- **加载时机**:任务匹配时触发。 +- **Token消耗**:数千tokens(按实际文件大小)。 +- **作用**:提供详细的操作指南和最佳实践。 + +用户说"帮我处理这个PDF",AI会判断匹配PDF Skill,然后加载详细的处理流程:先提取文本,再识别表单字段,最后填写并保存。 + +**第三层:资源和代码——深度参考** + +这层包括参考文档、可执行脚本、模板文件等。 + +- **加载时机**:SKILL.md中引用时。 +- **Token消耗**:按需加载。 +- **关键优势**:脚本执行不消耗上下文(仅结果消耗)。 + +一个包含复杂Python脚本的Skill,脚本本身的代码不会进入上下文,只有执行结果会返回。这让Skills可以承载几乎无限的资源,而不必担心Token限制。 + +![Skills三层渐进式加载架构](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJudaM0BlQicyFr2GkWb0h6PY453f0zib4zGXeYmmFRxOTUbuJYzvJtBWJvoib61uqicYo3NkaAFgXSiaCcXu8vkAZpDAWWnbcuGPwj7M/640?wx_fmt=jpeg&from=appmsg) + +### 一个真实的加载流程 + +以PDF处理为例,看Skills如何工作: + +**阶段1:初始状态** + +``` +用户输入:"用PDF技能填写这份合同" +系统提示 + 技能目录 + 用户消息 +Token消耗:约100 tokens +``` + +**阶段2:加载主手册** + +``` +AI判断:这个任务匹配PDF Skill +执行:bash cat ~/.claude/skills/pdf/SKILL.md +Token消耗:+3000 tokens +``` + +**阶段3:按需加载参考资料** + +``` +AI判断:需要表单填写规则 +执行:bash cat ~/.claude/skills/pdf/references/forms.md +Token消耗:+500 tokens +``` + +**阶段4:执行脚本** + +``` +执行:python scripts/fill_form.py --input contract.pdf --output filled.pdf +Token消耗:+200 tokens(仅输出结果) +``` + +**总Token消耗**:约3800 tokens。 + +对比传统方式:一次性加载所有相关文档和脚本定义,可能需要10,000+ tokens。Skills节省了60-70%的上下文空间。 + +![Skills加载流程示意图](https://mmbiz.qpic.cn/mmbiz_jpg/55IJsnyicJucklf0se97hDpQvLOBs6icfRhVBSUxsVugsfufulhlveiaQyjWxCwmKgyjcoibtHUfy3tbFopcC6OvJ4f4dTUdHfz7rCDYtGNfq34/640?wx_fmt=jpeg&from=appmsg) + +## Skills vs MCP vs Prompt:互补关系 + +### 三者的核心定位 + +Skills、MCP、Prompt不是竞争关系,而是互补关系: + +| 维度 | Skills | MCP | Prompt | +|------|--------|-----|--------| +| **核心定位** | 工作流程指南(How) | 外部系统连接(What) | 临时指令 | +| **解决问题** | 如何使用能力 | 提供什么数据/能力 | 当下做什么 | +| **形象比喻** | 使用说明书 | 工具箱 | 口头指令 | +| **Token效率** | 高(渐进加载) | 低(全量加载) | 中(每次重复) | +| **复用性** | 强(文件系统) | 中(协议层面) | 弱(手动复制) | + +![Skills vs MCP vs Prompt对比图](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJueR41mACITrYuXs21Yfnk2T0gfSBdbtucm8H84mWbyyQafmjxujfPKdmtyUpkZKTRH49erkbeYEJsdStMKIwTea65LXsb3V91o/640?wx_fmt=jpeg&from=appmsg) + +### Skills与MCP:工作手册 vs 门禁卡 + +**Skills解决"怎么做"(方法论/工作流),MCP解决"连到哪儿"(连接外部系统)**。 + +用职场类比: +- **MCP**:给AI一张门禁卡,让它能进入公司的各个系统(数据库、API、外部工具)。 +- **Skills**:给AI一本工作手册,教它如何使用这些系统完成具体任务。 + +一个组合场景: + +**生成销售报告** + +1. **MCP提供数据连接** + - 连接Salesforce获取客户数据。 + - 连接PostgreSQL查询销售记录。 + - 连接Google Sheets读取目标数据。 + +2. **Skills提供工作流程** + - 数据提取顺序(先查哪个系统)。 + - 计算逻辑(增长率、完成率)。 + - 报告格式和模板。 + - 异常处理规则。 + +MCP解决"能访问什么数据",Skills解决"如何使用这些数据生成报告"。 + +![Skills与MCP协作关系](https://mmbiz.qpic.cn/sz_mmbiz_jpg/55IJsnyicJufs3KcaQqmXZY32Yic0vqM9FeQ2ibTiaFbDYB0qnqPpRMrDdbaMeZqVuUnWQ6v0ox1zJPkZcPeicIxIaECPpJjbo1HzyichwiaTOXhjQ/640?wx_fmt=jpeg&from=appmsg) + +### Skills vs Prompt:从临时指令到持久能力 + +**Skills不就是高级一点的Prompt吗?** + +答案既是肯定的,也是否定的。 + +**相同点**:Skills的核心确实是自然语言指令,这与Prompt一致。 + +**根本区别**: + +- **生命周期**:Prompt是对话级的,Skills是系统级的。 +- **复用方式**:Prompt需要手动复制粘贴,Skills自动匹配触发。 +- **承载能力**:Prompt只能承载文本,Skills可以承载脚本、模板、参考文档。 +- **Token效率**:Prompt每次都全量加载,Skills按需渐进加载。 + +用一个实际例子: + +**没有Skills时**,每次都要说: + +``` +帮我总结这篇文章 → 翻译成英文 → 改成公众号风格 → 加标题 → 输出Markdown格式 +``` + +**有了Skills后**,只需要一句: +``` +使用「技术文章转公众号」Skill +``` + +AI会自动按照预设的完整流程执行。 + +## 实际应用 + +### 个人场景:把重复工作封装成能力包 + +**案例1:AI选题系统** + +一个内容团队用Skills构建了自动化选题系统,包含: +- 1个总控Agent。 +- 3个Skill(热点采集、选题生成、选题审核)。 + +每天只需要一句:"开始今日选题生成",系统就会自动: +1. 从多个平台采集全网热点。 +2. 筛选并生成TOP10选题(包含事件描述、核心角度、标题)。 +3. 按照内部方法论自动审核。 +4. 不通过时给出修改意见并迭代优化。 + +过去需要2-3小时的工作,现在几分钟就能完成初筛。 + +**案例2:整合包生成器** + +很多GitHub开源项目没有前端界面,环境配置复杂。有人用Skills做了一个"整合包生成器": + +提供一个GitHub链接,Skill就会: +1. 分析项目结构。 +2. 自动生成前端界面。 +3. 编写启动脚本。 +4. 打包成开箱即用的整合包。 + +解决了"想用但不会配置"的痛点。 + +### 团队场景:知识资产沉淀与共享 + +**传统方式的问题**: +- 每个团队各自维护长Prompt。 +- 写法、风格不统一。 +- 复用靠复制粘贴。 +- 难以版本管理和评审。 + +**Skills带来的改变**: +- 把"怎么做好一件事"固化成SKILL.md + 脚本 + 参考文档。 +- 放入Git版本库,走标准开发流程。 +- 团队间共享、评审、复用。 +- 形成企业内部的"技能库"(Skill Library)。 + +**组织架构示例**: +``` +公司级Agent产品 +├── 市场部维护:品牌文案Skill +├── 法务部维护:合同审阅Skill +├── 财务部维护:报销审核Skill +└── 技术部维护:代码审查Skill +``` + +所有技能装在同一个Agent身上,用户只跟一个界面打交道。 + +### 行业场景:专业知识标准化 + +**医疗诊断流程**:将诊断标准、注意事项、药物禁忌等封装成Skill,确保AI遵循医疗规范 + +**法律文书审查**:将审查要点、风险识别、合规要求标准化,提高审查质量和一致性 + +**代码审计规范**:将安全检查项、代码风格要求、最佳实践固化 + +**ML实验配置**:将实验设计规范、参数推荐范围、结果记录模板封装 + +这些领域知识需要结构化存储、团队共享、版本管理、跨平台使用——正是Skills的强项。 + +## 技术实现 + +### 最小可行Skill + +创建一个Skill只需要一个SKILL.md文件: + +```markdown +--- +name: hello-skill +description: A simple skill that greets users +--- + +# Hello Skill + +When user says hello, respond with a friendly greeting. +``` + +**必填字段**: +- `name`:技能名称(小写字母、数字、连字符符)。 +- `description`:功能描述。 + +**简单到人人可创建,强大到专业团队可用**。 + +### 完整Skill:PDF处理案例 + +``` +pdf-skill/ +├── SKILL.md +├── scripts/ +│ ├── extract_text.py +│ ├── fill_form.py +│ └── merge_pdfs.py +├── references/ +│ ├── FORMS.md +│ └── API_REFERENCE.md +└── templates/ + └── report_template.md +``` + +**SKILL.md内容**: + +```markdown +--- +name: pdf-processing +description: Extract text and tables from PDF files, fill forms, merge documents. + Use when working with PDF files or when the user mentions PDFs. +--- + +# PDF Processing + +## Quick Start + +1. For text extraction, use `python {baseDir}/scripts/extract_text.py` +2. For form filling, see [FORMS.md](references/FORMS.md) +3. For merging PDFs, execute the merge script + +## Supported Operations + +- Text extraction from text-based PDFs +- OCR for scanned PDFs (requires Tesseract) +- Form field identification and filling +- Multi-document merging + +## Best Practices + +- Always validate PDF integrity before processing +- Use OCR only when necessary (higher token cost) +- Keep extracted text under 10,000 tokens for best performance +``` + +**关键点**: + +- `{baseDir}`是自动替换变量,表示Skill的安装路径。 +- 可以引用其他文件(如FORMS.md),AI会在需要时加载。 +- 指令清晰、结构化,便于AI理解和执行。 + +### 安装和使用 + +**方法1:命令安装** + +```bash +# 安装官方Skill +claude skill install https://github.com/anthropics/skills/tree/main/skills/pdf + +# 或在对话中直接说 +"安装这个skill:https://github.com/xxx/skill-name" +``` + +**方法2:手动放置** + +将Skill文件夹放到对应目录: +- Claude Code:`~/.claude/skills/`。 +- Cursor:`~/.cursor/skills/。` +- OpenCode:`~/.config/opencode/skill/`。 + +**使用方式**: + +直接对话: +``` +用户:"帮我处理这个PDF" +AI会自动识别并调用PDF Skill +``` + +或者显式指定: +``` +用户:"使用PDF Skill提取这份文档的文本" +``` + +## 未来展望 + +### 从工具到生态 + +目前Skills还处于早期阶段,但已经有了生态雏形: + +- **官方Skills库**:Anthropic开源了官方Skills仓库,包含PDF、Excel、PPT、Word等常用技能。 +- **社区贡献**:GitHub上涌现大量社区贡献的Skills,涵盖数据分析、代码审查、文档生成等多个领域。 +- **工具支持**:Claude Code、Cursor、OpenCode等主流工具均已支持Skills。 +- **技能市场**:扣子等平台开始提供技能市场,支持搜索、安装、分享Skills。 + +### 潜在挑战 + +Skills也面临挑战: + +- **标准化问题**:不同平台、不同团队的Skills格式可能不统一,需要建立行业标准。 +- **安全与隐私**:Skills可以执行脚本,需要沙箱隔离和权限控制。 +- **质量参差**:开放的生态意味着质量良莠不齐,需要评价和筛选机制。 +- **学习曲线**:虽然创建简单,但要设计高质量的Skill仍需要经验。 + +### 对AI发展的意义 + +Skills代表一个重要趋势:**从让AI"理解"到让AI"执行"**。 + +过去几年,我们主要关注如何让AI更好地理解自然语言、理解上下文、理解意图。这是必要的基础,但还不够。 + +Skills的出现,标志着我们开始关注如何让AI系统地、可重复地、高质量地执行复杂任务。这不仅需要理解能力,还需要方法论、最佳实践、工具链的支持。 + +**这是AI从"对话伙伴"进化为"工作伙伴"的关键一步。** + +## 今天就开始你的第一个Skill + +Skills的热度已不亚于当年的Prompts。但这不只是流行趋势,而是实实在在的生产力革命。 + +如果你还在犹豫是否要尝试Skills,建议从最简单的开始: + +**今天**,安装一个官方Skill(比如skill-creator),感受一下"一个命令安装能力"的便捷。 + +**明天**,把最常用的一个动作固化成Skill——比如选题筛热点、报错日志分析、链接摘要生成。 + +**后天**,你会想把更多工作流程都搬进去。 + +到那一步,你就进入了另一个状态:**自由,创造的状态**。 + +Skills的核心价值,在于**复用**。当你把一次性的努力转化为可重复调用的能力,你就不再是每次都从零开始,而是站在前人的肩膀上持续前进。 diff --git "a/docs/md/DDD/\347\206\254\345\244\234\346\225\264\347\220\206\347\232\2042W\345\255\227DDD\345\255\246\344\271\240\347\254\224\350\256\260\357\274\214\344\273\216\347\220\206\350\256\272\345\210\260\345\256\236\346\210\230.md" "b/docs/md/DDD/\347\206\254\345\244\234\346\225\264\347\220\206\347\232\2042W\345\255\227DDD\345\255\246\344\271\240\347\254\224\350\256\260\357\274\214\344\273\216\347\220\206\350\256\272\345\210\260\345\256\236\346\210\230.md" new file mode 100644 index 0000000..93a2374 --- /dev/null +++ "b/docs/md/DDD/\347\206\254\345\244\234\346\225\264\347\220\206\347\232\2042W\345\255\227DDD\345\255\246\344\271\240\347\254\224\350\256\260\357\274\214\344\273\216\347\220\206\350\256\272\345\210\260\345\256\236\346\210\230.md" @@ -0,0 +1,922 @@ +> DDD 不是架构,而是一种架构设计方法论,它通过边界划分将复杂业务领域简单化,帮我们设计出清晰的领域和应用边界,可以很容易地实现架构演进。 + +# 基础概念 + +## 领域 + +领域就是用来确定范围的,范围即边界,这也是 DDD 在设计中不断强调边界的原因。 + +**简言之,DDD 的领域就是这个边界内要解决的业务问题域**。 + +领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围。领域可以拆分为多个子领域。一个领域相当于一个问题域,领域拆分为子域的过程就是大问题拆分为小问题的过程。 + +其实很好理解,DDD 的研究方法与自然科学的研究方法类似。当人们在自然科学研究中遇到复杂问题时,通常的做法就是将问题一步一步地细分,再针对细分出来的问题域,逐个深入研究,探索和建立所有子域的知识体系。当所有问题子域完成研究时,我们就建立了全部领域的完整知识体系了。 + +在领域不断划分的过程中,领域会细分为不同的子域,子域可以根据自身重要性和功能属性划分为三类子域,它们分别是:**核心域、通用域和支撑域**。 + +决定产品和公司核心竞争力的子域是核心域,它是业务成功的主要因素和公司的核心竞争力。没有太多个性化的诉求,同时被多个子域使用的通用功能子域是通用域。还有一种功能子域是必需的,但既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域,它就是支撑域。 + +这三类子域相较之下,核心域是最重要的,通用域和支撑域如果对应到企业系统,举例来说的话,通用域则是你需要用到的通用系统,比如认证、权限等等,这类应用很容易买到,没有企业特点限制,不需要做太多的定制化。而支撑域则具有企业特性,但不具有通用性,例如数据代码类的数据字典等系统。 + +聚合根与领域服务负责封装实现业务逻辑。领域服务负责对聚合根进行调度和封装,同时可以对外提供各种形式的服务,对于不能直接通过聚合根完成的业务操作就需要通过领域服务。 + +说白了就是,聚合根本身无法完全处理这个逻辑,例如支付这个步骤,订单聚合不可能支付,所以在订单聚合上架一层领域服务,在领域服务中实现支付逻辑,然后应用服务调用领域服务。 + +**遵守以下规范**: + +- 同限界上下文内的聚合之间的领域服务可直接调用。 +- 两个限界上下文的交互必须通过应用服务层抽离 接口->适配层 适配。 + +例子,用户升职,上级领导要变,上级领导的下属要变,代码如下: + +```java +@Service +public class UserDomainServiceImpl implements UserDomainService { + + @Override + public void promote(User user, User leader) { + + //保存领导 + user.saveLeader(leader); + + //领导增加下属 + leader.increaseSubordination(user); + } +} +``` + +## 限界上下文 + +我们可以将限界上下文拆解为两个词:限界和上下文。 + +限界就是领域的边界,而上下文则是语义环境。通过领域的限界上下文,我们就可以在统一的领域边界内用统一的语言进行交流,简单来说限界上下文可以理解为语义环境。 + +综合一下,我认为限界上下文的定义就是:**用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性**。 + +这个边界定义了模型的适用范围,使团队所有成员能够明确地知道什么应该在模型中实现,什么不应该在模型中实现。 + +举个例子: + +在一个明媚的早晨,孩子起床问妈妈:“今天应该穿几件衣服呀?”,妈妈回答:“能穿多少就穿多少!”那到底是穿多还是穿少呢? + +如果没有具体的语义环境,还真不太好理解。但是,如果你已经知道了这句话的语义环境,比如是寒冬腊月或者是炎炎夏日,那理解这句话的涵义就会很容易了。 + +**所以语言离不开它的语义环境**。 + +而业务的通用语言就有它的业务边界,我们不大可能用一个简单的术语没有歧义地去描述一个复杂的业务领域。限界上下文就是用来细分领域,从而定义通用语言所在的边界。 + +正如电商领域的商品一样,商品在不同的阶段有不同的术语,在销售阶段是商品,而在运输阶段则变成了货物。同样的一个东西,由于业务领域的不同,赋予了这些术语不同的涵义和职责边界,这个边界就可能会成为未来微服务设计的边界。看到这,我想你应该非常清楚了,领域边界就是通过限界上下文来定义的。 + +**理论上限界上下文就是微服务的边界。我们将限界上下文内的领域模型映射到微服务,就完成了从问题域到软件的解决方案**。 + +限界上下文之间的映射关系: + +- 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。 +- 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。 +- 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。 +- 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。 +- 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。 +- 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。 +- 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。 +- 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。 +- 另谋他路(SeparateWay):两个完全没有任何联系的上下文。 + +可以说,限界上下文是微服务设计和拆分的主要依据。在领域模型中,如果不考虑技术异构、团队沟通等其它外部因素,一个限界上下文理论上就可以设计为一个微服务。 + +## 贫血模型和充血模型 + +**贫血模型** + +贫血模型具有一堆属性和set get方法,存在的问题就是通过 pojo 这个对象上看不出业务有哪些逻辑,一个 pojo 可能被多个模块调用,只能去上层各种各样的service 来调用,这样以后当梳理这个实体有什么业务,只能一层一层去搜 service,也就是贫血失忆症,不够面向对象。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQRCkWsChrDseXzyfRVCS03qXRK5SCicFSHXtXVOfibRLe5iaqN7eLsaxmw/640) + +**充血模型** + +比如如下 user 用户有改密码,改手机号,修改登录失败次数等操作,都内聚在这个 user 实体中,每个实体的业务都是清晰的,就是充血模型,充血模型的内存计算会多一些,内聚核心业务逻辑处理。 + +说白了就是,不只是有贫血模型中的setter getter方法,还有其他的一些业务方法,这才是面向对象的本质,通过 user 实体就能看出有哪些业务存在。 + +```java +@NoArgsConstructor +@Getter +public class User extends Aggregate { + + /** + * 用户名 + */ + private String userName; + + /** + * 姓名 + */ + private String realName; + + /** + * 手机号 + */ + private String phone; + + /** + * 密码 + */ + private String password; + + /** + * 锁定结束时间 + */ + private Date lockEndTime; + + /** + * 登录失败次数 + */ + private Integer failNumber; + + /** + * 用户角色 + */ + private List roles; + + /** + * 部门 + */ + private Department department; + + /** + * 用户状态 + */ + private UserStatus userStatus; + + /** + * 用户地址 + */ + private Address address; + + public User(String userName, String phone, String password) { + + saveUserName(userName); + savePhone(phone); + savePassword(password); + } + + /** + * 保存用户名 + * @param userName + */ + private void saveUserName(String userName) { + if (StringUtils.isBlank(userName)){ + Assert.throwException("用户名不能为空!"); + } + + this.userName = userName; + } + + /** + * 保存电话 + * @param phone + */ + private void savePhone(String phone) { + if (StringUtils.isBlank(phone)){ + Assert.throwException("电话不能为空!"); + } + + this.phone = phone; + } + + /** + * 保存密码 + * @param password + */ + private void savePassword(String password) { + if (StringUtils.isBlank(password)){ + Assert.throwException("密码不能为空!"); + } + + this.password = password; + } + + /** + * 保存用户地址 + * @param province + * @param city + * @param region + */ + public void saveAddress(String province,String city,String region){ + this.address = new Address(province,city,region); + } + + /** + * 保存用户角色 + * @param roleList + */ + public void saveRole(List roleList) { + + if (CollectionUtils.isEmpty(roles)){ + Assert.throwException("角色不能为空!"); + } + + this.roles = roleList; + } +} +``` + +## 实体和值对象 + +**实体** + +实体和值对象这两个概念都是领域模型中的领域对象。实体和值对象是组成领域模型的基础单元。 + +在代码模型中,实体的表现形式是实体类,这个类包含了实体的属性和方法,通过这些方法实现实体自身的业务逻辑。 + +在 DDD 里,这些实体类通常采用充血模型,与这个实体相关的所有业务逻辑都在实体类的方法中实现,**跨多个实体的领域逻辑则在领域服务中实现**。 + +实体以 DO(领域对象)的形式存在,每个实体对象都有唯一的 ID。比如商品是商品上下文的一个实体,通过唯一的商品 ID 来标识,不管这个商品的数据如何变化,商品的 ID 一直保持不变,它始终是同一个商品。 + +在领域模型映射到数据模型时,一个实体可能对应 0 个、1 个或者多个数据库持久化对象。大多数情况下实体与持久化对象是一对一。在某些场景中,有些实体只是暂驻静态内存的一个运行态实体,它不需要持久化。比如,基于多个价格配置数据计算后生成的折扣实体。 + +而在有些复杂场景下,实体与持久化对象则可能是一对多或者多对一的关系。比如,用户 user 与角色 role 两个持久化对象可生成权限实体,一个实体对应两个持久化对象,这是一对多的场景。 + +再比如,有些场景为了避免数据库的联表查询,提升系统性能,会将客户信息 customer 和账户信息 account 两类数据保存到同一张数据库表中,客户和账户两个实体可根据需要从一个持久化对象中生成,这就是多对一的场景。 + +权限管理系统——用户实体,代码如下: + +```java +@NoArgsConstructor +@Getter +public class User extends Aggregate { + + /** + * 用户id-聚合根唯一标识 + */ + private UserId userId; + + /** + * 用户名 + */ + private String userName; + + /** + * 姓名 + */ + private String realName; + + /** + * 手机号 + */ + private String phone; + + /** + * 密码 + */ + private String password; + + /** + * 锁定结束时间 + */ + private Date lockEndTime; + + /** + * 登录失败次数 + */ + private Integer failNumber; + + /** + * 用户角色 + */ + private List roles; + + /** + * 部门 + */ + private Department department; + + /** + * 领导 + */ + private User leader; + + /** + * 下属 + */ + private List subordinationList = new ArrayList<>(); + + /** + * 用户状态 + */ + private UserStatus userStatus; + + /** + * 用户地址 + */ + private Address address; + + public User(String userName, String phone, String password) { + + saveUserName(userName); + savePhone(phone); + savePassword(password); + } + + /** + * 保存用户名 + * @param userName + */ + private void saveUserName(String userName) { + if (StringUtils.isBlank(userName)){ + Assert.throwException("用户名不能为空!"); + } + + this.userName = userName; + } + + /** + * 保存电话 + * @param phone + */ + private void savePhone(String phone) { + if (StringUtils.isBlank(phone)){ + Assert.throwException("电话不能为空!"); + } + + this.phone = phone; + } + + /** + * 保存密码 + * @param password + */ + private void savePassword(String password) { + if (StringUtils.isBlank(password)){ + Assert.throwException("密码不能为空!"); + } + + this.password = password; + } + + /** + * 保存用户地址 + * @param province + * @param city + * @param region + */ + public void saveAddress(String province,String city,String region){ + this.address = new Address(province,city,region); + } + + /** + * 保存用户角色 + * @param roleList + */ + public void saveRole(List roleList) { + + if (CollectionUtils.isEmpty(roles)){ + Assert.throwException("角色不能为空!"); + } + + this.roles = roleList; + } + + /** + * 保存领导 + * @param leader + */ + public void saveLeader(User leader) { + if (Objects.isNull(leader)){ + Assert.throwException("leader不能为空!"); + } + this.leader = leader; + } + + /** + * 增加下属 + * @param user + */ + public void increaseSubordination(User user) { + + if (null == user){ + Assert.throwException("leader不能为空!"); + } + + this.subordinationList.add(user); + } +} +``` + +**值对象** + +简单来说,值对象本质上就是一个集。 + +那这个集合里面有什么呢?若干个用于描述目的、具有整体概念和不可修改的属性。 + +那这个集合存在的意义又是什么?在领域建模的过程中,值对象可以保证属性归类的清晰和概念的完整性,避免属性零碎。 + +举例代码如下: + +```java +/** + * 地址数据 + */ +@Getter +public class Address extends ValueObject { + /** + * 省 + */ + private String province; + + /** + * 市 + */ + private String city; + + /** + * 区 + */ + private String region; + + public Address(String province, String city, String region) { + if (StringUtils.isBlank(province)){ + Assert.throwException("province不能为空!"); + } + if (StringUtils.isBlank(city)){ + Assert.throwException("city不能为空!"); + } + if (StringUtils.isBlank(region)){ + Assert.throwException("region不能为空!"); + + } + this.province = province; + this.city = city; + this.region = region; + } +} +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQUxN7tXn4ySotvv9qfPd1M8IS0f9f1bXHSTB4QAGQBiak3aibZqI2P6sg/640) + +人员实体原本包括:姓名、年龄、性别以及人员所在的省、市、县和街道等属性。这样显示地址相关的属性就很零碎了对不对?现在,我们可以将“省、市、县和街道等属性”拿出来构成一个“地址属性集合”,这个集合就是值对象了。 + +当你决定一个领域概念是否是 一个值对象时,你需要考虑它是否拥有以下特征: + +- 它度量或者描述了领城中的一件东西。 +- 它可以作为不变量。 +- 度量和描述改变时,可以用另一个值对象予以替换。 +- 它可以和其他值对象进行相等性比较。 +- 它不会对协作对象造成副作用。 + +值对象与实体一起构成聚合。**值对象逻辑上是实体属性的一部分,用于描述实体的特征。值对象创建后就不允许修改了,只能用另外一个值对象来整体替换**。 + +值对象是一些不会修改,只能完整替换的属性值的集合,你更关注他的属性和值,它没有太多的业务行为,用于描述实体的一些属性集,被实体引用,依附于实体的值对象基本没有自己的数据库表。 + +是否要设计成值对象,你要看这个对象是否后续还会来回修改,会不会有生命周期。如果不可修改,并且以后也不会专门针对它进行查询或者统计,你就可以把它设计成值对象,如果不行,那就设计成实体吧。 + +在领域建模时,我们可以将部分对象设计为值对象,保留对象的业务涵义,同时又减少了实体的数量;在数据建模时,我们可以将值对象嵌入实体,减少实体表的数量,简化数据库设计。 + +关于值对象,我还要多说几句。其实,DDD引入值对象还有一个重要的原因,就是到底领域建模优先还是数据建模优先? + +DDD提倡从领域模型设计出发,而不是先设计数据模型。前面讲过了,传统的数据模型设计通常是一个表对应一个实体,一个主表关联多个从表,当实体表太多的时候就很容易陷入无穷无尽的复杂的数据库设计,领域模型就很容易被数据模型绑架。可以说,值对象的诞生,在一定程度上,和实体是互补的。 + +同样的对象在不同的场景下,可能会设计出不同的结果。有些场景中,地址会被某一实体引用,它只承担描述实体的作用,并且它的值只能整体替换,这时候你就可以将地址设计为值对象,比如收货地址。而在某些业务场景中,地址会被经常修改,地址是作为一个独立对象存在的,这时候它应该设计为实体,比如行政区划中的地址信息维护。 + +**唯一的身份标识和可变性(mutability)特征将实体对象和值对象区分开来**。 + +比如,如果系统提供根据人名查找功能,但此时一个Person实体的唯一标识极有可能不是人名,因为存在大量重名的情况。 另 一方面,如果 一个系统提供根据公司税号的查找功能,此时税号便可以作为 Company 实体的唯一标识,因为政府为每个公司分配了唯一的税号。 + +值对象可以用于存放实体的唯 一标识。值对象是不变(immutable)的,这可以保证实体身份的稳定性,并且与身份标识相关的行为也可以得到集中处理。 + +以下是一些常用的创建实体身份标识的策略,从简单到复杂依次为: + +- 用户提供一个或多个初始唯一值作为程序输入,程序应该保证这些初始值是唯 一的。 +- 程序内部通过某种算法自动生成身份标识,此时可以使用一些类库或框架,当然程序自身也可以完成这样的功能。 +- 程序依赖于持久化存储,比如数据库,来生成唯一标识。 +- 另一个限界上下文 (系统或程序)已经决定出了唯一标识,这作为程序的输入,用户可以在一组标识中进行选择。 + +## 聚合 + +实体和值对象是很基础的领域对象。实体一般对应业务对象,它具有业务属性和业务行为;而值对象主要是属性集合,对实体的状态和特征进行描述。但实体和值对象都只是个体化的对象,它们的行为表现出来的是个体的能力。 + +那聚合在其中起什么作用呢? + +举个例子。社会是由一个个的个体组成的,象征着我们每一个人。随着社会的发展,慢慢出现了社团、机构、部门等组织,我们开始从个人变成了组织的一员,大家可以协同一致的工作,朝着一个最大的目标前进,发挥出更大的力量。 + +领域模型内的实体和值对象就好比个体,而能让实体和值对象协同工作的组织就是聚合,它用来确保这些领域对象在实现共同的业务逻辑时,能保证数据的一致性。 + +比如创建一个订单,必然会生成订单详情,订单详情肯定会有商品信息,我们在修改商品信息的时候,肯定就不能影响到这个订单详情中的商品信息。再比如:用户在下单的时候,会选择一个地址作为邮寄地址,如果该用户立刻下另一个订单,并对自己个人中心的地址进行修改,肯定就不能影响刚刚下单的邮寄地址信息。 + +你可以这么理解,聚合就是由业务和逻辑紧密关联的实体和值对象组合而成的,聚合是数据修改和持久化的基本单元,**每一个聚合对应一个仓储,实现数据的持久化**。 + +聚合在 DDD 分层架构里属于领域层,领域层包含了多个聚合,共同实现核心业务逻辑。 + +跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。比如有的业务场景需要同一个聚合的A和B两个实体来共同完成,我们就可以将这段业务逻辑用领域服务来实现;而有的业务逻辑需要聚合C和聚合D中的两个服务共同完成,这时你就可以用应用服务来组合这两个服务。 + +**聚合根** + +如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。 + +首先它作为实体本身,拥有实体的属性和业务行为,实现自身的业务逻辑。其次它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。 + +最后在聚合之间,它还是聚合对外的接口人,以聚合根 ID 关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。**也就是说,聚合之间通过聚合根 ID 关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体**。 + +下面以保险的投保业务场景为例,看一下聚合的构建过程主要都包括哪些步骤: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQZRQOfUHib0DCYOuiaeibkCG7GbXGRGiaiauuGURFUvibL2KRtkx0TaQA3nkQ/640) + +- 第1步:采用事件风暴,根据业务行为,梳理出在投保过程中发生这些行为的所有的实体和值对象,比如投保单、标的、客户、被保人等等。 +- 第2步:从众多实体中选出适合作为对象管理者的根实体,也就是聚合根。判断一个实体是否是聚合根,你可以结合以下场景分析:是否有独立的生命周期?是否有全局唯一ID?是否可以创建或修改其它对象?是否有专门的模块来管这个实体。图中的聚合根分别是投保单和客户实体。 +- 第3步:根据业务单一职责和高内聚原则,找出与聚合根关联的所有紧密依赖的实体和值对象。构建出1个包含聚合根(唯一)、多个实体和值对象的对象集合,这个集合就是聚合。在图中我们构建了客户和投保这两个聚合。 +- 第4步:在聚合内根据聚合根、实体和值对象的依赖关系,画出对象的引用和依赖模型。这里我需要说明一下:投保人和被保人的数据,是通过关联客户ID从客户聚合中获取的,在投保聚合里它们是投保单的值对象,这些值对象的数据是客户的冗余数据,即使未来客户聚合的数据发生了变更,也不会影响投保单的值对象数据。从图中我们还可以看出实体之间的引用关系,比如在投保聚合里投保单聚合根引用了报价单实体,报价单实体则引用了报价规则子实体。 +- 第5步:多个聚合根据业务语义和上下文一起划分到同一个限界上下文内。 + +这就是一个聚合诞生的完整过程了。 + +## 领域事件 + +举例来说的话,领域事件可以是业务流程的一个步骤,比如投保业务缴费完成后,触发投保单转保单的动作;也可能是定时批处理过程中发生的事件,比如批处理生成季缴保费通知单,触发发送缴费邮件通知操作;或者一个事件发生后触发的后续动作,比如密码连续输错三次,触发锁定账户的动作。 + +在做用户旅程或者场景分析时,我们要捕捉业务、需求人员或领域专家口中的关键词:“如果发生……,则……”“当做完……的时候,请通知……”“发生……时,则……”等。在这些场景中,如果发生某种事件后,会触发进一步的操作,那么这个事件很可能就是领域事件。 + +**领域事件相关案例** + +我来给你介绍一个保险承保业务过程中有关领域事件的案例。 + +一个保单的生成,经历了很多子域、业务状态变更和跨微服务业务数据的传递。这个过程会产生很多的领域事件,这些领域事件促成了保险业务数据、对象在不同的微服务和子域之间的流转和角色转换。在下面这张图中,我列出了几个关键流程,用来说明如何用领域事件驱动设计来驱动承保业务流程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQ7C1CQl4IEc8sIicyX8AJaOhcDNkxXXeOj7sib1SVNrbvqg7zKvIV27eA/640) + +事件起点:客户购买保险-业务人员完成保单录入-生成投保单-启动缴费动作。 + +1. 投保微服务生成缴费通知单,发布第一个事件:缴费通知单已生成,将缴费通知单数据发布到消息中间件。收款微服务订阅缴费通知单事件,完成缴费操作。缴费通知单已生成,领域事件结束。 +2. 收款微服务缴费完成后,发布第二个领域事件:缴费已完成,将缴费数据发布到消息中间件。原来的订阅方收款微服务这时则变成了发布方。原来的事件发布方投保微服务转换为订阅方。投保微服务在收到缴费信息并确认缴费完成后,完成投保单转成保单的操作。缴费已完成,领域事件结束。 +3. 投保微服务在投保单转保单完成后,发布第三个领域事件:保单已生成,将保单数据发布到消息中间件。保单微服务接收到保单数据后,完成保单数据保存操作。保单已生成,领域事件结束。 +4. 保单微服务完成保单数据保存后,后面还会发生一系列的领域事件,以并发的方式将保单数据通过消息中间件发送到佣金、收付费和再保等微服务,一直到财务,完后保单后续所有业务流程。这里就不详细说了。 + +总之,通过领域事件驱动的异步化机制,可以推动业务流程和数据在各个不同微服务之间的流转,实现微服务的解耦,减轻微服务之间服务调用的压力,提升用户体验。 + +一个完整的领域事件 = 事件发布 + 事件存储 + 事件分发 + 事件处理。 + +- 事件发布:构建一个事件,需要唯一标识,然后发布; + +- 事件存储:发布事件前需要存储,因为接收后的事件也会存储,可用于重试或对账等;就是每次执行一次具体的操作时,把行为记录下来,执行持久化。 + +- 事件分发:服务内的应用服务或者领域服务直接发布给订阅者,服务外需要借助消息中间件,比如Kafka,RabbitMQ等,支持同步或者异步。 + +- 事件处理:先将事件存储,然后再处理。 + +当然了,实际开发中事件存储和事件处理不是必须的。 + +因此实现方案:**发布订阅模式,分为跨上下文(Kafka,RocketMq)和上下文内(Spring事件,Guava Event Bus)的领域事件**。 + +举个例子,用户注册后,发送短信和邮件,使用Spring事件实现领域事件代码如下: + +```java +/** + * 用户注册事件 + **/ +public class UserRegisterEvent extends ApplicationEvent { + + public UserRegisterEvent(Object source) { + super(source); + } +} + + +/** + * 用户监听事件 + **/ +@Component +public class UserListener { + + @EventListener(UserRegisterEvent.class) + public void userRegister(UserRegisterEvent event) { + User user = (User) event.getSource(); + System.out.println("用户注册。。。发送短信。。。" + user); + System.out.println("用户注册。。。发送邮件。。。" + user); + } + + @EventListener(UserCancelEvent.class) + public void userCancelEvent(UserCancelEvent event) { + User user = (User) event.getSource(); + System.out.println("用户注销。。。" + user); + } + +} + +/** + * 发布用户注册事件 + */ +@RunWith(value = SpringJUnit4ClassRunner.class) +@SpringBootTest(classes = DemoApplication.class) +public class MyClient { + + @Autowired + private ApplicationContext applicationContext; + + @Test + public void test() { + User user = new User(); + //发布事件 + applicationContext.publishEvent(new UserRegisterEvent(user)); + } +} +``` + +**事件风暴** + +事件风暴是一项团队活动,领域专家与项目团队通过头脑风暴的形式,罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合,然后对每一个事件,标注出导致该事件的命令,再为每一个事件标注出命令发起方的角色。命令可以是用户发起,也可以是第三方系统调用或者定时器触发等,最后对事件进行分类,整理出实体、聚合、聚合根以及限界上下文。而事件风暴正是 DDD 战略设计中经常使用的一种方法,它可以快速分析和分解复杂的业务领域,完成领域建模。 + +# DDD分层架构 + +DDD 的分层架构在不断发展。最早是传统的四层架构;再后来领域层和应用层之间增加了上下文环境(Context)层,五层架构(DCI)就此形成了。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQjYZia7cTYxoia2B7gmuiblrFhibIYkdmKdeEhbicovPO5qYSPbOWh0StXyQ/640) + +DDD分层架构中的要素其实和三层架构类似,只是在DDD分层架构中,这些要素被重新归类,重新划分了层,确定了层与层之间的交互规则和职责边界。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQtiasTtQFETFbGlODH83q6Oh5BLxEyWicO9pa4fNjjZYYD6tORtfKyiaaw/640) + +## 用户接口层 + +用户接口层是前端应用和微服务之间服务访问和数据交换的桥梁。它处理前端发送的 Restful 请求和解析用户输入的配置文件等,将数据传递给应用层。或获取应用服务的数据后,进行数据组装,向前端提供数据服务。主要服务形态是 Facade 服务。 + +Facade 服务分为接口和实现两个部分。完成服务定向,DO 与 DTO 数据的转换和组装,实现前端与应用层数据的转换和交换。 + +- 一般包括用户接口、Web 服务、rpc请求,mq消息等外部输入均被视为外部输入的请求。对外暴露API,具体形式不限于RPC、Rest API、消息等。 + +- 一般都很薄,提供必要的参数校验和异常捕获流程。 + +- 一般会提供VO或者DTO到Entity或者ValueObject的转换,用于前后端调用的适配,当然dto可以直接使用command和query,视情况而定。 + +用户接口层很重要,在于前后端调用的适配。**若你的微服务要面向很多应用或渠道提供服务,而每个渠道的入参出参都不一样,你不太可能开发出太多应用服务,这样Facade接口就起很好的作用了,包括DO和DTO对象的组装和转换等**。 + +## 应用层 + +应用层是很薄的一层,理论上不应该有业务规则或逻辑,主要面向用例和流程相关的操作。但应用层又位于领域层之上,因为领域层包含多个聚合,所以它可以协调多个聚合的服务和领域对象完成服务编排和组合,协作完成业务操作。除了同步方法调用外,还可以发布或者订阅领域事件,权限校验、事务控制,一个事务对应一个聚合根。 + +**应用层负责不同聚合之间的服务和数据协调,负责微服务之间的事件发布和订阅**。 + +通过应用服务对外暴露微服务的内部功能,这样就可以隐藏领域层核心业务逻辑的复杂性以及内部实现机制。 + +应用层的主要服务形态有:应用服务、事件发布和订阅服务。应用服务内用于组合和编排的服务,主要来源于领域服务,也可以是外部微服务的应用服务。 + +## 领域层 + +**领域层包含聚合根、实体、值对象、领域服务等领域模型中的领域对象**。 + +这里我要特别解释一下其中几个领域对象的关系,以便你在设计领域层的时候能更加清楚。 + +首先,领域模型的业务逻辑主要是由实体和领域服务来实现的,其中实体会采用充血模型来实现所有与之相关的业务功能。 + +其次,你要知道,实体和领域对象在实现业务逻辑上不是同级的,当**领域中的某些功能,单一实体(或者值对象)不能实现时,领域服务就会出马,它可以组合聚合内的多个实体(或者值对象),实现复杂的业务逻辑**。 + +领域层主要的服务形态有实体方法和领域服务。实体采用充血模型,在实体类内部实现实体相关的所有业务逻辑,实现的形式是实体类中的方法。实体是微服务的原子业务逻辑单元。在设计时我们主要考虑实体自身的属性和业务行为,实现领域模型的核心基础能力。不必过多考虑外部操作和业务流程,这样才能保证领域模型的稳定性。 + +**DDD 提倡富领域模型,尽量将业务逻辑归属到实体对象上,实在无法归属的部分则设计成领域服务**。 + +领域服务会对多个实体或实体方法进行组装和编排,实现跨多个实体的复杂核心业务逻辑。对于严格分层架构,**如果单个实体的方法需要对应用层暴露,则需要通过领域服务封装后才能暴露给应用服务**。 + +## 基础层 + +**基础层也叫基础设施层,基础层是贯穿所有层的,它的作用就是为其它各层提供通用的技术和基础服务,包括第三方工具、驱动、消息中间件、网关、文件、缓存以及数据库等,比较常见的功能还是提供数据库持久化**。 + +基础层的服务形态主要是仓储服务。仓储服务包括接口和实现两部分。仓储接口服务供应用层或者领域层服务调用,仓储实现服务,完成领域对象的持久化或数据初始化。 + +DDD分层架构的数据库等基础资源访问,采用了仓储(Repository)设计模式,通过依赖到置实现各层对基础资源的解耦。 + +仓储又分为两部分:仓储接口和仓储实现。 + +**仓储接口放在领域层中,仓储实现放在基础层。原来三层架构通用的第三方工具包、驱动、Common、Utility、Config等通用的公共的资源类统一放到了基础层**。 + +比如说,在传统架构设计中,由于上层应用对数据库的强耦合,很多公司在架构演进中最担忧的可能就是换数据库了,因为一旦更换数据库,就可能需要重写大部分的代码,这对应用来说是致命的。那采用依赖倒置的设计以后应用层就可以通过解耦来保持独立的核心业务。 + +- 为业务逻辑提供支撑能力,提供通用的技术能力,仓库写增删改查类似DAO。 +- 防腐层实现(封装变化)用于业务检查和隔离第三方服务,内部 try catch。 + +# 防腐层(ACL) + +当某个功能模块需要依赖第三方系统提供的数据或者功能时,我们常用的策略就是直接使用外部系统的API、数据结构。 + +这样存在的问题就是,**因使用外部系统,而被外部系统的质量问题影响,从而“腐化”本身设计的问题**。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQTDVWnzY7icqpN8NuibumhjK7RbGEwaHKia70VpiavgKwPiaLVBNXeb8z2ew/640) + +因此我们的解决方案就是在两个系统之间加入一个中间层,隔离第三方系统的依赖,对第三方系统进行通讯转换和语义隔离,这个中间层,我们叫它防腐层。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQaLrQUNtSytGjeYXaXVR02KAwHXqjbj4mtSwBcRMCDkReC6iaEicYol6g/640) + +说白了就是,两个系统之间加了中间层,中间层类似适配器模式,解决接口差异的对接,接口转换是单向的(即从调用方向被调用方进行接口转换),防腐层强调两个子系统语义解耦,接口转换是双向的。 + +# 服务调用 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQpT00vO3qq7wDywOygpeHHmG1WScRvibnm7aZYbJQjk22IcFOENpy7Mg/640) + +**微服务内跨层服务调用** + +微服务架构下往往采用前后端分离的设计模式,前端应用独立部署。前端应用调用发布在API 网关上的 Facade 服务,Facade 定向到应用服务。应用服务作为服务组织和编排者,它的服务调用有这样两种路径: + +- 第一种是应用服务调用并组装领域服务。此时领域服务会组装实体和实体方法,实现核心领域逻辑。领域服务通过仓储服务获取持久化数据对象,完成实体数据初始化。 +- 第二种是应用服务直接调用仓储服务。这种方式主要针对像缓存、文件等类型的基础层数据访问。这类数据主要是查询操作,没有太多的领域逻辑,不经过领域层,不涉及数据库持久化对象。 + +**微服务之间的服务调用** + +微服务之间的应用服务可以直接访问,也可以通过 API 网关访问。由于跨微服务操作,在进行数据新增和修改操作时,你需关注分布式事务,保证数据的一致性。 + +**领域事件驱动** + +领域事件驱动包括微服务内和微服务之间的事件。微服务内通过事件总线(EventBus)完成聚合之间的异步处理。微服务之间通过消息中间件完成。异步化的领域事件驱动机制是一种间接的服务访问方式。当应用服务业务逻辑处理完成后,如果发生领域事件,可调用事件发布服务,完成事件发布。当接收到订阅的主题数据时,事件订阅服务会调用事件处理领域服务,完成进一步的业务操作。 + +# 服务依赖 + +在《实现领域驱动设计》一书中,DDD 分层架构有一个重要的原则:**每层只能与位于其下方的层发生耦合**。 + +而架构根据耦合的紧密程度又可以分为两种:严格分层架构和松散分层架构。 + +**优化后的DDD 分层架构模型就属于严格分层架构,任何层只能对位于其直接下方的层产生依赖。而传统的 DDD 分层架构则属于松散分层架构,它允许某层与其任意下方的层发生依赖**。 + +那我们怎么选呢?综合我的经验,为了服务的可管理,我建议你采用严格分层架构。 + +**在严格分层架构中,领域服务只能被应用服务调用,而应用服务只能被用户接口层调用,服务是逐层对外封装或组合的,依赖关系清晰**。 + +而在松散分层架构中,领域服务可以同时被应用层或用户接口层调用,服务的依赖关系比较复杂且难管理,甚至容易使核心业务逻辑外泄。试想下,如果领域层中的某个服务发生了重大变更,那该如何通知所有调用方同步调整和升级呢?但在严格分层架构中,你只需要逐层通知上层服务就可以了。 + +# 服务封装 + +在严格分层架构模式下,不允许服务的跨层调用,每个服务只能调用它的下一层服务。服务从下到上依次为:实体方法、领域服务和应用服务。如果需要实现服务的跨层调用,我们应该怎么办?我建议你采用服务逐层封装的方式。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQAwd49xDNQ2YSV10XeS2QQGBoWKZvicFDRXe5Z2oksOdfQlWZnREItNA/640) + +我们看一下上面这张图,服务的封装和调用主要有以下几种方式: + +**实体方法的封装** + +实体方法是最底层的原子业务逻辑。如果单一实体的方法需要被跨层调用,你可以将它封装成领域服务,这样封装的领域服务就可以被应用服务调用和编排了。如果它还需要被用户接口层调用,你还需要将这个领域服务封装成应用服务。经过逐层服务封装,实体方法就可以暴露给上面不同的层,实现跨层调用。 + +封装时服务前面的名字可以保持一致,你可以用 DomainService 或 *AppService 后缀来区分领域服务或应用服务。 + +**领域服务的组合和封装** + +领域服务会对多个实体和实体方法进行组合和编排,供应用服务调用。如果它需要暴露给用户接口层,领域服务就需要封装成应用服务。 + +**应用服务的组合和编排** + +应用服务会对多个领域服务进行组合和编排,暴露给用户接口层,供前端应用调用。 + +在应用服务组合和编排时,你需要关注一个现象:多个应用服务可能会对多个同样的领域服务重复进行同样业务逻辑的组合和编排。当出现这种情况时,你就需要分析是不是领域服务可以整合了。你可以将这几个不断重复组合的领域服务,合并到一个领域服务中实现。这样既省去了应用服务的反复编排,也实现了服务的演进。这样领域模型将会越来越精炼,更能适应业务的要求。 + +应用服务类放在应用层 Service 目录结构下。领域事件的发布和订阅类放在应用层 Event。 + +# DDD建模步骤 + +设计领域模型的一般步骤如下: + +1. 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系。 +2. 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象。 +3. 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根。 +4. 为聚合根设计仓储,并思考实体或值对象的创建方式。 +5. 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。 + +# DDD代码模型 + +微服务—级目录是按照DDD分层架构的分层职责来定义的。从下面这张图中,我们可以看到,在代码模型里分别为用户接口层、应用层、领域层和基础层,建立了interfaces、application、domain 和 infrastructure 四个—级代码目录。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQSjfpMAM9u2FvTYNIXL6S8B8gQDIqhEaP01DK517MvgFplzRtbYSGPg/640) + +- Interfaces(用户接口层)∶它主要存放用户接口层与前端交互、展现数据相关的代码。前端应用通过这一层的接口,向应用服务获取展现所需的数据。这一层主要用来处理用户发送的Restful请求,解析用户输入的配置文件,并将数据传递给Application层。数据的组装、数据传输格式以及Facade接口等代码都会放在这一层目录里。 +- Application(应用层)︰它主要存放应用层服务组合和编排相关的代码。应用服务向下基于微服务内的领域服务或外部微服务的应用服务完成服务的编排和组合,向上为用户接口层提供各种应用数据展现支持服务。应用服务和事件等代码会放在这一层目录里。 +- Domain(领域层)︰它主要存放领域层核心业务逻辑相关的代码。领域层可以包含多个聚合代码包,它们共同实现领域模型的核心业务逻辑。聚合以及聚合内的实体、方法、领域服务和事件等代码会放在这一层目录里。 +- Infrastructure(基础层)∶它主要存放基础资源服务相关的代码,为其它各层提供的通用技术能力、三方软件包、数据库服务、配置和基础资源服务的代码都会放在这一层目录里。 + +## 用户接口层 + +Interfaces 的代码目录结构有:assembler、dto 和 facade 三类。 + +- Assembler:实现 DTO 与领域对象之间的相互转换和数据交换。一般来说 Assembler 与DTO 总是一同出现。 + +- DTO:它是数据传输的载体,内部不存在任何业务逻辑,我们可以通过 DTO 把内部的领域对象与外界隔离。 +- Facade:提供较粗粒度的调用接口,将用户请求委派给一个或多个应用服务进行处理。 + +## 应用层 + +Application 的代码目录结构有:event 和 service。 + +- Event(事件):这层目录主要存放事件相关的代码。它包括两个子目录:publish 和subscribe。前者主要存放事件发布相关代码,后者主要存放事件订阅相关代码(事件处理相关的核心业务逻辑在领域层实现)。 + +这里提示一下:**虽然应用层和领域层都可以进行事件的发布和处理,但为了实现事件的统一管理,我建议你将微服务内所有事件的发布和订阅的处理都统一放到应用层,事件相关的核心业务逻辑实现放在领域层。通过应用层调用领域层服务,来实现完整的事件发布和订阅处理流程**。 + +- Service(应用服务):这层的服务是应用服务。应用服务会对多个领域服务或外部应用服务进行封装、编排和组合,对外提供粗粒度的服务。应用服务主要实现服务组合和编排,是一段独立的业务逻辑。你可以将所有应用服务放在一个应用服务类里,也可以把一个应用服务设计为一个应用服务类,以防应用服务类代码量过大。 + +## 领域层 + +Domain 是由一个或多个聚合包构成,共同实现领域模型的核心业务逻辑。聚合内的代码模型是标准和统一的,包括:entity、event、repository 和 service 四个子目录。 + +而领域层聚合内部的代码目录结构是这样的: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQhkotL8hkunzzEbMQMBT4KobMTG2BzX0lKng7VyNMR2ibmxhHSDcl6kQ/640) + +- Aggregate(聚合):它是聚合软件包的根目录,可以根据实际项目的聚合名称命名,比如权限聚合。在聚合内定义聚合根、实体和值对象以及领域服务之间的关系和边界。聚合内实现高内聚的业务逻辑,它的代码可以独立拆分为微服务。以聚合为单位的代码放在一个包里的主要目的是为了业务内聚,而更大的目的是为了以后微服务之间聚合的重组。聚合之间清晰的代码边界,可以让你轻松地实现以聚合为单位的微服务重组,在微服务架构演进中有着很重要的作用。 + +- Entity(实体):它存放聚合根、实体、值对象以及工厂模式(Factory)相关代码。实体类采用充血模型,同一实体相关的业务逻辑都在实体类代码中实现。跨实体的业务逻辑代码在领域服务中实现。 +- Event(事件):它存放事件实体以及与事件活动相关的业务逻辑代码。 +- Service(领域服务):它存放领域服务代码。一个领域服务是多个实体组合出来的一段业务逻辑。你可以将聚合内所有领域服务都放在一个领域服务类中,你也可以把每一个领域服务设计为一个类。如果领域服务内的业务逻辑相对复杂,我建议你将一个领域服务设计为一个领域服务类,避免由于所有领域服务代码都放在一个领域服务类中,而出现代码臃肿的问题。领域服务封装多个实体或方法后向上层提供应用服务调用。 +- Repository(仓储):它存放所在聚合的查询或持久化领域对象的代码,通常包括仓储接口和仓储实现方法。为了方便聚合的拆分和组合,我们设定了一个原则:一个聚合对应一个仓储。 + +特别说明:按照 DDD 分层架构,仓储实现本应该属于基础层代码,但为了在微服务架构演进时,保证代码拆分和重组的便利性,可以将聚合仓储实现的代码放到聚合包内。这样,如果需求或者设计发生变化导致聚合需要拆分或重组时,我们就可以将包括核心业务逻辑和仓储代码的聚合包整体迁移,轻松实现微服务架构演进。 + +## 基础层 + +Infrastructure 的代码目录结构有:config 和 util 两个子目录。 + +- Config:主要存放配置相关代码。 +- Util:主要存放平台、开发框架、消息、数据库、缓存、文件、总线、网关、第三方类库、通用算法等基础代码,你可以为不同的资源类别建立不同的子目录。 + +------ + +关于代码模型还需要强调两点内容。 + +- 第一点:聚合之间的代码边界一定要清晰。聚合之间的服务调用和数据关联应该是尽可能的松耦合和低关联,聚合之间的服务调用应该通过上层的应用层组合实现调用,原则上不允许聚合之间直接调用领域服务。这种松耦合的代码关联,在以后业务发展和需求变更时,可以很方便地实现业务功能和聚合代码的重组,在微服务架构演进中将会起到非常重要的作用。 +- 第二点:你一定要有代码分层的概念。写代码时一定要搞清楚代码的职责,将它放在职责对应的代码目录内。应用层代码主要完成服务组合和编排,以及聚合之间的协作,它是很薄的一层,不应该有核心领域逻辑代码。领域层是业务的核心,领域模型的核心逻辑代码一定要在领域层实现。如果将核心领域逻辑代码放到应用层,你的基于DDD分层架构模型的微服务慢慢就会演变成传统的三层架构模型了。 + +## 目录结构 + +以下是一个 DDD 工程的代码目录结构,提供参考: + +``` +│ +│ ├─interface 用户接口层 +│ │ └─controller 控制器,对外提供(Restful)接口 +│ │ └─facade 外观模式,对外提供本地接口和dubbo接口 +│ │ └─mq mq消息,消费者消费外部mq消息 +│ │ +│ ├─application 应用层 +│ │ ├─assembler 装配器 +│ │ ├─dto 数据传输对象,xxxCommand/xxxQuery/xxxVo +│ │ │ ├─command 接受增删改的参数 +│ │ │ ├─query 接受查询的参数 +│ │ │ ├─vo 返回给前端的vo对象 +│ │ ├─service 应用服务,负责领域的组合、编排、转发、转换和传递 +│ │ ├─repository 查询数据的仓库接口 +│ │ ├─listener 事件监听定义 +│ │ +│ ├─domain 领域层 +│ │ ├─entity 领域实体 +│ │ ├─valueobject 领域值对象 +│ │ ├─service 领域服务 +│ │ ├─repository 仓库接口,增删改的接口 +│ │ ├─acl 防腐层接口 +│ │ ├─event 领域事件 +│ │ +│ ├─infrastructure 基础设施层 +│ │ ├─converter 实体转换器 +│ │ ├─repository 仓库 +│ │ │ ├─impl 仓库实现 +│ │ │ ├─mapper mybatis mapper接口 +│ │ │ ├─po 数据库orm数据对象 +│ │ ├─ack 实体转换器 +│ │ ├─mq mq消息 +│ │ ├─cache 缓存 +│ │ ├─util 工具类 +│ │ +│ +``` + +# 数据对象视图 + +- 数据持久化对象 PO(Persistent Object):持久化对象,它跟持久层(通常是关系型数据库)的数据结构形成一 一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应 PO 的一个(或若干个)属性。最形象的理解就是一个 PO 就是数据库中的一条记录,好处是可以把一条记录作为一个对象处理,可以方便的转为其它对象。也有团队使用DO(Data Object)表示数据对象。 + +- 领域对象 DO(Domain Object):领域对象,就是从现实世界中抽象出来的有形或无形的业务实体,使用的是充血模型设计的对象。也有团队使用用 BO(Business Objects)表示业务对象的概念。 + +- 数据传输对象 DTO(Data Transfer Object):数据传输对象,主要用于远程调用之间传输的对象的地方。比如我们一张表有 100 个字段,那么对应的 PO 就有 100 个属性。但是客户端只需要 10 个字段,没有必要把整个 PO 对象传递到客户端,这时我们就可以用只有这 10 个属性的 DTO 来传递结果到客户端,这样也不会暴露服务端表结构。到达客户端以后,如果用这个对象来对应界面显示,那此时它的身份就转为 VO。DTO泛指用于展示层与服务层之间的数据传输对象,当然VO也相当于数据DTO的一种。 + +- 视图对象 VO(View Object):视图对象,主要对应界面显示的数据对象。对于一个WEB页面,小程序,微信公众号等前端需要的数据对象。也有团队用VO表示领域层中的Value Object值对象,这个要根据团队的规范来定义。 + +- 简单对象POJO(Plain Ordinary Java Object):简单对象,是只具有setter getter方法对象的统称。但是不要把对象名命名成 xxxPOJO! + +我们结合下面这张图,看看微服务各层数据对象的职责和转换过程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMB2qgoBgtQBs9NUlzaSOmQ9jBiavB1V5RKocZZtKdj8rvVxUqNJU56AQV1rAPXXyq5p6ib6mJiaicZrA/640) + +**基础层** + +基础层的主要对象是 PO 对象。我们需要先建立 DO 和 PO 的映射关系。当 DO 数据需要持久化时,仓储服务会将 DO 转换为 PO 对象,完成数据库持久化操作。当 DO 数据需要初始化时,仓储服务从数据库获取数据形成 PO 对象,并将 PO 转换为 DO,完成数据初始化。大多数情况下 PO 和 DO 是一一对应的。但也有 DO 和 PO 多对多的情况,在 DO 和 PO数据转换时,需要进行数据重组 + +**领域层** + +领域层的主要对象是 DO 对象。DO 是实体和值对象的数据和业务行为载体,承载着基础的核心业务逻辑。通过 DO 和 PO 转换,我们可以完成数据持久化和初始化。 + +**应用层** + +应用层的主要对象是 DO 对象。如果需要调用其它微服务的应用服务,DO 会转换为DTO,完成跨微服务的数据组装和传输。用户接口层先完成 DTO 到 DO 的转换,然后应用服务接收 DO 进行业务处理。如果 DTO 与 DO 是一对多的关系,这时就需要进行 DO数据重组。 + +**用户接口层** + +用户接口层会完成 DO 和 DTO 的互转,完成微服务与前端应用数据交互及转换。Facade服务会对多个 DO 对象进行组装,转换为 DTO 对象,向前端应用完成数据转换和传输。 + +**前端应用** + +前端应用主要是 VO 对象。展现层使用 VO 进行界面展示,通过用户接口层与应用层采用DTO 对象进行数据交互。 + +# 总结 + +DDD 基于各种考虑,有很多的设计原则,也用到了很多的设计模式。条条框框多了,很多人可能就会被束缚住,总是担心或犹豫这是不是原汁原味的 DDD。 + +**其实我们不必追求极致的 DDD,这样做反而会导致过度设计,增加开发复杂度和项目成本**。 + +DDD 的设计原则或模式,是考虑了很多具体场景或者前提的。有的是为了解耦,如仓储服务、边界以及分层,有的则是为了保证数据一致性,如聚合根管理等。在理解了这些设计原则的根本原因后,有些场景你就可以灵活把握设计方法了,你可以突破一些原则,不必受限于条条框框,大胆选择最合适的方法。 diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Mapping.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Mapping.md" new file mode 100644 index 0000000..7f9f7b5 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Mapping.md" @@ -0,0 +1,368 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + +[TOC] + +本篇讲解Elasticsearch中非常重要的一个概念:**Mapping**,Mapping是索引必不可少的组成部分。 + +## Mapping 的基本概念 + +**Mapping 也称之为映射,定义了 ES 的索引结构、字段类型、分词器等属性,是索引必不可少的组成部分** + +ES 中的 Mapping 有点类似于关系型数据库中“表结构”的概念,在 MySQL 中,表结构里包含了字段名称,字段的类型还有索引信息等。在 Mapping 里也包含了一些属性,比如字段名称、类型、字段使用的分词器、是否评分、是否创建索引等属性。 + +### 查看索引 Mapping + +```JSON +//查看索引完整的mapping +GET /my_index/_mappings +//查看索引指定字段的mapping +GET /my_index/_mappings/field/field_name +``` + +例如,如果你有一个名为 "my_index" 的索引,并且你想查询字段 "my_field" 的 mapping,那么请求就像这样: + +```jon +GET /my_index/_mapping/field/my_field +``` + +此请求会返回如下类型的输出: + +```json +{ + "my_index" : { + "mappings" : { + "my_field" : { + "full_name" : "my_field", + "mapping" : { + "my_field" : { + "type" : "text", + "fields" : { + "keyword" : { + "type" : "keyword", + "ignore_above" : 256 + } + } + } + } + } + } + } +} +``` + +在这个响应中,你可以看到 "my_field" 是 "text" 类型,并且它也有一个子字段 "keyword"。 + +## 字段数据类型 + +映射的数据类型也就是 ES 索引支持的数据类型,其概念和 MySQL 中的字段类型相似,但是具体的类型和 MySQL 中有所区别,最主要的区别就在于 ES 中支持可分词的数据类型,如:Text 类型,可分词类型是用以支持全文检索的,这也是 ES 生态最核心的功能。 + +### 数字类型 + +- **long**:64 位有符号整形。 +- **integer**:32 位有符号整形。 +- **short**:16 位有符号整形。 +- **byte**:8位有符号整形。 +- **double**:双精度64位浮点类型。 +- **float**:单精度32位浮点类型。 +- **half_float**:半精度16位浮点数。 +- **scaled_float**:缩放类型浮点数,按固定 double 比例因子缩放。 +- **unsigned_long**:无符号 64 位整数。 + +### 基本数据类型 + +- **binary**:存储二进制字符串,经过Base64编码处理。 +- **boolean**:布尔类型,接收 ture 和 false 两个值。 + +### Keywords 类型 + +- **keyword**:这种类型被用来索引结构化数据,如 email 地址、主机名、状态码以及标签等。这类数据可以以精确值的形式进行搜索,并且可以用于过滤 (filtering),排序 (sorting) 和聚合 (aggregating)。关键词字段只和其确切的值匹配,它们的查询不会进行分词处理。 +- **constant_keyword**:这种类型适用于在所有文档中都始终有相同值的字段。比如在一次特定的索引操作中,所有的文档都需要包含一个常量字段,例如 `env` 的值可能为 "production"。 +- **wildcard**:这种类型的字段可以存储任何字符串,并且对于这种类型的字段进行的查询可以使用通配符表达式。这种类型的字段对于像 grep 这样的场景非常有用,即当你需要在一个长字符串中搜索一个较短的子串时。但是要注意,虽然 wildcard 字段提供了强大的模式匹配能力,但是这种能力是需要付出性能代价的。 + +### 日期类型 + +JSON 没有日期数据类型,因此 Elasticsearch 中的日期可以是以下三种: + +- **包含格式化日期的字符串**:例如 "2015-01-01"、 "2015/01/01 12:10:30"。 +- **时间戳**:表示自"1970年 1 月 1 日"以来的毫秒数/秒数。 +- **date_nanos**:此数据类型是对 date 类型的补充。但是有一个重要区别。date 类型存储最高精度为毫秒,而date_nanos 类型存储日期最高精度是纳秒,但是高精度意味着可存储的日期范围小,即:从大约 1970 到 2262。 + +### 对象类型 + +- **object**:默认情况下,Elasticsearch 使用 object 数据类型来处理 JSON 对象。 +- **flattened**:这是用来索引对象数组或者具有未知结构的字段的特殊映射类型。其将整个JSON对象作为单个键值对存储,帮助降低索引大小和提高搜索速度。 +- **nested**:这是一个类似于 object 的数据类型,但它能保存并查询对象数组内部对象的独立性,因此可以用来处理更复杂的结构。 +- **join**:这是一个特殊数据类型,用于模拟在文档之间的父/子关系。这样可以创建一对多的连接,例如,在博客文章和评论这样的场景中使用。 + +### 空间数据类型 + +- **geo_point**:表示地理位置的点,存储纬度和经度信息。 +- **geo_shape**:表示复杂的地理形状,如多边形、线、圆等。 +- **point**:在笛卡尔空间中表示一个点,存储X和Y坐标。 +- **shape**:在笛卡尔空间中表示任意复杂的几何形状。 + +### 文档排名类型 + +- **dense_vector**:记录浮点值的密集向量。这种类型常用于存储机器学习模型的输出,例如词嵌入、句子嵌入等。 +- **rank_feature**:记录单个数值特征以优化排名。当这个字段被查询时,Elasticsearch 会考虑其值来重新排序搜索结果。 +- **rank_features**:记录多个数值特征以优化排名。与`rank_feature`类似,但它能够处理包含多个特征的对象。当这些字段被查询时,Elasticsearch 会考虑它们的值来重新排序搜索结果。 + +### 文本搜索类型 + +- **text**:用于存储全文和进行全文搜索的数据类型。 +- **annotated-text:**这是一个特殊的文本字段,它支持包含标记的文本。这些标记表示文本中的命名实体或其他重要项,可以在后续搜索中使用。 +- **completion** :这是一个专门为自动补全和搜索建议设计的数据类型。 +- **search_as_you_type:** 这是一种特殊的文本字段,它被优化以提供按键查询时的即时反馈,从而提高用户输入时的搜索体验。 +- **token_count**:这是一种数值型字段,用于存储文本字段中的词元数量。此字段常用于信息检索场景,比如评估某个字段的长度。 + +## 两种映射类型 + +### 自动映射:Dynamic Field Mapping + +Elasticsearch的Dynamic Field Mapping是一种自动产生index mapping的机制。在通常情况下,当一个新文档被索引到Elasticsearch中,如果其中包含了未在mapping中定义的字段,Elasticsearch就会尝试根据这个新字段的数据类型自动生成相应的mapping。 + +自动映射关系如下: + +| **field type** | **dynamic** | +| -------------- | ---------------------------------- | +| true/false | boolean | +| 小数 | float | +| 数字 | long | +| object | object | +| 数组 | 取决于数组中的第一个非空元素的类型 | +| 日期格式字符串 | date | +| 数字类型字符串 | float/long | +| 其他字符串 | text + keyword | + +除了上述字段类型之外,其他类型都必须显式映射,也就是必须手工指定,因为其他类型ES无法自动识别。 + +这里有几点需要注意: + +- 数据类型识别:Elasticsearch会按照以下顺序判断数据类型:长整数、浮点数、布尔值、日期、字符串(字符串可能会进一步映射为text或keyword)。 +- 字段名称含义:Elasticsearch不会考虑字段名称的含义,它仅仅依靠字段的数据类型来生成mapping。 +- 关闭动态映射:如果你不希望Elasticsearch自动创建mapping,可以将index的`dynamic`设置为`false`。 +- 动态模板:你可以使用动态模板来改变默认的mapping规则,例如,你可以将所有看起来像日期的字符串都映射为date类型。 +- 对象和嵌套字段:对于对象(object)和嵌套字段(nested),Elasticsearch也会递归地应用动态映射规则。 +- 更新映射:请注意,一旦字段的映射被创建,就不能再修改字段的数据类型了。因此,如果你要索引的文档中有新的字段,最好事先定义好mapping,避免让Elasticsearch自动映射可能产生不符合你期望的结果。 +- 当一个字段第一次出现时,Elasticsearch会使用先行数据类型来设置映射。如果后续数据类型与先前设置的映射类型不一致,Elasticsearch可能无法正确索引这些文档。 + +总的来说,虽然动态字段映射可以在某些情况下提供便利,但它也可能导致未预见的问题。因此,更推荐在开始索引文档之前就定义好mapping。 + +### 显式映射:Expllcit Field Mapping + +在 Elasticsearch 中,显式映射(Explicit Field Mapping)是指为索引预定义的字段类型和行为。当你创建一个索引时,你可以定义每个字段的数据类型、分词器或者其他相关的配置。这就是显式映射。 + +以下是一些主要的显式映射类型: + +- **核心数据类型**:包括 string(字符串)、integer(整型)、long(长整型)、double(双精度浮点型)、boolean(布尔型)等。 +- **复合数据类型**:包括 object(对象),用于单个 JSON 对象,nested,用于 JSON 数组。 +- **地理数据类型**:如 geo_point 和 geo_shape。 +- **专门用途的数据类型**:例如 IP、自动完成、token count、join types 等。 + +通过显式映射,Elasticsearch 可以更准确地解析和索引数据,对查询性能优化起到关键作用。如果不提供显式映射,Elasticsearch 将会根据输入数据自动推断并生成隐式映射,但可能无法达到最理想的效果。 + +以下是一个示例,展示了怎么设置一个简单的显式映射: + +```json +PUT my_index +{ + "mappings": { + "properties": { + "name": { "type": "text" }, + "age": { "type": "integer" } + } + } +} +``` + +上述代码中,我们在 `my_index` 索引中定义了两个字段的映射,`name` 字段类型为 `text`,`age` 字段类型为 `integer`。 + +注意:在 Elasticsearch 7.0 之后,映射类型被废弃,所有的映射参数直接放在 "properties" 下。 + +## 映射参数 + +在Elasticsearch中,映射参数是用于定义如何处理文档和其包含的字段的规则。 + +主要参数有下: + +- **index**:是否对当前字段创建倒排索引,默认 true,如果不创建索引,该字段不会通过索引被搜索到,但是仍然会在 source 元数据中展示。 +- **analyzer**:指定分析器(character filter、tokenizer、Token filters)。 +- **boost**:对当前字段相关度的评分权重,默认1。 +- **coerce**:是否允许强制类型转换,为 true的话 “1”能被转为 1, false则转不了。虽然这个参数可以帮助我们强制类型转换,但是它可能会在数据质量管理中引起问题。如果原始数据包含错误的类型,使用 "coerce" 可能会隐藏这些问题,而不是将其暴露出来。 +- **copy_to**:该参数允许将多个字段的值复制到组字段中,然后可以将其作为单个字段进行查询。 +- **doc_values**:为了提升排序和聚合效率,默认true,如果确定不需要对字段进行排序或聚合,也不需要通过脚本访问字段值,则可以禁用doc值以节省磁盘空间,对于text字段和annotated_text字段,无法禁用此选项,因为这些字段类型在默认情况下不使用doc values。 +- **dynamic**:控制是否可以动态添加新字段 + - **true** :新检测到的字段将添加到映射中(默认)。 + - **false** :新检测到的字段将被忽略。这些字段将不会被索引,因此将无法搜索,但仍会出现在_source返回的匹配项中。这些字段不会添加到映射中,必须显式添加新字段。 + - **strict** :如果检测到新字段,则会引发异常并拒绝文档。必须将新字段显式添加到映射。 +- **eager_global_ordinals**:用于聚合的字段上,优化聚合性能,但不适用于 Frozen indices。 + - **Frozen indices**(冻结索引):有些索引使用率很高,会被保存在内存中,有些使用率特别低,宁愿在使用的时候重新创建,在使用完毕后丢弃数据,Frozen indices 的数据命中频率小,不适用于高搜索负载,数据不会被保存在内存中,堆空间占用比普通索引少得多,Frozen indices是只读的,请求可能是秒级或者分钟级。 +- **enable**:是否创建倒排索引,可以对字段操作,也可以对索引操作,如果不创建索引,仍然可以检索并在_source元数据中展示,谨慎使用,该状态无法修改。**enable的作用和index类似,区别就是enable可以对全局进行设置**。例如: + +```JSON +PUT my_index +{ + "mappings": { + "enabled": false + } +} +``` + +- **fielddata**:查询时内存数据结构,在首次用当前字段聚合、排序或者在脚本中使用时,需要字段为fielddata数据结构,并且创建倒排索引保存到堆中。 +- **fields**:给field创建多字段,用于不同目的(全文检索或者聚合分析排序)。 +- **format**:格式化。例如: + +```JSON +"date": { + "type": "date", + "format": "yyyy-MM-dd" +} +``` + +- **ignore_above**:这是一个针对keyword类型字段的设置,对于超过指定长度的字符串,ES 不会对其建立索引。 +- **ignore_malformed**:忽略类型错误。 +- **index_options**:控制将哪些信息添加到反向索引中以进行搜索和突出显示。仅用于text字段。 +- **Index_phrases**:提升 exact_value 查询速度,但是要消耗更多磁盘空间。 +- **Index_prefixes**:前缀搜索。 + - **min_chars**:前缀最小长度> 0,默认 2(包含)。 + - **max_chars**:前缀最大长度< 20,默认 5(包含)。 +- **meta**:附加元数据。 +- **normalizer**:normalizer 参数用于解析前(索引或者查询时)的标准化配置。 +- **norms**:是否禁用评分(在 filter 和聚合字段上应该禁用)。 +- **null_value**:为 null 值设置默认值。 +- **position_increment_gap**:对于数组或者列表类型的字段,在进行phrase query或者phrase suggest时,允许用户自定义同一字段内两个相邻元素间的位置增量,默认100。 +- **properties**:除了mapping还可用于object的属性设置。 +- **search_analyzer**:设置单独的查询时分析器,如果定义了analyzer而没有定义search_analyzer,则search_analyzer的值默认会和analyzer保持一致,如果两个都没有定义,则默认是:"standard"。analyzer针对的是元数据,而search_analyzer针对的是传入的搜索词。 +- **similarity**:为字段设置相关度算法,和评分有关。支持BM25、classic(TF-IDF)、boolean。 +- **store**:设置字段是否仅查询。 +- **term_vector**:运维参数。这个参数可以设置存储哪些信息用于更复杂的文本处理,例如在词向量建模或者更复杂的文本检索场景中使用。 + +## Text & Keyword + +### Text + +当一个字段是要被全文检索时,比如 Email 内容、产品描述,这些字段应该使用 text 类型。设置 text 类型以后,字段内容会被分析,在生成倒排索引之前,字符串会被分析器分成一个个词项。text类型的字段不用于排序,很少用于聚合。 + +**注意事项** + +- 适用于全文检索:如 match 查询。 +- 文本字段会被分词。 +- 默认情况下,会创建倒排索引。 +- 自动映射器会为 Text 类型创建 Keyword 字段。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMvaqlT5NT0m2n3F5E1sljQH2OKwFiaibIolpkHJa0iazFkAmmpN3hVkpibWa6ahlO4oE8XdEn32o5gLA/640) + +### Keyword + +Keyword 类型适用于不分词的字段,如姓名、Id、数字等。如果数字类型不用于范围查找,用 Keyword 的性能要高于数值类型。 + +当使用 Keyword 类型查询时,其字段值会被作为一个整体,并保留字段值的原始属性。 + +```JSON +GET index/_search +{ + "query": { + "match": { + "title.keyword": "测试文本值" + } + } +} +``` + +注意事项 + +- Keyword 不会对文本分词,会保留字段的原有属性,包括大小写等。 +- Keyword 仅仅是字段类型,而不会对搜索词产生任何影响。 +- Keyword 一般用于需要精确查找的字段,或者聚合排序字段。 +- Keyword 通常和 Term 搜索一起用。 +- Keyword 字段的 `ignore_above` 参数代表其截断长度,默认 256,如果超出长度,字段值会被忽略,而不是截断,忽略指的是会忽略这个字段的索引,搜索不到,但数据还是存在的。 + +## 映射模板 + +之前讲过的映射类型或者映射参数,都是为确定的某个字段而声明的。 + +但是当我们不确定字段名字的时候该怎么设置mapping呢?映射模板就是用来解决这种场景的。 + +如果希望对符合某类要求的特定字段制定映射,就需要用到映射模板:**Dynamic templates**。映射模板有时也被称作:自动映射模板、动态模板等。 + +以下是一个示例: + +```json +{ + "mappings": { + "dynamic_templates": [ + { + "strings_as_keyword": { + "match_mapping_type": "string", + "mapping": { + "type": "keyword" + } + } + }, + { + "longs_as_integer": { + "match_mapping_type": "long", + "mapping": { + "type": "integer" + } + } + } + ] + } +} +``` + +在上述例子中,我们定义了两个模板:`strings_as_keyword` 和 `longs_as_integer`。当新字段被发现时,Elasticsearch 会检查这些模板以决定如何映射这个新字段。 + +- `strings_as_keyword` 模板将所有新的字符串类型字段映射为 `keyword` 类型。 +- `longs_as_integer` 模板将所有新的长整数(long)类型字段映射为 `integer` 类型。 + +注意:这些只是示例,实际的映射应该取决于实际数据和查询需求。例如,如果你需要对字符串字段进行全文搜索,那么将其映射为 `text` 可能更合适。 + +### 参数 + +- `match`:匹配字段名称。 +- `unmatch`:反匹配字段名称。 +- `match_mapping_type`:匹配字段类型,例如 string、long、double、boolean、date。 +- `match_pattern`:允许更复杂的名字模式,支持"starts_with"、"ends_with" 和 "contains"。 +- `path_match`:允许你用路径 (如 article.title) 来匹配字段。 +- `path_unmatch`:反匹配路径。 +- `mapping`:该字段被匹配时,应用的映射设置。 + +### 案例 + +```JSON +PUT test_dynamic_template + +{ + "mappings": { + "dynamic_templates": [{ + "integers": { + "match_mapping_type": "long", + "mapping": { + "type": "integer" + } + } + }, + { + "longs_as_strings": { + "match_mapping_type": "string", + "match": "num_*", + "unmatch": "*_text", + "mapping": { + "type": "keyword" + } + } + } + ] + } +} +``` + +以上代码会产生以下效果: + +- 所有 long 类型字段会默认映射为 integer。 +- 所有文本字段,如果是以 num_ 开头,并且不以 _text 结尾,会自动映射为 keyword 类型。 + diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Nested & Join.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Nested & Join.md" new file mode 100644 index 0000000..f2e16d7 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Nested & Join.md" @@ -0,0 +1,289 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +ES的 Nested 类型用于处理在一个文档中嵌套复杂的结构数据,而 Join 类型用于建立父子文档之间的关联关系。 + +## 嵌套类型:Nested + +Elasticsearch没有内部对象的概念,因此,ES在存储复杂类型的时候会把对象的复杂层次结果扁平化为一个键值对列表。 + +**比如**: + +```json +PUT my-index/_doc/1 +{ + "group" : "fans", + "user" : [ + { + "first" : "John", + "last" : "Smith" + }, + { + "first" : "Alice", + "last" : "White" + } + ] +} +``` + +上面的文档被创建之后,user数组中的每个json对象会以下面的形式存储 + +```json +{ + "group" : "fans", + "user.first" : [ "alice", "john" ], + "user.last" : [ "smith", "white" ] +} +``` + +`user.first`和 `user.last`字段被扁平化为多值字段,`first`和 `last`之间的关联丢失。 + +解决方法可以使用Nested类型,Nested属于object类型的一种,是Elasticsearch中用于复杂类型对象数组的索引操作,嵌套类型(Nested)允许在一个文档内部嵌套另一个文档,这使得可以在同一个文档中表示复杂的层次结构数据。 + +下面是关于如何定义和使用嵌套类型的示例: + +定义映射(Mapping): + +```json +PUT /my_index +{ + "mappings": { + "properties": { + "name": { + "type": "text" + }, + "comments": { + "type": "nested", + "properties": { + "user": { "type": "keyword" }, + "message": { "type": "text" } + } + } + } + } +} +``` + +在上述示例中,我们创建了一个名为 "my_index" 的索引,并定义了一个 "comments" 字段作为嵌套类型。嵌套类型包含两个属性: "user" 和 "message"。 + +输入数据(Indexing): + +```json +POST /my_index/_doc +{ + "name": "Product A", + "comments": [ + { + "user": "User 1", + "message": "Great product!" + }, + { + "user": "User 2", + "message": "Needs improvement." + } + ] +} +``` + +在上述示例中,我们向索引 "my_index" 中插入了一个文档,其中 "comments" 字段包含了两个嵌套文档。 + +查询数据(Querying): + +```json +GET /my_index/_search +{ + "query": { + "nested": { + "path": "comments", + "query": { + "bool": { + "must": [ + { "match": { "comments.user": "User 1" } }, + { "match": { "comments.message": "Great product!" } } + ] + } + } + } + } +} +``` + +在上述示例中,我们使用嵌套查询(nested query)来搜索包含特定评论的文档。我们指定了路径为 "comments",并在 `must` 子句中添加了匹配条件。 + +输出结果: + +```json +{ + "hits": { + "total": { + "value": 1, + "relation": "eq" + }, + "hits": [ + { + "_source": { + "name": "Product A", + "comments": [ + { + "user": "User 1", + "message": "Great product!" + } + ] + } + } + ] + } +} +``` + +在上述示例中,我们得到了一个匹配的文档,其中 "comments" 字段只包含了符合查询条件的嵌套文档。 + +### 参数 + +- `path`(必需):指定嵌套字段的路径。它告诉 Elasticsearch 在哪个字段上应用嵌套查询。 + +- `score_mode`(可选):指定如何计算嵌套文档的评分。 + + avg (默认):使用所有匹配的子对象的平均相关性得分。 + + max:使用所有匹配的子对象中的最高相关性得分。 + + min:使用所有匹配的子对象中最低的相关性得分。 + + none:不要使用匹配的子对象的相关性分数。该查询为父文档分配得分为0。 + + sum:将所有匹配的子对象的相关性得分相加。 + +- `inner_hits`(可选):允许获取与嵌套文档匹配的内部结果。使用此参数可以检索与查询匹配的特定嵌套文档,并返回有关它们的信息。 + +- `ignore_unmapped`(可选):如果设置为 `true`,则忽略没有嵌套字段映射的文档,并将其视为无匹配。默认情况下,设为 `false`。 + +- `nested`(可选):表示查询是否应该应用于嵌套字段的上下文。默认情况下,设为 `true`。如果设置为 `false`,则将查询视为普通的非嵌套查询。 + +- `score_mode`(可选):指定如何计算嵌套文档的评分。可选的值包括 `"none"`、`"avg"`、`"max"`、`"sum"` 和 `"min"`。默认情况下,使用 `"avg"`。 + +## 父子级关系:Join + +连接数据类型是一个特殊字段,它在同一索引的文档中创建父/子关系。关系部分在文档中定义了一组可能的关系,每个关系是一个父名和一个子名。 + +父/子关系可以定义如下: + +```json +PUT +{ + "mappings": { + "properties": { + "": { + "type": "join", + "relations": { + "": "" + } + } + } + } +} +``` + +常见的一个示例是创建一个索引来存储博客的数据。每个博客可以有多个评论,我们可以使用Join类型来建立博客和评论之间的父子关系。 + +首先,我们定义一个包含两个类型的索引:`blogs`和`comments`。`blogs`类型表示博客,而`comments`类型表示评论。我们将为`blogs`类型定义一个Join字段,用于与`comments`类型建立关联。 + +以下是一个简化的示例: + +创建索引并定义映射: + +```json +PUT my_index +{ + "mappings": { + "properties": { + "title": { + "type": "text" + }, + "join_field": { + "type": "join", + "relations": { + "blogs": "comments" + } + } + } + } +} +``` + +添加博客文档: + +```json +PUT my_index/_doc/1 +{ + "title": "Elasticsearch Join 示例", + "join_field": "blogs" +} +``` + +添加评论文档,并关联到博客: + +```json +PUT my_index/_doc/2?routing=1 +{ + "title": "很棒的博客", + "join_field": { + "name": "comments", + "parent": "1" + } +} +``` + +查询博客及其关联的评论: + +```json +GET my_index/_search +{ + "query": { + "has_child": { + "type": "comments", + "query": { + "match_all": {} + } + } + } +} +``` + +以上示例展示了如何使用Join类型在Elasticsearch中建立父子关系,并进行查询操作。实际使用时,可能需要根据自己的数据结构和查询需求进行适当的调整。 + +使用场景 + +**Join唯一合适应用场景是:当索引数据包含一对多的关系,并且其中一个实体的数量远远超过另一个的时候。比如:老师有 一万个学生** + +`join`类型不能像关系数据库中的表链接那样去用,不论是 `has_child`或者是 `has_parent`查询都会对索引的查询性能有严重的负面影响。并且会触发 global ordinals。 + +Global Ordinals是一种用于优化字段的查询性能的技术。在使用Join类型时,如果启用了Global Ordinals特性,它将为Join字段创建全局有序的编号,以支持快速的父子文档查询。 + +当你执行具有Join字段的查询时,ES会使用Global Ordinals来识别匹配的父文档,并快速定位到对应的子文档。这样可以避免对所有文档进行扫描和过滤的开销,提高查询的效率。 + +需要注意的是,启用Global Ordinals可能会增加索引的内存使用量和一些额外的计算开销。因此,在决定是否启用Global Ordinals时,需要权衡查询性能和资源消耗之间的平衡。 + +**注意** + +- 在索引父子级关系数据的时候必须传入routing参数,即指定把数据存入哪个分片,因为父文档和子文档必须在同一个分片上,因此,在获取、删除或更新子文档时需要提供相同的路由值。 +- 每个索引只允许有一个 `join`类型的字段映射。 +- 一个元素可以有多个子元素但只有一个父元素。 +- 可以向现有连接字段添加新关系。 +- 也可以向现有元素添加子元素,但前提是该元素已经是父元素。 + +### 参数 + +当使用Elasticsearch的Join类型进行查询时,以下是一些常用的参数和选项: + +- `has_parent`和`has_child`:这两个查询参数用于在父子文档之间执行查询。您可以指定要匹配的父文档或子文档的类型以及具体的查询条件。 +- `parent_id`:用于指定要查询的子文档的父文档ID。通过指定`parent_id`参数,您可以快速检索与特定父文档相关联的所有子文档。 +- `inner_hits`:内部命中参数允许您在查询结果中获取与父文档或子文档匹配的内部命中结果。您可以使用`inner_hits`来检索与查询条件匹配的子文档或匹配的父文档及其关联的子文档。 +- `ignore_unmapped`:当设置为true时,如果查询字段不存在映射或没有任何匹配的文档时,将忽略该查询并返回空结果。 +- `max_children`:可用于限制每个父文档返回的子文档数量。 + +这些只是一些常见的参数和选项,根据你的实际需求,还可以使用其他参数来进一步细化查询。请参考Elasticsearch官方文档以获取更详细的参数和用法信息。 \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Pipeline.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Pipeline.md" new file mode 100644 index 0000000..34e71d8 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Pipeline.md" @@ -0,0 +1,251 @@ +在现代的数据处理和分析场景中,数据不仅需要被存储和检索,还需要经过各种复杂的转换、处理和丰富,以满足业务需求和提高数据价值。 + +Elasticsearch Pipeline作为Elasticsearch中强大而灵活的功能之一,为用户提供了处理数据的机制,可以在数据索引之前或之后应用多种处理步骤,例如数据预处理、转换、清洗、分析等操作。 + +## 使用场景 + +Elasticsearch Pipeline 可以用于多种实际场景,其中包括但不限于: + +- 数据预处理:对原始数据进行清洗、标准化、去除噪声等操作,保证数据质量和一致性。 +- 数据转换:将数据转换为更加符合业务需求的形式,例如字段映射、格式转换、数据合并等。 +- 日志处理:实时日志数据的解析、提取关键信息、计算指标、数据聚合等操作。 +- 数据安全:对敏感数据进行脱敏处理、数据屏蔽、权限控制等操作,确保数据安全性。 + +## 具体使用 + +要实现Elasticsearch Pipeline功能,需要在节点上进行以下设置: + +1. **启用Ingest节点**:确保节点上已启用Ingest处理模块(默认情况下,每个节点都是Ingest Node),因为Pipeline是在Ingest处理阶段应用的。可以在elasticsearch.yml配置文件中添加以下设置来启用Ingest节点: + + ```yaml + node.ingest: true + ``` + +2. **配置Pipeline的最大值**:如果需要创建复杂的Pipeline或者包含大量处理步骤的Pipeline,可能需要调整默认的Pipeline容量限制。可以通过以下方式在elasticsearch.yml配置文件中设置Pipeline的最大值: + + ```yaml + ingest.max_pipelines: 1000 + ``` + +3. **检查内存和资源使用**:确保节点具有足够的内存和资源来支持Pipeline的运行,避免因为资源不足而导致Pipeline执行失败或性能下降。 + +对上述参数进行合理的配置后,就可以定义 Pipeline,并将其应用于索引文档了。 + +下面是一个简单的示例代码,演示如何创建和使用Pipeline: + +**创建Pipeline** + +```json +PUT _ingest/pipeline/my_pipeline +{ + "description" : "My custom pipeline", + "processors" : [ + { + "set": { + "field": "new_field", + "value": "example" + } + }, + { + "uppercase": { + "field": "message" + } + } + ] +} +``` + +上面的代码定义了一个名为 `my_pipeline` 的Pipeline,包含两个处理步骤: + +- `set` 处理器:将字段 `new_field` 设置为固定值 `example`。 +- `uppercase` 处理器:将字段 `message` 中的文本转换为大写。 + +一个Elasticsearch Pipeline通常由以下几个主要部分组成: + +- **描述(Description)**:Pipeline的描述部分包含对Pipeline的简要说明或注释,用于帮助其他人理解该Pipeline的作用和功能。 +- **处理器(Processors)**:Pipeline的核心是处理器,处理器定义了对文档进行的具体处理步骤。每个处理器都执行特定的操作,例如设置字段值、重命名字段、转换数据、条件判断等。处理器按照在Pipeline中的顺序依次执行,以完成对文档的处理。 +- **条件(Conditions)**:可选部分,条件定义了触发Pipeline应用的条件。只有当条件满足时,Pipeline才会被应用到相应的文档上。条件可以基于文档内容、字段值、索引信息等进行判断。 +- **内置变量(Built-in Variables)**:在处理器中可以使用一些内置变量来引用文档数据或上下文信息,并在处理过程中进行操作。例如,`_index`表示当前文档所属的索引名称,`_ingest.timestamp`表示处理器执行的时间戳等。 +- **标签(Tags)**:可选部分,为Pipeline添加标签,用于标识和分类不同类型的Pipeline。 + +这些部分共同构成了一个完整的Elasticsearch Pipeline,通过定义和配置这些部分,可以实现对文档数据的灵活处理和转换。 + +**应用Pipeline** + +一旦Pipeline被定义,可以在索引文档时指定应用该Pipeline: + +```json +POST my_index/_doc/1?pipeline=my_pipeline +{ + "message": "Hello, World!" +} +``` + +## 异常处理 + +在Elasticsearch Pipeline 中处理异常情况通常通过 `on_failure` 处理器来实现。下面是一个示例代码,演示如何使用 `on_failure` 处理器来处理异常情况: + +```json +PUT _ingest/pipeline/my_pipeline +{ + "description": "Pipeline with error handling", + "processors": [ + { + "set": { + "field": "new_field", + "value": "{{field_with_value}}" + } + }, + { + "on_failure": [ + { + "set": { + "field": "error_message", + "value": "{{_ingest.on_failure_message}}" + } + } + ] + } + ] +} +``` + +在上面的示例中,定义了一个名为 `my_pipeline` 的 Pipeline,其中包含两个处理器: + +- 第一个处理器使用 `set` 处理器来设置一个新的字段 `new_field` 的值为另一个字段 `field_with_value` 的值。 +- 第二个处理器是一个 `on_failure` 处理器,在前一个处理器执行失败时会被触发。这里使用 `on_failure_message` 变量来获取失败的原因,并将其设置到一个新的字段 `error_message` 中。 + +当第一个处理器执行失败时,第二个处理器会被触发,并将失败信息存储到 `error_message` 字段中,以便后续处理或记录日志。这样可以帮助我们更好地处理异常情况,确保数据处理的稳定性。 + +如果是Pipeline级别的错误,可以通过全局设置`on_failure`来处理整个Pipeline执行过程中的异常情况: + +```json +PUT _ingest/pipeline/my_pipeline +{ + "description": "Pipeline with global error handling", + "on_failure": [ + { + "set": { + "field": "error_message", + "value": "{{_ingest.on_failure_message}}" + } + } + ], + "processors": [ + { + "set": { + "field": "new_field", + "value": "{{field_with_value}}" + } + } + ] +} +``` + +在上述示例中,Pipeline `my_pipeline` 中定义了一个全局的`on_failure`处理器,在整个Pipeline执行过程中发生异常时会触发。当任何处理器执行失败时,全局`on_failure`处理器将被调用,并将失败消息存储到`error_message`字段中。 + +通过设置全局的`on_failure`处理器,可以统一处理整个Pipeline中任何处理器可能出现的异常情况,提高数据处理的稳定性和可靠性。这样即便是Pipeline级别的错误,也能得到有效的处理和记录,帮助排查问题并保证数据处理流程的正常运行。 + +## 为索引设置默认Pipeline + +从 Elasticsearch 6.5.x 开始,引入了一个名为 index.default_pipeline 的新索引设置。 这仅仅意味着所有摄取的文档都将由默认管道进行预处理: + +```json +PUT my_index +{ + "settings": { + "default_pipeline": "add_last_update_time" + } +} +``` + +## 内置Processors + +Elasticsearch内置的Processors提供了各种功能,用于在Ingest Pipeline中对文档进行处理。以下是一些常用的内置Processors及其作用: + +- **Set Processor**:设置字段的固定值或通过表达式计算值。 +- **Grok Processor**:解析文本字段并提取结构化数据。 +- **Date Processor**:解析日期字段。 +- **Convert Processor**:转换字段类型。 +- **Remove Processor**:删除指定字段。 +- **Split Processor**:根据分隔符拆分字段。 +- **GeoIP Processor**:根据IP地址查找地理位置信息。 +- **User Agent Processor**:解析User-Agent字段。 + +## Pipeline API + +,以下是有关Elasticsearch Pipeline API的简要介绍和示例代码: + +- **Put Pipeline API**:用于创建或更新Pipeline。 + + ```json + PUT /_ingest/pipeline/my_pipeline + { + "description": "My custom pipeline", + "processors": [ + { + "set": { + "field": "new_field", + "value": "default" + } + } + ] + } + ``` + +- **Get Pipeline API**:用于获取Pipeline的信息。 + + ```json + GET /_ingest/pipeline/my_pipeline + ``` + +- **Delete Pipeline API**:用于删除Pipeline。 + + ```json + DELETE /_ingest/pipeline/my_pipeline + ``` + +- **Simulate Pipeline API**:用于模拟Pipeline对文档的处理效果。 + + ```json + POST /_ingest/pipeline/_simulate + { + "pipeline": { + "processors": [ + { + "set": { + "field": "new_field", + "value": "default" + } + } + ] + }, + "docs": [ + { + "_source": { + "my_field": "my_value" + } + } + ] + } + ``` + +- **Manage Pipelines in Index Templates**:可以在索引模板中定义Pipeline。 + + ```json + PUT /_index_template/my_template + { + "index_patterns": ["my_index*"], + "composed_of": ["my_pipeline"], + "priority": 1 + } + ``` + +## 使用建议 + +在使用Elasticsearch Pipeline时,有几点建议可以帮助提高效率和准确性: + +- **测试和验证**:在应用Pipeline之前,务必进行充分的测试和验证,确保处理步骤的准确性和稳定性。 +- **监控和调优**:定期监控Pipeline的性能和效果,根据实际情况进行调优和优化,以提高数据处理和索引效率。 +- **复用Pipeline**:针对相似的数据处理需求,可以设计通用的Pipeline,以便在多个索引中重复使用,提高代码复用性和维护性。 +- **合理使用条件**:根据具体需求选择合适的条件触发Pipeline的应用,避免不必要的处理过程,提高系统性能。 diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Query DSL.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Query DSL.md" new file mode 100644 index 0000000..f8972f1 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-Query DSL.md" @@ -0,0 +1,556 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +DSL是Domain Specific Language的缩写,指的是为特定问题领域设计的计算机语言。这种语言专注于某特定领域的问题解决,因而比通用编程语言更有效率。 + +在Elasticsearch中,DSL指的是Elasticsearch Query DSL,是一种以JSON形式表示的查询语言。通过这种语言,用户可以构建复杂的查询、排序和过滤数据等操作。这些查询可以是全文搜索、聚合搜索,也可以是结构化的搜索。 + +## 查询上下文 + +搜索是Elasticsearch中最关键和重要的部分,使用`query`关键字进行检索,更倾向于相关度搜索,故需要计算评分。 + +在查询上下文中,一个查询语句表示一个文档和查询语句的匹配程度。无论文档匹配与否,查询语句总能计算出一个相关性分数在`_score`字段上。 + +### 相关度评分:score + +相关度评分用于对搜索结果排序,评分越高则认为其结果和搜索的预期值相关度越高,即越符合搜索预期值,默认情况下评分越高,则结果越靠前。在7.x之前相关度评分默认使用TF/IDF算法计算而来,7.x之后默认为BM25。 + +score是根据各种因素计算出来的,包括: + +- **Term Frequency(词频)**:一个词在文档中出现的次数越多,score就越高。 +- **Inverse Document Frequency(逆文档频率**):一个词在所有文档中出现的次数越少,score就越高。 +- **Field Length Norm(字段长度规范**):字段的长度越短,score就越高。 + +这三个因素共同决定了score的值。然而,你也可以通过设置自定义评分或者禁用评分来影响score的计算。 + +#### TF/IDF & BM25 + +TF/IDF是一种在信息检索和文本挖掘中广泛使用的统计方法,用于评估一个词语对于一个文件集或一个语料库中的一个文件的重要程度。名称中的TF表示“术语频率”,IDF表示“逆向文件频率”。 + +- **TF (Term Frequency)** :这是衡量词在文档中出现的频率。通常来说,一个词在文档中出现的次数越多,其重要性就可能越大。但这并不总是正确的,比如在很多英文文档中,“the”、“and”等词出现的频率非常高,但我们并不能因此认为它们就非常重要。因此,需要结合 IDF 来使用。 +- **IDF (Inverse Document Frequency)** :这是衡量词是否常见的度量。如果某个词在许多文档中都出现,那么它可能并不具有区分性,对于搜索和分类的帮助就不大。例如,每篇英文文章中都会出现的“the”对于区分文章内容就没有什么帮助。所以,如果一个词在所有文档中出现得越多,那么其 IDF 值就会越小,相反,如果一个词很少在文档中出现,那么其 IDF 值就会较大。 + +TF-IDF 会将这两个因子结合起来,为每个词产生一个权重。具有较高 TF-IDF 分数的词被认为在文档中更重要。通过这种方式,ES 能够提供相关性排序,使得包含用户查询词汇的最相关文档排在搜索结果的前面。 + +BM25是一种更先进的排名函数,也是基于TF/IDF的一种改进型方法。它引入了两个新概念: + +- **文档长度归一化**:长文档可能会有更多的关键词,但这并不意味着它与查询更相关。BM25通过调整文档长度来解决这个问题。 +- **饱和度**:在TF/IDF中,词项的出现频率越高,其重要性就越大。然而在实践中,一旦一个词在文档中出现过,再次出现时增加的相关性可能会降低。BM25通过设置一个饱和点来解决这个问题,超过这个点,词的权重增加就会变得不那么敏感。 + +总结而言,BM25是TF/IDF的改进版,通过文档长度归一化和频率饱和度控制来优化搜索结果。 + +### 源数据:source + +`_source`字段包含索引时原始的JSON文档内容,字段本身不建立索引(因此无法进行搜索),但是会被存储,所以当执行获取请求是可以返回`_source`字段。 + +虽然很方便,但是`_source`字段的确会对索引产生存储开销,你可以通过关闭`_source`字段来节省空间,但这通常不建议,因为有了原始数据,我们可以对数据进行重新索引,并且在获取数据时也更加灵活。 + +如果你禁用了`_source`字段,那么会有以下几个影响: + +- **无法获取原始数据**:当你查询某个文档时,你将无法获取到原始的`_source`字段内容,因为它没有被存储在Elasticsearch中。 +- **更新和重新索引的问题**:如果你想更新文档或者执行重新索引操作,可能会遇到问题,因为这两种操作都需要原始的`_source`字段。 +- **脚本字段和某些Aggregations可能受到影响**:如果你正在使用脚本字段或者依赖`_source`字段的Aggregations,那么禁用`_source`可能导致这些特性出问题。 + +下面是一些使用`_source`字段的例子: + + + +1. 在索引文档时启用/禁用`_source`: + +```json +PUT my_index +{ + "mappings": { + "_source": { + "enabled": false + }, + "properties": { + "field1": { "type": "text" } + } + } +} +``` + +在这个例子中,新创建的`my_index`索引将不会存储`_source`字段。 + + + +2. 获取文档的`_source`字段: + +```json +GET /my_index/_doc/1 +``` + +返回的结果中会包含`_source`字段。 + + + +3. 在获取文档时只获取`_source`字段中特定的字段: + +```json +GET /my_index/_doc/1?_source=field1,field2 +``` + +在这个例子中,返回的`_source`字段只包含`field1`和`field2`。 + + + +注意:`_source`字段并不用于搜索,禁用`_source`字段不会影响你的搜索结果。 + +## 源数据过滤 + +假设你的应用只需要获取部分字段(如"name"和"price"),而其他字段(如"desc"和"tags")不经常使用或者数据量较大,导致传输和处理这些额外的数据会增加网络开销和处理时间。在这种情况下,通过设置`includes`和`excludes`可以有效地减少每次请求返回的数据量,提高效率。 + +例如: + +```JSON +PUT product +{ + "mappings": { + "_source": { + "includes": ["name", "price"], + "excludes": ["desc", "tags"] + } + } +} +``` + +**Including**:结果中返回哪些field。 + +**Excluding**:结果中不要返回哪些field,Excluding优先级比Including更高。 + +> 需要注意的是,尽管这些设置会影响搜索结果中_source字段的内容,但并不会改变实际存储在Elasticsearch中的数据。也就是说,"desc"和"tags"字段仍然会被索引和存储,只是在获取源数据时不会被返回。 + +上述这种在mapping中定义的方式不推荐,因为mapping不可变。我们可以在查询过程中指定返回的字段,如下: + +```JSON +GET product/_search +{ + "_source": { + "includes": ["owner.*", "name"], + "excludes": ["name", "desc", "price"] + }, + "query": { + "match_all": {} + } +} +``` + +Elasticsearch的`_source`字段在查询时支持使用通配符(wildcards)来包含或排除特定字段。使得能够更灵活地操纵返回的数据。 + +关于规则,可以参考以下几点: + +- *:匹配任意字符序列,包括空序列。 +- ?:匹配任意单个字符。 +- [abc]: 匹配方括号内列出的任意单个字符。例如,[abc]将匹配"a", "b", 或 "c"。 + +请注意,通配符表达式可能会导致查询性能下降,特别是在大型索引中,因此应谨慎使用。 + +## 全文检索 + +全文检索是Elasticsearch的核心功能之一,它可以高效地在大量文本数据中寻找特定关键词。 + +在Elasticsearch中,全文检索主要依靠两个步骤:"分析"(Analysis)和"查询"(Search)。 + +- **分析**: 当你向Elasticsearch插入一个文档时,会进行"分析"处理,将原始文本数据转换成称为"tokens"或"terms"的小片段。这个过程可能包括如下操作: + - 切分文本(Tokenization) + - 将所有字符转换为小写(Lowercasing) + - 删除常见但无重要含义的单词(Stopwords) + - 提取词根(Stemming) +- **查询**:当执行全文搜索时,查询字符串也会经过类似的分析过程,然后再与已经分析过的数据进行比对,找出匹配的结果并返回。 + +Elasticsearch提供了许多种全文搜索的查询类型,例如: + +- **Match Query**:最基本的全文搜索查询。 +- **Match Phrase Query**:用于查找包含特定短语的文档。 +- **Multi-Match Query**:类似Match Query,但可以在多个字段上进行搜索。 +- **Query String Query**:提供了丰富的搜索语法,可以执行复杂的、灵活的全文搜索。 + +### match:匹配包含某个term的子句 + +`match` 查询是 Elasticsearch 中的一种全文查询方式,它包括标准分析和词项搜索。尽管它可以应用于精确字段,但其主要用途是进行全文搜索。当与全文字段一起使用时,match 查询可以解析查询字符串,并执行短语查询或者构建一个布尔查询,这意味着它会考虑字段中的每个单词。 + +下面有一个简单的 `match` 查询示例: + +```json +GET /_search +{ + "query": { + "match": { + "message": "this is a test" + } + } +} +``` + +在这个示例中,Elasticsearch 会在 "message" 字段中搜索包含 "this"、"is"、"a" 和 "test" 的文档。 + +请注意,`match` 查询不仅仅会匹配完全相同的短语,它还可以处理更复杂的情况,如多个单词(它会匹配任何一个)、误拼、同义词等,这主要取决于你所使用的分析器和搜索设置。 + +`match` 查询还有一些其他参数,例如: + +- **operator**:定义多个搜索词之间的关系,默认为 `or`。如果设为 `and`,则返回的文档必须包含所有搜索词。 +- **minimum_should_match**:控制返回的文档应至少匹配的搜索词的数量或比例。 +- **fuzziness**:允许模糊匹配,可以找到那些拼写错误或接近的词汇。 + +### match_all:匹配所有结果的子句 + +`match_all`是Elasticsearch中的一个查询类型,用于获取索引中的所有文档。 + +这是一个`match_all`查询的基本示例: + +```json +{ + "query": { + "match_all": {} + } +} +``` + +在上述示例中,我们可以看到查询对象中存在一个"match_all"字段,其值是一个空对象。这表示我们希望匹配所有文档。 + +需要注意,由于 `match_all` 查询可能返回大量的数据,所以一般在使用时都会与分页(pagination)功能结合起来,这样可以控制返回结果的数量,避免一次性加载过多数据导致的性能问题。例如,你可以使用 `from` 和 `size` 参数来限制返回结果: + +```JSON +GET /_search +{ + "query": { + "match_all": {} + }, + "from": 10, + "size": 10 +} +``` + +Elasticsearch的 `match_all` 查询是最简单的查询,它不需要任何参数,但如果你想为它添加权重,可以使用 `boost` 参数。例如: + +```json +GET /_search +{ + "query": { + "match_all": { "boost" : 1.2 } + } +} +``` + +在上面的查询中,`boost` 参数被设置为1.2,给匹配到的所有文档增加了额外的相关性得分提升。 + +### multi_match:多字段条件 + +`multi_match` 可以用来在多个字段上进行全文搜索。它接受一个查询字符串和一组需要在其中执行查询的字段列表。 + +例如: + +```json +{ + "query": { + "multi_match" : { + "query": "这是测试", + "fields": [ "field1", "field2" ] + } + } +} +``` + +在此示例中,查询字符串"这是测试"将在字段"field1"和"field2"中搜索。 + +`multi_match`查询也支持使用通配符(*)来匹配多个字段: + +```json +{ + "query": { + "multi_match" : { + "query": "这是测试", + "fields": [ "*_name" ] + } + } +} +``` + +在这个例子中,会在所有以"_name"结尾的字段中进行搜索。 + +此外,`multi_match` 查询还支持许多参数,包括: + +- **type**:设置查询类型,可选值包括:`best_fields`, `most_fields`, `cross_fields`, `phrase`, `phrase_prefix` 等。 + +例如,“best_fields” 类型会从指定的字段中挑选分数最高的匹配结果计算最终得分,而“most_fields” 类型则会在每个字段中都寻找匹配项并将其分数累加起来。 + +- **tie_breaker**:当一个词在多个字段中找到时,用于决定最终得分的参数。 +- **minimum_should_match**:用于控制应匹配的最小子句数。 +- **operator**:主要有两个操作符 `OR` 和 `AND`,默认为 `OR`。 + +需要注意的是,当使用 `multi_match` 查询时,如果字段不同,其权重可能也会不同。你可以通过在字段名后面添加尖括号(^)和权重值来调整特定字段的权重。例如,`"fields": [ "name^3", "description" ]`表示在"name"字段中的匹配结果权重是"description"字段的三倍。 + +### match_phrase:短语查询 + +`match_phrase` 用于精确匹配包含指定短语的文档。match_phrase 查询需要字段值中的单词顺序与查询字符串中的单词顺序完全一致。 + +例如: + +```JSON +GET /_search +{ + "query": { + "match_phrase": { + "message": "this is a test" + } + } +} +``` + +这个查询将会找到"message"字段中包含完整短语"this is a test"的所有文档。 + +此外,`match_phrase` 查询还有一个 `slop` 参数,可以定义词组中的词语可能存在的位置偏移量。例如,如果将 `slop` 设置为 1,则查询 "this is a test" 也可匹配 "this is test a",因为 "a" 和 "test" 只需移动一个位置即可匹配。 + +```json +GET /_search +{ + "query": { + "match_phrase": { + "query": "this is a test", + "slop": 2 + } + } +} +``` + +请注意,`match_phrase` 查询需要整个短语完全匹配,而不仅仅是查询中的所有单词都存在。如果你只是希望所有单词都存在,而不关心它们的顺序或精确出现方式,那么你应该使用 `match` 查询。 + +## Term Query + +精确查询用于查找包含指定精确值的文档,而不是执行全文搜索。 + +### term:匹配和搜索词项完全相等的结果 + +`term` 查询主要用于查询某个字段完全匹配给定值的文档。这对精确匹配非常有效,例如数字、布尔值或者字符串。 + +用法示例: + +```JSON +GET /_search +{ + "query": { + "term" : { "user" : "Kimchy" } + } +} +``` + +在这个例子中,我们正在搜索"user"字段中完全匹配"Kimchy"的文档。 + +需要注意的是,`term` 查询对于分析过的字段(例如,文本字段)可能不会像你预期的那样工作,因为它会搜索精确的词汇项,而不是单词。如果你想要对文本字段进行全文搜素,应该使用 `match` 查询。 + +另外一个需要注意的点就是 `term` 查询对大小写敏感,所以 "Kimchy" 和 "kimchy" 是两个不同的词条。 + +#### term和match_phrase的区别 + +`term` 查询和 `match_phrase` 查询是 Elasticsearch 提供的两种查询方式,它们都用于查找文档,但主要的区别在于如何解析查询字符串以及匹配的精确度。 + +- **term**:这个查询做的是精确匹配。当你使用`term`查询时,Elasticsearch会查找完全等于你指定的词汇的文档。例如,如果你搜索`term` "apple",那么只有包含完全为"apple"的文档会被匹配到,而包含"apples"或"APPLE"的文档则不会被匹配到。因此,`term`查询对大小写敏感,且不会进行任何形式的分析(如停用词移除、词干提取等)。 +- **match_phrase**:这个查询是用来匹配一系列词汇或者短语的。`match_phrase`查询会保证你查询的词汇必须以你提供的顺序完全匹配。比如,如果你使用`match_phrase`查询 "quick brown fox",那么只有包含这个完整短语的文档才会被匹配到,单独包含"quick"、"brown"或者"fox"的文档则不会被匹配到。此外,与`term`查询不同,`match_phrase`查询会进行文本分析,这意味着它会考虑词汇的大小写、复数形式等。 + +总结来说,`term`查询更适合精确匹配,而`match_phrase`查询更适合短语匹配。但是,`match_phrase`并不能100%保证精确匹配,因为它会处理和考虑文本的各种变体(比如,大小写、单复数形式等)。 + +### terms:匹配和搜索词项列表中任意项匹配的结果 + +`terms` 查询用于匹配指定字段中包含一个或多个值的文档。这是一个精确匹配查询,不会像全文查询那样对查询字符串进行分析。 + +假设你有一个 "user" 的字段,并且你想找到该字段值为 "John" 或者 "Jane" 的所有文档,你可以使用 `terms` 查询: + +```JSON +GET /_search +{ + "query": { + "terms" : { + "user" : ["John", "Jane"], + "boost" : 1.0 + } + } +} +``` + +上面的查询将返回所有"user" 字段等于 "John" 或者 "Jane" 的文档。 + +其中`boost` 参数用于增加或减少特定查询的相对权重。它将改变查询结果的相关性分数(_score),以影响最终结果的排名。 + +例如,在上述 `terms` 查询中,`boost` 参数被设置为 1.0。这意味着如果字段 "user" 的值包含 "John" 或 "Jane",那么其相关性分数(_score)就会乘以 1.0。因此,这个设置实际上并没有改变任何东西,因为乘以 1 不会改变原始分数。但是,如果你将 `boost` 参数设置为大于 1 的数,那么匹配的文档的 _score 将会提高,反之则会降低。 + +## Range:范围查找 + +Range查询允许我们查找某个范围内的值。假设我们有一个商品表,其中有商品价格字段,我们可以用range查询来查找价格在一定范围内的商品。 + +以下是一个基础的范围查询的例子: + +```json +GET /products/_search +{ + "query": { + "range" : { + "price" : { + "gte" : 10, + "lte" : 20, + "boost" : 2.0 + } + } + } +} +``` + +在这个例子中,我们正在查询价格大于或等于(gte)10且小于或等于(lte)20的所有商品。"boost"参数表示增加该查询的重要性。 + +Range查询支持以下参数: + +- **gte**:大于或等于。 +- **lte**:小于或等于。 +- **gt**:大于。 +- **lt**:小于。 +- **boost**:增加查询的重要性。 + +此外,对于日期类型的字段,你还可以使用如下方式进行范围查询: + +```json +{ + "query": { + "range" : { + "timestamp" : { + "gte" : "now-1d/d", + "lt" : "now/d" + } + } + } +} +``` + +在上述查询中,我们正在查找过去24小时内的数据。"now-1d/d"表示从现在算起的一天前,而"now/d"表示当前时间。 + +## Filter + +过滤器(Filter)是用于筛选数据的一种工具。过滤器和查询(query)相似,但有几个重要的区别: + +- **过滤不关心文档的相关度得分(relevance score)**:查询会为每个匹配的文档计算一个相关度得分,以决定返回结果的排序。相比之下,过滤器只关心文档是否匹配 - 没有“部分匹配”,只有“匹配”或“不匹配”。 +- **过滤器可以被缓存**:由于过滤器不需要计算得分,因此它们的结果可以被缓存起来用于之后的搜索请求,这可以大大提高性能。 + +常见的过滤器类型包括:`term`、`terms`、`range`、`bool`、`match_all` 等。例如,范围过滤器 `range` 可以用于查找数字或日期字段在指定范围内的文档;布尔过滤器 `bool` 则允许你组合多个过滤器,并定义它们如何互相交互。 + +使用过滤器时,通常会把它们放在 `bool` 查询的 `filter` 子句中。例如: + +```json +{ + "query": { + "bool": { + "filter": [ + { "term": { "status": "active" }}, + { "range": { "age": { "gte": 30, "lte": 40 }}} + ] + } + } +} +``` + +这个查询会返回所有“状态为 active 并且年龄在 30 到 40 之间”的文档,而不会考虑它们的相关度得分。 + +### Filter缓存机制 + +在 Elasticsearch 中,过滤查询结果的缓存机制是非常重要的一个性能优化手段。由于过滤器(filter)只关心是否匹配,而不关心评分 (_score),因此它们的结果可以被缓存以提高性能。 + +每次 filter 查询执行时,Elasticsearch 都会生成一个名为 "bitset" 的数据结构,其中每个文档都对应一个位(0 或 1),表示这个文档是否与 filter 匹配。这个 bitset 就是被存储在缓存中的部分。 + +如果相同的 filter 查询再次执行,Elasticsearch 可以直接从缓存中获取这个 bitset,而不需要再次遍历所有的文档来找出哪些文档符合这个 filter。这大大提高了查询速度,并减少了 CPU 使用。 + +这种缓存策略特别适合那些重复查询的场景,例如用户界面的过滤器和类似的功能,因为他们通常会产生很多相同的 filter 查询。 + +然而,值得注意的是,虽然这种缓存可以显著改善查询性能,但也会占用内存空间。如果你有很多唯一的过滤条件,那么过滤器缓存可能会变得很大,从而导致内存问题。这就需要你对使用的过滤器进行适当的管理和限制。 + +Filter缓存功能会遵循以下原则: + +- **同一Filter的多次应用**:如果在后续查询中有多次使用相同的Filter,则ES会把第一次查询的结果储存在缓存中,后续的查询将直接从缓存中获取结果,而不再做任何磁盘I/O或者其他计算。 +- **根据需求清理缓存**:ES会根据内存使用情况自动清理缓存,当然你也可以手动清空缓存。但这并不意味着我们无限制地依赖Filter缓存,大量的缓存可能导致更重的GC压力。 +- **不缓存复杂查询**:一些查询条件较复杂的过滤器可能不会被缓存,比如script filter、geo filter等。这是因为这些过滤器本身的构建和维护成本可能就超过了查询的计算成本。 + +ES的Filter缓存机制可以大大提高查询效率,但如果不慎用,比如缓存过多或者不适合缓存的查询,可能会对性能产生负面影响。因此,在设计和优化ES查询时,应当充分考虑Filter的使用和缓存策略。 + +## Bool Query + +Bool Query(组合查询)可以组合多个查询条件,bool查询也是采用more_matches_is_better的机制,因此满足must和should子句的文档将会合并起来计算分值。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOyKiabe9ph3M760B405ANeTQKKL6ArXcTAUI1g5KV80ib0zNicy5L2usIJuPtDAGvsVa5ibs5Sch5GZw/640) + +`boost`和`minumum_should_match`是参数,其他四个都是查询子句。 + +- **must**:必须满足子句(查询)必须出现在匹配的文档中,并将有助于得分。 +- **filter**:过滤器不计算相关度分数。 +- **should**:满足 or子句(查询)应出现在匹配的文档中。 +- **must_not**:必须不满足,不计算相关度分数 ,not子句(查询)不得出现在匹配的文档中。子句在过滤器上下文中执行,这意味着计分被忽略,并且子句被视为用于缓存。 + +例子1:下面的语句表示:包含"xiaomi"或"phone" 并且包含"shouji"的文档例子: + +```JSON +GET product/_search +{ + "query": { + "bool": { + "must": [ + { + "match": { + "name": "xiaomi phone" + } + }, + { + "match_phrase": { + "desc": "shouji" + } + } + ] + } + } +} +``` + +### should与must或filter一起使用 + +当 `should` 子句与 `must` 或 `filter` 子句一起使用时,这时候需要注意了。 + +只要满足了 `must` 或 `filter` 的条件,`should` 子句就不再是必须的。换句话说,如果存在一个或者多个 `must` 或 `filter` 子句,那么 `should` 子句的条件会被视为可选。 + +然而,如果 `should` 子句与 `must_not` 子句单独使用(也就是没有 `must` 或 `filter`),则至少需要满足一个 `should` 子句的条件。 + +这里有一个例子来说明: + +```JSON +GET /_search +{ + "query": { + "bool": { + "must": [ + { "term": { "user": "kimchy" }} + ], + "filter": [ + { "term": { "tag": "tech" }} + ], + "should": [ + { "term": { "tag": "wow" }}, + { "term": { "tag": "elasticsearch" }} + ] + } + } +} +``` + +在这个查询中,`must` 和 `filter` 子句的条件是必须满足的,而 `should` 子句的条件则是可选的。如果匹配的文档同时满足 `should` 子句的条件,那么它们的得分将会更高。 + +那如果我们一起使用的时候想让should满足该怎么办?这时候`minimum_should_match` 参数就派上用场了。 + +### minimum_should_match + +`minimum_should_match`参数定义了在 `should` 子句中至少需要满足多少条件。 + +例如,如果你有5个 `should` 子句并且设置了 `"minimum_should_match": 3`,那么任何匹配至少三个 `should` 子句的文档都会被返回。 + +这个参数可以接收绝对数值(如 `2`)、百分比(如 `30%`)、和组合(如 `3<90%` 表示至少匹配3个或者90%,取其中较大的那个)等不同类型的值。 + +注意:如果 `bool` 查询中只有 `should` 子句(没有 `must` 或 `filter`),那么默认情况下至少需要匹配一个 `should` 条件,也就是`minimum_should_match`默认值是1,除非 `minimum_should_match` 明确设定为其他值。如果包含 `must` 或 `filter`的情况下`minimum_should_match`默认值 0。 + +所以我们可以在包含`must` 或 `filter`的情况下,设置`minimum_should_match`值来满足`should`子句中的条件。 \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\206\231\345\205\245\345\216\237\347\220\206.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\206\231\345\205\245\345\216\237\347\220\206.md" new file mode 100644 index 0000000..ce47872 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\206\231\345\205\245\345\216\237\347\220\206.md" @@ -0,0 +1,178 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +ES作为一款开源的分布式搜索和分析引擎,以其卓越的性能和灵活的扩展性而备受青睐。 + +在实际应用中,如何最大限度地发挥ES的写入能力并保证数据的一致性和可靠性仍然是一个值得关注的话题。 + +接下来,我们将深入了解ES的写入过程和原理。 + +## 写入过程 + +### 写操作 + +ES支持四种对文档的数据写操作: + +- **create**:如果在PUT数据的时候当前数据已经存在,则数据会被覆盖。如果在PUT的时候加上操作类型create,此时如果数据已存在,则会返回失败,因为已经强制指定了操作类型为create,ES就不会再去执行update操作。 +- **delete**:删除文档,ES对文档的删除是懒删除机制,即标记删除,会被记录在 `.del`文件中。在后续的合并(merge)过程中,Elasticsearch会根据一定的条件和策略,将包含已删除文档的分段进行合并。在合并期间,`.del` 文件中的已删除文档将被完全删除,从而释放磁盘空间。 +- **index**:在ES中,写入操作被称为Index,这里Index为动词,即索引数据,为数据创建在ES中的索引。 +- **update**:执行partial update(全量更新,部分更新)。PUT是全量更新,POST是部分更新。 + +### 写流程 + +**ES中的数据写入均发生在 Primary Shard,当数据在 Primary Shard 写入完成之后会同步到相应的 Replica Shard** + +下图演示了单条数据写入ES的流程: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPiaOFniaChcYseAhZC62kjia9ewRsibTJY7wpld97DTibnVQqIjcd3ibVF4kfzs3Uib9kY91FFAwboia0QBQ/640) + +以下为数据写入的步骤: + +1. 客户端发起写入请求至node-4。 +2. node-4通过文档 id 在路由表中的映射信息确定当前数据的位置在分片0,分片0的主分片位于node-5,并将数据转发至node-5。 +3. 数据在node-5写入,写入成功之后将数据的同步请求转发至其副本所在的node-4和node-6上面,等待所有副本数据写入成功之后,node-5将结果报告node-4,并由node-4将结果返回给客户端,报告数据写入成功。 + +在这个过程中,接收用户请求的节点是不固定的,上述例子中,node-4 发挥了协调节点和客户端节点的作用,将数据转发至对应节点和接收以及返回用户请求。 + +数据在由 node-4 转发至 node-5的时候,是通过以下公式来计算指定的文档具体在哪个分片的: + +```json +shard_num = hash(_routing) % num_primary_shards +``` + +其中,`_routing` 的默认值就是文档的 id。 + +### 写一致性策略 + +ES 5.x 之后,一致性策略由 `wait_for_active_shards` 参数控制。 + + `wait_for_active_shards` 即确定客户端返回数据之前必须处于 active 的分片数(包括主分片和副本),默认值为1,即只需要主分片写入成功。 + +最大值为 `number_of_replicas + 1` ,可以设置为 `all`或任何正整数。如果当前 active 状态的分片没有达到设定阈值,写操作必须等待并且重试,默认等待时间30秒,直到 active 状态的副本数量超过设定的阈值或者超时返回失败为止。 + +假设我们有一个由A、B、C三个节点组成的集群。并且我们创建了一个index副本数设置为3的索引(此时共4个分片数据,比节点数多一个)。 + +如果我们尝试索引操作,默认情况下,该操作只会确保每个主分片的主副本在继续之前可用。这意味着即使B和c出现故障被A托管主分片,索引操作仍将仅使用数据的一个副本进行。 + +如果`wait_for_active_shards`设置为3(并且所有3个节点都已启动),那么索引操作将需要3个活动分片才能继续进行,因为集群中有3个活动节点,每个节点都持有一个活跃,所以满足这一要求。 + +但是,如果我们设置`wait_for_active_shards`为all(或设置为4),数据写入将直接失败,因为集群此时根本不可能有四个活跃的分片。 + +除非在集群中启动一个新节点来托管分片的第四个分片,否则该操作将超时。 + +## 写入原理 + +### Refresh + +ES中数据并不是直接写到文件系统缓存里的,在内部ES开辟了名为:`Memory Buffer`的缓存区。 + +Memory Buffer的性能非常高,客户端发出写入请求的时候是直接写在Memory Buffer里的。 + +**Memory Buffer的空间阈值默认大小为堆内存的10%,时间阈值为1s。空间阈值和时间阈值只要达成任意一个,就会触发 Refresh操作** + +也可以调用 Refresh API 来人工触发 Refresh 操作: + +```json +POST /_refresh + +GET /_refresh + +POST /_refresh + +GET /_refresh +``` + +Refresh 操作会将缓存中的数据生成一个个 segment 文件。 + +原理见下图: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPiaOFniaChcYseAhZC62kjia9UoGZXzloHosNG1EaFd7OqnE486LJldFSq1KIcMuMmETrWqCfXSfxvA/640) + +内存索引缓冲区中的文档被写入新段,新段首先写入文件系统缓存(这个过程性能消耗很低),然后才刷新到磁盘(这个过程代价很高)。但是,在文件进入缓存后,它就可以像任何其他文件一样被打开和读取。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPiaOFniaChcYseAhZC62kjia9y9dGZIaljYOwrFj96TbuSadJOReyUJozmseWD89o5SYaialG0LlvxLA/640) + +Lucene 允许写入和打开新的段,使它们包含的文档对搜索可见,而无需执行完整的提交。这是一个比提交到磁盘更轻松的过程,并且可以经常执行而不会降低性能。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPiaOFniaChcYseAhZC62kjia9rQrA4KXly557nCuHwfeaqKia2jUUUb9ggZ6w2dfhLgb6tr1ia4CIeC4Q/640) + +这个写入和打开新段的过程即被称为 **Refresh** 。刷新使自上次刷新以来对索引执行的所有操作都可用于搜索。 + +`index.refresh_interval`参数可以设置多久执行一次刷新操作,默认为 `1s`,可以设置 `-1` 禁用刷新。 + +并不是所有的情况都需要每秒刷新。比如 Elasticsearch 索引大量的日志文件,此时并不需要太高的写入实时性, 可以增大刷新间隔来降低每个索引的刷新频率,从而降低因为实时性而带来的性能开销,进而提升检索效率。 + +```json +POST +{ + "settings": { + "refresh_interval": "30s" + } +} +``` + +**需要注意的是,虽然此时数据能被查询到,但还没落到磁盘中,数据仍然是不安全的** + +### Merge + +由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数量太多会带来较大的麻烦,每一个段都会消耗文件句柄、内存和cpu运行周期。更重要的是,每个搜索请求都必须轮流检查每个段,所以段越多,搜索也就越慢。 + +Elasticsearch通过在后台进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPiaOFniaChcYseAhZC62kjia9D01VC5Hdw8WTIcuonsuFzU2zZDCVA7tOFNkia9OoIOGOH6lBG5ZygkQ/640) + +Elasticsearch 中的一个 shard 是一个 Lucene 索引,一个 Lucene 索引被分解成段。段是存储索引数据的索引中的内部存储元素,并且是不可变的。较小的段会定期合并到较大的段中,并删除较小的段。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPiaOFniaChcYseAhZC62kjia9EiaQIvhVFhYbeBmG74oVWdwWr0wozYOibKY7YribvmHkia7XvcVeW8siajQ/640) + +**Merge是非常消耗资源的,Refesh的频率越高,生成Segment的频率就越高,Merge就执行的越频繁** + +合并大的段需要消耗大量的I/O和CPU资源,如果任其发展会影响搜索性能。Elasticsearch在默认情况下会对合并流程进行资源限制,所以搜索仍然有足够的资源很好地执行。 + +### Flush + +Flush 是确保当前存储在 Translog 中的数据也永久存储在 Lucene 索引中的过程。 + +重新启动时,Elasticsearch 会将所有未刷新的操作从 Translog 重播到 Lucene 索引,以使其恢复到重新启动前的状态。Elasticsearch 会根据需要自动触发Flush,使用启发式算法来权衡未刷新事务日志的大小与执行每次刷新的成本。 + +一旦操作被刷新,它就会永久存储在 Lucene 索引中。这可能意味着不需要在事务日志中维护它的额外副本。事务日志由多个文件组成,称为 generation ,一旦不再需要,Elasticsearch 将删除相应的文件,从而释放磁盘空间。 + +也可以使用 Flush API 触发一个或多个索引的刷新,尽管用户很少需要直接调用此 API。如果您在索引某些文档后调用刷新 API,并成功响应,表明 Elasticsearch 已刷新在调用刷新 API 之前索引的所有文档。 + +```json +POST /my_index/_flush +``` + +请注意,手动调用刷新操作可能会对系统性能产生一定的影响,因为它涉及到磁盘写入和索引更新。建议在必要时使用手动刷新操作,而不是频繁地调用。 + +### Translog + +对索引的修改操作会在 Lucene 执行 commit 之后真正持久化到磁盘,这个过程是非常消耗资源的,因此不可能在每次索引操作或删除操作后执行。Lucene 提交的成本太高,无法对每个单独的更改执行,因此每个分片副本先将操作写入其事务日志,也就是 Translog。 + +所有索引和删除操作在被内部 Lucene 索引处理之后,但在它们被确认之前写入到 translog。如果发生崩溃,当分片恢复时,已确认但尚未包含在最后一次 Lucene 提交中的最近操作将从 translog 中恢复。 + +ES的 Flush 是 Lucene 执行 commit 并开始写入 translog 的过程,Flush 是在后台自动执行的,translog 中的数据仅在 translog 被执行 `fsync` 和 `commit` 时才会持久化到磁盘。如果发生硬件故障或操作系统崩溃或 JVM 崩溃或分片故障,自上次 translog 提交以来写入的任何数据都将丢失。 + +以下参数可控制 translog 的行为: + +- `index.translog.sync_interval`:无论写入操作如何,translog 默认每隔 5s 被 fsync 写入磁盘并 commit 一次,不允许设置小于 100ms 的提交间隔。设置得较小,例如设置为 1s,会增加磁盘 I/O 的频率,但能提供更高的数据持久性。与之相反,若设置得较大,例如设置为 -1,表示关闭自动触发的 Translog 刷新机制,将完全依赖于系统或文件系统层面的刷新策略。这样可以提高写入性能,但可能会增加数据丢失风险。 + +- `index.translog.durability`:此参数定义了刷新到磁盘的方式,该参数有以下可选值: + + - `request`:表示 Elasticsearch 在响应客户端请求之前必须将数据刷新到磁盘。这是最安全的选项,但可能会影响写入性能。 + - `async`:表示 Elasticsearch 在异步模式下将数据刷新到磁盘。它允许更高的写入性能,但可能会增加一定的数据丢失风险。 + - `fsync`:表示 Elasticsearch 在将数据刷新到磁盘后,通过执行 fsync 操作来确保数据已经写入到物理介质。这是最慢的选项,但提供了最高的数据持久性。 + + 默认情况下,`index.translog.durability` 的值为 `request`,即 Elasticsearch 必须在响应客户端请求之前将数据刷新到磁盘,以确保数据的持久性。 + +- `index.translog.flush_threshold_size`:此参数定义了触发 Translog 刷新的阈值大小,默认值为 512MB。这意味着当 Translog 中累积的数据大小达到或超过 512MB 时,Elasticsearch 将自动触发刷新操作,将数据刷新到磁盘。可以根据实际需求调整该参数的值。如果值设置得较小,例如设置为 128MB,会增加 Translog 刷新的频率,但可能会对系统的写入性能产生一定影响。而将其设置得较大,则会减少 Translog 刷新的频率,提高写入性能,但也会增加数据丢失风险。 + +### 图解写入流程 + +一图以蔽之: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPiaOFniaChcYseAhZC62kjia9kCcnUcde5orrgaLvY9FmGh2wUPB1F7fL09oibfRsbl0fnVjEBIm7UibQ/640) \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\206\231\345\205\245\345\222\214\346\243\200\347\264\242\350\260\203\344\274\230.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\206\231\345\205\245\345\222\214\346\243\200\347\264\242\350\260\203\344\274\230.md" new file mode 100644 index 0000000..572208c --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\206\231\345\205\245\345\222\214\346\243\200\347\264\242\350\260\203\344\274\230.md" @@ -0,0 +1,149 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +当涉及到大规模数据存储和检索时,Elasticsearch以其快速、高效和强大的搜索能力而闻名,并被广泛应用于各种场景,例如日志分析、全文搜索和实时数据分析。 + +然而,并不是只要将数据存入ES就可以立即获得最佳性能和查询效率。正如任何强大的工具一样,ES也需要进行调优,以充分发挥其潜力并满足特定业务需求。 + +在这篇文章中,我们将探讨ES写入调优和查询调优的关键方面,并提供一些实用的技巧和建议,帮助您优化ES集群的性能和响应速度。 + +## 写入调优 + +### 基本原则 + +写入性能调优是建立在 Elasticsearch 的写入原理之上的。 + +ES 数据写入具有一定的延时性,这是为了减少频繁的索引文件产生。默认情况下 ES 每秒生成一个 Segment 文件,当达到一定阈值的时候会执行merge,merge 过程发生在 JVM中,频繁的生成 Segmen 文件可能会导致频繁的触发 FGC,导致 OOM。 + +为了避免这种情况,通常采取的手段是降低 Segment 文件的生成频率,办法有两个:一个是增加时间阈值,另一个是增大Buffer的空间阈值,因为缓冲区写满也会生成 Segment 文件。 + +生产经常面临的写入可以分为两种情况: + +**高频低量**:高频的创建或更新索引或文档,一般发生在 C 端业务场景下。 + +**低频高量**:一般情况为定期重建索引或批量更新文档数据。 + +在搜索引擎的业务场景下,用户一般并不需要那么高的写入实时性。比如你在网站发布一条征婚信息,或者二手交易平台发布一个商品信息。其他人并不是马上能搜索到的,这其实也是正常的处理逻辑。 + +这个延时的过程需要处理很多事情,比如:你的信息需要后台审核。 + +你发布的内容在搜索服务中需要建立索引,而且你的数据可能并不会马上被写入索引,而是等待要写入的数据达到一定数量之后,批量写入。 + +这种操作优点类似于我们快递物流的场景,只有当快递数量达到一定量级的时候,比如能装满整个车的时候,快递车才会发车。因为反正是要跑一趟,装的越多,平均成本越低。 + +这和我们数据写入到磁盘的过程是非常相似的,我们可以把一条文档数据看做是一个快递,而快递车每次发车就是向磁盘写入数据的一个过程,这个过程不宜太多,太多只会降低性能,就是体现在运输成本上面,而对于我们数据写入而言就是体现在我们硬件性能损耗上面。 + +### 优化手段 + +以下为常见数据写入的调优手段,写入调优均以提升写入吞吐量和并发能力为目标,而非提升写入实时性。 + +#### 增加 flush 时间间隔 + +flush的过程是非常消耗资源的。增加flush的时间间隔目的是减小数据写入磁盘的频率,降低磁盘IO频率。 + +#### 增加 refresh_interval 参数的值 + +增加 refresh_interval 参数的值,目的是减少segment文件的创建,降低merge次数,因为merge是发生在jvm中的,有可能导致full GC。 + +ES的 refresh 行为非常昂贵,并且在正在进行的索引活动时经常调用,会降低索引速度。 + +默认情况下,Elasticsearch 每秒定期刷新索引,如果没有搜索流量或搜索流量很少(例如每 5 分钟不到一个搜索请求),可以适当调大此参数的值。 + +#### 增加Buffer大小 + +本质也是减小refresh的时间间隔,因为导致segment文件创建的原因不仅有时间阈值,还有buffer空间大小,写满了也会创建。 默认值为JVM 空间的10%。 + +#### 关闭副本 + +当需要单次写入大量数据的时候,建议关闭副本,暂停搜索服务,或选择在检索请求量谷值区间时间段来完成。 + +关闭副本可以带来如下好处: + +- 减小读写之间的资源抢占,读写分离。 +- 当检索请求数量很少的时候,可以减少甚至完全删除副本分片,关闭segment的自动创建以达到高效利用内存的目的,因为副本的存在会导致主从之间频繁的进行数据同步,大大增加服务器的资源占用。 + +具体可通过设置`index.number_of_replicas` 为0以加快索引速度。没有副本意味着丢失单个节点可能会导致数据丢失,因此数据保存在其他地方很重要,以便在出现问题时可以重试初始加载。初始加载完成后,可以设置`index.number_of_replicas`改回其原始值。 + +#### 禁用swap + +大多数操作系统尝试将尽可能多的内存用于文件系统缓存,并急切地换掉未使用的应用程序内存。这可能导致部分 JVM 堆甚至其可执行页面被换出到磁盘。 + +交换对性能和节点稳定性非常不利,应该不惜一切代价避免。它可能导致垃圾收集持续几分钟而不是几毫秒,并且可能导致节点响应缓慢甚至与集群断开连接。在Elastic分布式系统中,让操作系统杀死节点更有效。 + +#### 使用多个工作线程 + +发送批量请求的单个线程不太可能最大化 Elasticsearch 集群的索引容量。为了使用集群的所有资源,应该从多个线程或进程发送数据。除了更好地利用集群的资源外,还有助于降低每个 fsync 的成本。 + +确保注意 `TOO_MANY_REQUESTS` 响应代码:429。(EsRejectedExecutionException使用 Java 客户端),这是 Elasticsearch 告诉我们它无法跟上当前索引速度的方式。发生这种情况时,应该在重试之前暂停索引,最好使用随机指数退避。 + +与调整批量请求的大小类似,只有测试才能确定最佳工作线程数量是多少。这可以通过逐渐增加线程数量来测试,直到集群上的 I/O 或 CPU 饱和。 + +#### max_result_window参数 + +`max_result_window`是分页返回的最大数值,默认值为10000。max_result_window本身是对JVM的一种保护机制,通过设定一个合理的阈值,避免初学者分页查询时由于单页数据过大而导致OOM。 + +设置一个合理的大小是需要通过你的各项指标参数来衡量确定的,比如你用户量、数据量、物理内存的大小、分片的数量等等。通过监控数据和分析各项指标从而确定一个最佳值,并非越大越好。 + +## 查询调优 + +### 读写性能不可兼得 + +首先要明确一点:鱼和熊掌不可兼得。读写性能调优在很多场景下是只能二选一的。牺牲 A 换 B 的行为非常常见。索引本质上也是通过空间换取时间。牺牲写入实时性就是为了提高检索的性能。 + +当你在二手平台或者某垂直信息网站发布信息之后,是允许有信息写入的延时性的。但是检索不行,甚至 1 秒的等待时间对用户来说都是无法接受的。满足用户的要求甚至必须做到10 ms以内。 + +### 优化手段 + +#### 避免单次召回大量数据 + +搜索引擎最擅长的事情是从海量数据中查询少量相关文档,而非单次检索大量文档。非常不建议动辄查询上万数据。如果有这样的需求,建议使用滚动查询 + +#### 避免单个文档过大 + +鉴于默认`http.max_content_length`设置为 100MB,Elasticsearch 将拒绝索引任何大于该值的文档。您可能决定增加该特定设置,但 Lucene 仍然有大约 2GB 的限制。 + +即使不考虑硬性限制,大型文档通常也不实用。大型文档对网络、内存使用和磁盘造成了更大的压力,即使对于不请求的搜索请求也是如此。 + +有时重新考虑信息单元应该是什么是有用的。例如,您想让书籍可搜索的事实并不一定意味着文档应该包含整本书。使用章节甚至段落作为文档可能是一个更好的主意,然后在这些文档中拥有一个属性来标识它们属于哪本书。这不仅避免了大文档的问题,还使搜索体验更好。例如,如果用户搜索两个单词 fooand bar,则不同章节之间的匹配可能很差,而同一段落中的匹配可能很好。 + +#### 单次查询10条文档 好于 10次查询每次一条 + +批量请求将产生比单文档索引请求更好的性能。但是每次查询多少文档最佳,不同的集群最佳值可能不同,为了获得批量请求的最佳阈值,建议在具有单个分片的单个节点上运行基准测试。 + +首先尝试一次索引 100 个文档,然后是 200 个,然后是 400 个等。在每次基准测试运行中,批量请求中的文档数量翻倍。当索引速度开始趋于平稳时,就可以获得已达到数据批量请求的最佳大小。在相同性能的情况下,当大量请求同时发送时,太大的批量请求可能会使集群承受内存压力,因此建议避免每个请求超过几十兆字节。 + +#### 数据建模 + +很多人会忽略对 Elasticsearch 数据建模的重要性。 + +nested属于object类型的一种,是Elasticsearch中用于复杂类型对象数组的索引操作。Elasticsearch没有内部对象的概念,因此,ES在存储复杂类型的时候会把对象的复杂层次结果扁平化为一个键值对列表。 + +特别是,应避免Join连接。Nested 可以使查询慢几倍,Join 会使查询慢数百倍。两种类型的使用场景应该是:Nested针对字段值为非基本数据类型的时候,而Join则用于当子文档数量级非常大的时候。 + +#### 给系统留足够的内存 + +Lucene的数据的fsync是发生在OS cache的,要给OS cache预留足够的内存大小。 + +#### 预索引 + +利用查询中的模式来优化数据的索引方式。例如,如果所有文档都有一个price字段,并且大多数查询 range 在固定的范围列表上运行聚合,可以通过将范围预先索引到索引中并使用聚合来加快聚合速度。 + +#### 使用 filter 代替 query + +query和filter的主要区别在: filter是结果导向的而query是过程导向。query倾向于“当前文档和查询的语句的相关度”,而filter倾向于“当前文档和查询的条件是不是相符”。即在查询过程中,query是要对查询的每个结果计算相关性得分的,而filter不会。另外filter有相应的缓存机制,可以提高查询效率。 + +#### 避免深度分页 + +避免单页数据过大,可以参考百度或者淘宝的做法。es提供两种解决方案 scroll search 和 search after。 + +#### 使用 Keyword 类型 + +并非所有数值数据都应映射为数值字段数据类型。Elasticsearch为查询优化数字字段,例如integeror long。如果不需要范围查找,对于 term查询而言,keyword 比 integer 性能更好。 + +#### 避免使用脚本 + +Scripting是Elasticsearch支持的一种专门用于复杂场景下支持自定义编程的强大的脚本功能。相对于 DSL 而言,脚本的性能更差,DSL能解决 80% 以上的查询需求,如非必须,尽量避免使用 Script。 \ No newline at end of file diff --git "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\345\210\206\350\257\215\345\231\250.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\210\206\350\257\215\345\231\250.md" similarity index 79% rename from "docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\345\210\206\350\257\215\345\231\250.md" rename to "docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\210\206\350\257\215\345\231\250.md" index dccb47f..01aa2dc 100644 --- "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\345\210\206\350\257\215\345\231\250.md" +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\210\206\350\257\215\345\231\250.md" @@ -1,18 +1,35 @@ -## 规范化:normalization +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) -在Elasticsearch中,"normalization" 是指将文本数据转化为一种标准形式的步骤。这种处理主要发生在索引时,包括以下操作: +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) -1. **Lowercasing**:将所有字符转换为小写。这是最常见的标准化形式,因为搜索常常是不区分大小写的。 -2. **Removing diacritical marks**:移除重音符号或其他变音记号。例如,将 "résumé" 转换为 "resume"。 -3. **Converting characters to their ASCII equivalent**:将非ASCII字符转换为等效的ASCII字符。例如,将 "ë" 转换为 "e"。 + +[TOC] + +在Elasticsearch中,分词器是用于将文本数据划分为一系列的单词(或称之为词项、tokens)的组件。这个过程是全文搜索中的关键步骤。 + +一个分词器通常包含以下三个部分: + +- **字符过滤器(Character Filters)**:它接收原始文本作为输入,然后可以对这些原始文本进行各种转换,如去除HTML标签,将数字转换为文字等。 +- **分词器(Tokenizer)**:它将经过字符过滤器处理后的文本进行切分,生成一系列词项。例如,标准分词器会按照空格将文本切分成词项。 +- **词项过滤器(Token Filters)**:它对词项进行进一步的处理。比如小写化,停用词过滤(移除常见而无意义的词汇如"and", "the"),同义词处理,stemming(提取词根)等。 + +Elasticsearch提供了许多内置的分词器,如标准分词器(Standard Tokenizer)、简单分词器(Simple Tokenizer)、空白分词器(Whitespace Tokenizer)、关键字分词器(Keyword Tokenizer)等。每种分词器都有其特定的应用场景,并且用户也可以自定义分词器以满足特殊需求。 + +## 规范化:Normalization + +在Elasticsearch中,"Normalization" 是指将文本数据转化为一种标准形式的步骤。这种处理主要发生在索引时,包括以下操作: + +- **Lowercasing**:将所有字符转换为小写。这是最常见的标准化形式,因为搜索常常是不区分大小写的。 +- **Removing diacritical marks**:移除重音符号或其他变音记号。例如,将 "résumé" 转换为 "resume"。 +- **Converting characters to their ASCII equivalent**:将非ASCII字符转换为等效的ASCII字符。例如,将 "ë" 转换为 "e"。 这些转换有助于提高搜索的准确性,因为用户可能以各种不同的方式输入同一个词语。通过将索引和搜索查询都转换为相同的形式,可以更好地匹配相关结果。 -**说白了normalization就是将不通用的词汇变成通用的词汇。文档规范化,提高召回率**。 +**normalization的作用就是将文档规范化,提高召回率** 举个例子: -假设我们希望在 Elasticsearch 中创建一个新的索引,该索引包含一个自定义分析器,该分析器将文本字段转换为小写并移除变音符号。你可以使用以下的请求: +假设我们希望在 Elasticsearch 中创建一个新的索引,该索引包含一个自定义分析器,该分析器将文本字段转换为小写并移除变音符号。示例如下: ```JSON PUT /my_index @@ -20,7 +37,8 @@ PUT /my_index "settings": { "analysis": { "analyzer": { - "my_custom_analyzer": { "type": "custom", + "my_custom_analyzer": { + "type": "custom", "tokenizer": "standard", "filter": ["lowercase", "asciifolding"] } @@ -42,9 +60,9 @@ PUT /my_index 这个分析器包括三部分: -- `"type": "custom"`: 这表示我们正在创建一个自定义分析器。 -- `"tokenizer": "standard"`: 这设置了标准分词器,它按空格和标点符号将文本拆分为单词。 -- `"filter": ["lowercase", "asciifolding"]`: 这是一个过滤器链,将所有文本转为小写 (lowercasing) 并移除所有的变音符号(如 accented characters)。 +- `"type": "custom"`: 这表示我们正在创建一个自定义分析器。 +- `"tokenizer": "standard"`: 这设置了标准分词器,它按空格和标点符号将文本拆分为单词。 +- `"filter": ["lowercase", "asciifolding"]`: 这是一个过滤器链,将所有文本转为小写 (lowercasing) 并移除所有的变音符号(如 accented characters)。 最后,在 `mappings` 对象中,我们指定 "my_field" 字段要使用这个自定义分析器。 @@ -59,19 +77,19 @@ POST /my_index/_doc Elasticsearch 在索引这个文档时会将 "Méditerranéen RÉSUMÉ" 转换成 "mediterraneen resume"。这样,无论搜索查询是 "Méditerranéen", "méditerranéen", "MEDITERRANÉEN", "Resume", "résumé" 或 "RESUME",都能找到这个文档。 -## 字符过滤器:character filter +## 字符过滤器:Character Filter -Character filters就是在分词之前过滤掉一些无用的字符, 是 Elasticsearch 中的一种文本处理组件,它可以在分词前先对原始文本进行处理。这包括删除HTML标签、转换符号等。 +Character Filters就是在分词之前过滤掉一些无用的字符, 是 Elasticsearch 中的一种文本处理组件,它可以在分词前先对原始文本进行处理。这包括删除HTML标签、转换符号等。 -下面是一些常用的 character filter: +下面是一些常用的 Character Filter: -1. **HTML Strip Character Filter**:从输入中去除HTML元素,只保留文本内容。例如,`

Hello World

` 被处理为 `"Hello World"`。 -2. **Mapping Character Filter**:通过一个预定义的映射关系,将指定的字符或字符串替换为其他字符或字符串。例如,你可以定义一个规则将 "&" 替换为 "and"。 -3. **Pattern Replace Character Filter**:使用正则表达式匹配和替换字符。 +- **HTML Strip Character Filter**:从输入中去除HTML元素,只保留文本内容。例如,`

Hello World

` 被处理为 `"Hello World"`。 +- **Mapping Character Filter**:通过一个预定义的映射关系,将指定的字符或字符串替换为其他字符或字符串。例如,你可以定义一个规则将 "&" 替换为 "and"。 +- **Pattern Replace Character Filter**:使用正则表达式匹配和替换字符。 ### HTML Strip Character Filter -HTML Strip Character Filter 是 Elasticsearch 中的一个 character filter,其功能是从输入的文本中去除 HTML 元素。这对于处理包含 HTML 标签的文本十分有用。 +HTML Strip Character Filter 是 Elasticsearch 中的一种 Character Filter,其功能是从输入的文本中去除 HTML 元素。这对于处理包含 HTML 标签的文本十分有用。 下面的例子展示了如何创建一个使用 HTML Strip Character Filter 的索引: @@ -175,7 +193,7 @@ Pattern Replace Character Filter 是 Elasticsearch 中一个强大的工具, 例如,假设你需要在索引或搜索时删除所有的数字,可以使用 Pattern Replace Character Filter,并设置一个匹配所有数字的正则表达式 `[0-9]`,然后将其替换为空字符串或其他所需的字符。 -以下是如何配置并使用 Pattern Replace Character Filter 的示例: +示例如下: ```JSON PUT /my_index @@ -210,9 +228,7 @@ PUT /my_index 在这个例子中,我们定义了一个名为 `my_pattern_replace_char_filter` 的字符过滤器,该过滤器将所有数字(匹配正则表达式 `[0-9]`)替换为一个空字符串("")。然后,在我们的分析器 `my_analyzer` 中使用了这个字符过滤器。最后,在映射中我们指定了字段 "text" 使用这个分析器。因此,当你向 "text" 字段存储含有数字的文本时,所有的数字会被移除。 -例如: - -当你配置好索引并设定了特定的字符过滤规则后,你可以向这个索引插入文档。以下是一个插入文档的例子: +当你配置好索引并设定了特定的字符过滤规则后,你可以向这个索引插入文档。例如: ```JSON PUT /my_index/_doc/1 @@ -225,7 +241,7 @@ PUT /my_index/_doc/1 所以,在Elasticsearch中,无论用户搜索 "I have apples." 还是原始的 "I have 10 apples.",都能找到这条记录。同时,如果你检索这个文档,例如 `GET /my_index/_doc/1`,返回的结果中 `text` 字段仍为原始输入: "I have 10 apples.",因为 character filter 只对搜索和索引过程生效,不会改变实际存储的文档内容。 -Pattern Replace Character Filter有一个常用的场景就是手机号脱敏,比如:假设我们希望将电话号码中的前 7 位数字替换为星号 "*" 来进行脱敏处理。可以使用 Pattern Replace Character Filter 进行配置,如下: +Pattern Replace Character Filter有一个常用的场景就是手机号脱敏,比如:假设我们希望将电话号码中的某几位数字替换为星号 "*" 来进行脱敏处理。可以使用 Pattern Replace Character Filter 进行配置,如下: ```JSON PUT /my_index @@ -269,15 +285,15 @@ PUT /my_index/_doc/1 } ``` -Elasticsearch 在索引这个文档时,会按照我们设置的规则将手机号码脱敏为 "123***\*8901\*\*",所以无论用户搜索 "\*\*My phone number is 12345678901.\*\*" 还是 "\*\*My phone number is 123\****8901." 都能找到这条记录。 +Elasticsearch 在索引这个文档时,会按照我们设置的规则将手机号码脱敏,所以无论用户搜索 "My phone number is 12345678901." 还是 "My phone number is 123\****8901." 都能找到这条记录。 -## 令牌过滤器(token filter) +## 令牌过滤器(Token Filter) 在 Elasticsearch 中,Token Filter 负责处理 Analyzer 的 Tokenizer 输出的单词或者 tokens。这些处理操作包括:转换为小写、删除停用词、添加同义词等。 ### 大小写和停用词 -以下是一个例子,我们创建一个自定义分析器来演示如何使用 `lowercase` 和 `stop` token filter: +以下是一个例子,我们创建一个自定义分析器来演示如何使用 `lowercase` 和 `stop token filter`: ```JSON PUT /my_index @@ -326,11 +342,11 @@ PUT /my_index/_doc/1 ### 同义词 -`synonym` token filter 可以帮助我们处理同义词。它可以将某个词或短语映射到其它的同义词。 +`synonym token filter` 可以帮助我们处理同义词。它可以将某个词或短语映射到其它的同义词。 -例如,假设你有一个电子商务网站,并且你想让搜索 "cellphone" 的用户也能看到所有包含 "mobile", "smartphone" 的商品。你可以使用 `synonym` token filter 来实现这一目标。 +例如,假设你有一个电子商务网站,并且你想让搜索 "cellphone" 的用户也能看到所有包含 "mobile", "smartphone" 的商品。你可以使用 `synonym token filter` 来实现这一目标。 -以下是一个使用 `synonym` token filter 的例子: +以下是一个使用 `synonym token filter` 的例子: ```JSON PUT /my_index @@ -423,21 +439,21 @@ PUT /my_index 注意:`synonyms_path` 是相对于 `config` 目录的路径。例如,如果你的 `config` 目录在 `/etc/elasticsearch/`,那么 `synonyms.txt` 文件应该放在 `/etc/elasticsearch/analysis/synonyms.txt`。 -## 分词器(tokenizer) +## 分词器(Tokenizer) 在 Elasticsearch 中,分词器是用于将文本字段分解成独立的关键词(或称为 token)的组件。这是全文搜索中的一个重要过程。Elasticsearch 提供了多种内建的 tokenizer。 以下是一些常用的 tokenizer: -1. **Standard Tokenizer**:它根据空白字符和大部分标点符号将文本划分为单词。这是默认的 tokenizer。 -2. **Whitespace Tokenizer**:仅根据空白字符(包括空格,tab,换行等)进行切分。 -3. **Language Tokenizers**:基于特定语言的规则来进行分词,如 `english`、`french` 等。 -4. **Keyword Tokenizer**:它接收任何文本并作为一个整体输出,没有进行任何分词。 -5. **Pattern Tokenizer**:使用正则表达式来进行分词,可以自定义规则。 +- **Standard Tokenizer**:它根据空白字符和大部分标点符号将文本划分为单词。这是默认的 tokenizer。 +- **Whitespace Tokenizer**:仅根据空白字符(包括空格,tab,换行等)进行切分。 +- **Language Tokenizers**:基于特定语言的规则来进行分词,如 `english`、`french` 等。 +- **Keyword Tokenizer**:它接收任何文本并作为一个整体输出,没有进行任何分词。 +- **Pattern Tokenizer**:使用正则表达式来进行分词,可以自定义规则。 你可以根据不同的数据和查询需求,选择适当的 tokenizer。另外,也可以通过定义 custom analyzer 来混合使用 tokenizer 和 filter(比如 lowercase filter,stop words filter 等)以达到更复杂的分词需求。 -### 自定义分词器:custom analyzer +### 自定义分词器:Custom Analyzer 在 Elasticsearch 中,你可以创建自定义分词器(Custom Analyzer)。一个自定义分词器由一个 tokenizer 和零个或多个 token filters 组成。tokenizer 负责将输入文本划分为一系列 token,然后 token filters 对这些 token 进行处理,比如转换成小写、删除停用词等。 @@ -477,9 +493,9 @@ PUT /my_index } ``` -在这个配置中,我们首先定义了一个名为 `my_stopwords` 的停用词过滤器,包含两个停用词 "the" 和 "and"。然后我们创建了一个名为 `my_custom_analyzer` 的自定义分析器,其中使用了 `standard` tokenizer 以及 `lowercase` filter 和 `my_stopwords` filter。 +在这个配置中,我们首先定义了一个名为 `my_stopwords` 的停用词过滤器,包含两个停用词 "the" 和 "and"。然后我们创建了一个名为 `my_custom_analyzer` 的自定义分析器,其中使用了 `standard tokenizer `以及 `lowercase filter` 和 `my_stopwords filter`。 -因此,在为字段 `text` 索引文本时,Elasticsearch 会首先使用 `standard` tokenizer 将文本切分为 tokens,然后将这些 tokens 转换为小写,并移除其中的 "the" 和 "and"。对于搜索查询也同样适用此规则。 +因此,在为字段 `text` 索引文本时,Elasticsearch 会首先使用 `standard tokenizer` 将文本切分为 tokens,然后将这些 tokens 转换为小写,并移除其中的 "the" 和 "and"。对于搜索查询也同样适用此规则。 ## 中文分词器:ik分词 @@ -506,8 +522,8 @@ IK 分词器是一个开源的中文分词器插件,特别为 Elasticsearch ### ik提供的两种analyzer -1. ik_max_word会将文本做最细粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,中华人民,中华,华人,人民共和国,人民,人,民,共和国,共和,和,国国,国歌”,会穷尽各种可能的组合,适合 Term Query。 -2. ik_smart: 会做最粗粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,国歌”,适合 Phrase 查询。 +- **ik_max_word**:会将文本做最细粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,中华人民,中华,华人,人民共和国,人民,人,民,共和国,共和,和,国国,国歌”,会穷尽各种可能的组合,适合 Term Query。 +- **ik_smart**:会做最粗粒度的拆分,比如会将“中华人民共和国国歌”拆分为“中华人民共和国,国歌”,适合 Phrase 查询。 ### ik自定义词库 @@ -539,7 +555,7 @@ IK 分词器是一个开源的中文分词器插件,特别为 Elasticsearch 要修改词库,必须重启ES才能生效,有时我们会频繁更新词库,比较麻烦,更致命的是,es肯定是分布式的,可能有数百个节点,我们不能每次都一个一个节点上面去修改。基于这种场景,我们可以使用热更新功能。 -实现热更新有2种办法:基于远程词库和基于数据库。 +实现热更新有两种办法:基于远程词库和基于数据库。 #### 基于远程词库 @@ -563,8 +579,8 @@ IK 分词器支持从远程 URL 下载扩展字典,这就可以用来实现词 根据官方文档,该请求需要满足下列2点: -1. 该 http 请求需要返回两个头部(header),一个是 `Last-Modified`,一个是 `ETag`,这两者都是字符串类型,只要有一个发生变化,该插件就会去抓取新的分词进而更新词库。 -2. 该 http 请求返回的内容格式是一行一个分词,换行符用 `\n` 即可。 +- 该 http 请求需要返回两个头部(header),一个是 `Last-Modified`,一个是 `ETag`,这两者都是字符串类型,只要有一个发生变化,该插件就会去抓取新的分词进而更新词库。 +- 该 http 请求返回的内容格式是一行一个分词,换行符用 `\n` 即可。 满足上面两点要求就可以实现热更新分词了,不需要重启 ES 实例。 @@ -580,7 +596,7 @@ IK 分词器支持从远程 URL 下载扩展字典,这就可以用来实现词 #### 基于数据库 -另外一种方式是基于数据库,这种方式使用比较多,需要修改ik插件源码。 +另外一种方式是基于数据库,这种方式使用比较多,但需要修改ik插件源码,有一定复杂度。 基本思路是将词库维护在数据库(MySQL,Oracle等),修改ik源码去数据库加载词库,然后将源码重新打包引入到我们的elasticsearch中。 @@ -591,4 +607,4 @@ IK 分词器支持从远程 URL 下载扩展字典,这就可以用来实现词 3. **编写读取数据库词库的函数**:编写一个可以从数据库读取词库数据并转换为 IK 分词器可以使用的格式(比如 ArrayList)的函数。 4. **修改字典加载部分的代码**:找到 IK 源码中负责加载扩展字典的部分,原本这部分代码是将文件内容加载到内存中,现在改为调用你刚才编写的函数,从数据库中加载词库数据。 5. **添加定时任务**:添加一个定时任务,每隔一段时间重新执行一次上述加载操作,以实现词库的热更新。 -6. **编译和安装**:完成上述修改后,按照 IK 插件的构建说明,使用 Maven 或其他工具将其编译成插件,然后安装到 Elasticsearch 中。 \ No newline at end of file +6. **编译和安装**:完成上述修改后,按照 IK 插件的构建说明,使用 Maven 或其他工具将其编译成插件,然后安装到 Elasticsearch 中。 \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\271\266\345\217\221\346\216\247\345\210\266.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\271\266\345\217\221\346\216\247\345\210\266.md" new file mode 100644 index 0000000..a5d74c4 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\345\271\266\345\217\221\346\216\247\345\210\266.md" @@ -0,0 +1,161 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +ES的并发控制是一种机制,用于处理多个同时对同一份数据进行读写操作的情况,以确保数据的一致性和正确性。 + +实现并发控制的方法主要有两种:悲观锁和乐观锁。 + +## 悲观锁 + +悲观锁是一种并发控制机制,它基于一种假设:在任何时候都会发生并发冲突。因此,在进行读写操作之前,悲观锁会将数据标记为“被锁定”,以阻止其他操作对其进行修改。 + +对于一个共享数据,某个线程访问到这个数据的时候,会认为这个数据随时有可能会被其他线程访问而造成数据不安全的情况,因此线程在每次访问的时候都会对数据加一把锁。这样其他线程如果在加锁期间想访问当前数据就只能等待,也就是阻塞线程了。 + +一个现实中的悲观锁例子是银行柜台排队取款。 + +假设某个银行只有一个柜台提供服务,多个客户需要办理业务。当第一个客户进入柜台并开始办理业务时,其他客户会悲观地认为自己无法立即获得服务,因此必须在柜台前排队等待。这种情况下,每个客户都悲观地预期自己必须等待一段时间才能办理业务,直到轮到自己。柜台就像是被锁住的资源,只允许一个客户同时使用,其他客户需等待释放才能进行操作。 + +## 乐观锁 + +乐观锁基于假设多个事务之间很少发生冲突的思想。在使用乐观锁的情况下,系统默认认为并发操作不会产生冲突,因此不会立即阻塞其他事务的执行。 + +具体实现乐观锁的方式是,每个事务在读取数据时会获取一个版本号(或时间戳),在提交更新时会检查该版本号是否被其他事务修改过。如果版本号未被修改,意味着没有冲突发生,可以继续提交更新;但如果版本号已经被修改,说明有其他事务已经修改了数据,当前事务则需要重新读取最新数据并重新执行。 + +乐观锁主要依赖于数据的版本控制来实现并发控制,可以降低锁粒度,提高并发性能。然而,在高并发环境下,如果冲突频繁发生,乐观锁可能会导致大量的回滚和重试操作,影响系统的性能。因此,在选择乐观锁时需要仔细评估并发冲突的概率和代价。 + +一个现实中的乐观锁例子是电影院的选座系统。当多个用户同时访问选座系统时,系统采用乐观锁机制来处理并发操作。 + +假设用户A和用户B同时进入选座系统,并选择了相同的座位。系统会首先记录用户A和用户B选择该座位的时间戳或版本号。当用户A提交座位选择后,系统会检查座位的时间戳或版本号是否被修改。如果未被修改,则说明没有冲突发生,系统会将座位分配给用户A。但如果时间戳或版本号已经被修改,说明用户B已经在此期间选择了相同的座位,系统需要重新读取最新数据并通知用户A重新选择座位。 + +在这个例子中,选座系统默认认为用户之间很少选择相同座位,因此不立即阻塞其他用户的操作。通过乐观锁机制,系统可以减少并发冲突的发生,并提高用户的选择效率和系统的并发性能。 + +## 如何选择 + +**首先,悲观锁和乐观锁没有孰优孰劣,它们各自有各自的适用场景** + +选择乐观锁还是悲观锁取决于具体的应用场景和需求。下面是一些考虑因素: + +1. 并发程度:如果系统中并发冲突较为频繁,多个事务之间经常需要争抢同一个资源,那么悲观锁可能更适合。悲观锁可以确保资源的互斥访问,但会导致其他事务等待锁释放,可能影响系统的性能。 +2. 冲突概率:如果系统中并发冲突较为罕见,多个事务之间很少竞争同一个资源,那么乐观锁可能更适合。乐观锁假设并发操作不会产生冲突,可以提高系统的并发性能。但如果冲突发生频率较高,乐观锁可能会导致大量的回滚和重试,降低系统性能。 +3. 锁粒度:悲观锁通常会对整个资源或数据进行加锁,阻塞其他事务的访问。如果需要细粒度的并发控制,或者希望允许多个事务同时读取数据,那么乐观锁可能更适合。乐观锁可以降低锁粒度,提高并发性能。 +4. 实现复杂度:乐观锁相对而言实现起来更简单,只需要添加版本号或时间戳等机制即可。而悲观锁的实现可能需要借助底层的锁机制,如数据库的行级锁或使用并发控制工具。因此,在实现复杂度方面,乐观锁更容易实现和维护。 + +总而言之,选择乐观锁还是悲观锁应该根据具体场景和需求进行评估。如果并发冲突较为频繁且需要确保互斥访问,可以选择悲观锁;如果并发冲突较为罕见且需要提高并发性能,可以选择乐观锁。 + +## ES的并发控制 + +**ES的并发控制是通过乐观锁机制来实现的** + +Elasticsearch 是分布式的。创建、更新或删除文档时,必须将文档的新版本复制到集群中的其他节点。ES 也是异步并行的,所以这些复制请求是并行发送的,并且可能不按顺序执行到每个节点。ES需要一种并发策略来保证数据的安全性,而这种策略就是乐观锁并发控制策略。 + +为了保证旧文档不会被新文档覆盖,对文档执行的每个操作都由协调该更改的主分片分配一个序列号(_seq_no)。每个操作都会操作序列号递增,因此可以保证较新的操作具有更高的序列号。然后,ES 可以使用操作序列号来确保更新的文档版本永远不会被分配了较小序列号的版本覆盖。 + +### 版本号:_version + +**基本原理** + +每个索引文档都有一个版本号。默认情况下,使用从 1 开始的内部版本控制,每次更新都会增加,包括删除。 + +版本号可以设置为外部值(例如,如果在数据库中维护)。要启用此功能,`version_type` 应设置为 `external`。提供的值必须是大于或等于 0 且小于 9.2e+18 左右的数字长整型值。 + +使用外部版本类型时,系统会检查传递给索引请求的版本号是否大于当前存储文档的版本。如果为真,文档将被索引并使用新的版本号。如果提供的值小于或等于存储文档的版本号,则会发生版本冲突,索引操作将失败。 + +**作用范围** + +_version 的有效范围为当前文档。 + +**版本类型** + +`version_type`字段有以下几种取值: + +- `internal`(默认值):使用内部版本号(_version)来检测文档版本冲突。如果两个操作同时修改了相同的文档,后面执行的操作将失败并返回版本冲突的错误。 +- `external`:使用外部版本号来检测文档版本冲突。当执行操作时,必须提供文档的当前版本号,如果提供的版本号与实际版本号不匹配,则操作将失败。 +- `external_gte`:同样使用外部版本号,但提供的版本号大于等于实际版本号时才执行操作。如果提供的版本号小于实际版本号,则操作将失败,external_gte 需要谨慎使用,否则可能会丢失数据。 + +通过指定适当的`version_type`,可以根据业务需求选择如何处理文档版本冲突。在某些场景下,外部版本号可能更适合,因为它允许应用程序明确控制版本冲突的处理方式。而在其他情况下,可以使用内部版本号来简化版本管理,并自动处理版本冲突。 + +### _seq_no & _primary_term + +_seq_no 和 _primary_term 是用来并发控制,和 `_version`不同,`_version`属于当前文档,而 `_seq_no`属于整个index。 + +**_seq_no & _primary_term** + +- **_seq_no**:索引级别的版本号,索引中所有文档共享一个 `_seq_no` 。 +- **_primary_term**:primary_term是一个整数,每当Primary Shard发生重新分配时,比如节点重启,Primary选举或重新分配等primary_term会递增1。主要作用是用来恢复数据时处理当多个文档的_seq_no 一样时的冲突,避免 Primary Shard 上的数据写入被覆盖。 + +**if_seq_no & if_primary_term** + +在Elasticsearch中,`if_seq_no` 和 `if_primary_term` 是用于乐观锁并发控制的参数,用于确保对文档的操作不会与其他操作产生冲突。 + +`if_seq_no` 参数用于指定期望的文档序列号(seq_no),而 `if_primary_term` 参数用于指定期望的 primary term。这两个参数一起作为条件,如果提供的条件与实际存储的文档序列号和主要项匹配,则操作成功执行;否则,操作将失败并返回版本冲突的错误。 + +假设我们有一个名为 `my_index` 的索引,其中包含 `_id` 为 `1` 的文档。当前文档的 `seq_no` 是 `10`,`primary_term` 是 `1`。 + +示例 1:更新文档 + +```json +PUT my_index/_doc/1?if_seq_no=10&if_primary_term=1 +{ + "foo": "bar" +} +``` + +输出: + +```json +{ + "_index": "my_index", + "_type": "_doc", + "_id": "1", + "_version": 11, + "result": "updated", + "_shards": { + "total": 2, + "successful": 1, + "failed": 0 + } +} +``` + +在这个示例中,通过提供正确的 `if_seq_no` 和 `if_primary_term` 条件,操作成功地更新了文档,并返回了更新后的版本号 `_version`。 + +示例 2:更新文档,但条件不匹配 + +```json +PUT my_index/_doc/1?if_seq_no=11&if_primary_term=1 +{ + "foo": "bar" +} +``` + +输出: + +```json +{ + "error": { + "root_cause": [ + { + "type": "version_conflict_engine_exception", + "reason": "[1]: version conflict, current version [11], provided version [11]", + "index_uuid": "xxxxxxxxxxxxx", + "shard": "0", + "index": "my_index" + } + ], + "type": "version_conflict_engine_exception", + "reason": "[1]: version conflict, current version [11], provided version [11]", + "index_uuid": "xxxxxxxxxxxxx", + "shard": "0", + "index": "my_index" + }, + "status": 409 +} +``` + +在这个示例中,由于提供的 `if_seq_no` 和 `if_primary_term` 条件与实际存储的文档序列号和主要项不匹配,操作失败并返回版本冲突的错误。 + +通过使用 `if_seq_no` 和 `if_primary_term` 参数,我们可以精确控制对文档的并发操作,并避免冲突。 \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\220\234\347\264\242\346\216\250\350\215\220.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\220\234\347\264\242\346\216\250\350\215\220.md" new file mode 100644 index 0000000..460d567 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\220\234\347\264\242\346\216\250\350\215\220.md" @@ -0,0 +1,370 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +我们在进行搜索的时候,一般都会要求具有“搜索推荐”或者叫“搜索补全”的功能,即在用户输入搜索的过程中,进行自动补全或者纠错,以此来提高搜索文档的匹配精准度,进而提升用户的搜索体验,这就是Suggest。 + +ES针对不同的应用场景,把Suggester主要分为以下四种: + +`Tern Suggester`,`Phrase Suggester`,`Completion Suggester`,`Context Suggester` + +## Term Suggester + +意如其名,Term Suggester针对单独term的搜索推荐,不考虑搜索短语中多个term的关系。 + +请求示例模版: + +```json +POST /_search +{ + "suggest": { + "": { + "text": "", + "term": { + "suggest_mode": "", + "field": "" + } + } + } +} +``` + +以下是一个具体的示例,演示如何使用 Term Suggester 进行搜索建议: + +```json +POST my_index/_search +{ + "suggest": { + "my-suggestion": { + "text": "bown", + "term": { + "suggest_mode": "popular", + "field": "title" + } + } + } +} +``` + +在这个示例中,我们发送了一个建议请求,要求根据用户输入的文本 `"bown"` 提供搜索建议。建议器将在 `title` 字段中查找匹配项,并提供最受欢迎的建议结果。 + +### Options + +- **text**:用户搜索的文本。 +- **field**:要从哪个字段选取推荐数据。 +- **analyzer**:使用哪种分词器。 +- **size**:每个建议返回的最大结果数。 +- **sort**:如何按照提示词项排序,参数值只可以是以下两个枚举: + - score:分数>词频>词项本身。 + - frequency:词频>分数>词项本身。 +- **suggest_mode**:搜索推荐的推荐模式,参数值亦是枚举: + - missing:默认值,当用户输入的文本在索引中找不到匹配项时,仍然提供建议。如果用户输入的文本在索引中没有匹配项,但有与之相关的建议结果,则这些建议结果将被返回作为搜索建议。这种模式适用于确保即使没有完全匹配的结果,用户仍能获得相关的建议。 + - popular:根据最受欢迎或最频繁出现的词项来生成建议结果。对于给定的用户输入,Term Suggester 将返回那些在索引中最常出现的词项作为建议结果。这种模式适用于提供与最流行或最常见搜索关键词相关的建议。 + - always:始终提供建议,即使已经存在完全匹配的结果。无论用户输入的文本是否与索引中的某个词项完全匹配,Term Suggester 都会提供一组建议结果。这种模式适用于用户输入的文本可能只是部分匹配的情况,以便提供更多的补全或纠错建议。 +- **max_edits**:可以具有最大偏移距离候选建议以便被认为是建议。只能是1到2之间的值。任何其他值都将导致引发错误的请求错误。默认为2。 +- **prefix_length**:前缀匹配的时候,必须满足的最少字符。 +- **min_word_length**:最少包含的单词数量,通过设置 `min_word_length` 参数,可以过滤掉那些长度不足的词项,从而得到更具有意义和相关性的建议结果。 +- **min_doc_freq**:最少的文档频率,通过设置 `min_doc_freq` 参数,可以过滤掉那些在文档中出现频率较低的词项,从而得到更具有代表性和相关性的建议结果。 +- **max_term_freq**:最大的词频,通过设置 `max_term_freq` 参数,可以控制建议结果中词项的重复出现程度,以避免过多重复的词项。 + +## Phrase Suggester + +Phrase Suggester 是 Elasticsearch 中用于短语级别建议的功能。它可以根据用户输入的文本生成相关的短语建议,帮助用户补全或纠正输入。 + +Term Suggester可以对单个term进行建议或者纠错,但是不会考虑多个term之间的关系,Phrase Suggester在Term Suggester的基础上,会去考虑多个term之间的关系,比如是否同时出现在一个索引原文中,相邻程度以及词频等等。 + +以下是一个使用 Phrase Suggester 的请求示例模板: + +```json +POST /_search +{ + "suggest": { + "": { + "text": "", + "phrase": { + "field": "", + "gram_size": , + "direct_generator": [ + { + "field": "", + "suggest_mode": "" + } + ] + } + } + } +} +``` + +以下是一个具体的示例,演示如何使用 Phrase Suggester 进行短语建议: + +```json +POST my_index/_search +{ + "suggest": { + "my-suggestion": { + "text": "quik brwn", + "phrase": { + "field": "title", + "gram_size": 2, + "direct_generator": [ + { + "field": "title", + "suggest_mode": "popular" + } + ] + } + } + } +} +``` + +在这个示例中,我们发送了一个建议请求,要求根据用户输入的文本 `"quik brwn"` 提供短语建议。Phrase Suggester 将在 `title` 字段中查找与短语相关的建议结果。 + +生成短语时,使用的 gram 大小为 2,表示使用两个连续的词项进行组合。而直接生成器(direct_generator)将根据最受欢迎或最频繁出现的词项生成建议结果。 + +### Options + +- **real_word_error_likelihood**:默认值为 0.95,即告诉 Elasticsearch 索引中有5% 的术语拼写错误。该参数指定了词语在索引中被认为是拼写错误的概率。较低的值将使得更多在索引中出现的词语被视为拼写错误,即使它们实际上是正确的。 +- **max_errors**:最大容忍错误百分比。默认值为 1,表示最多允许 1% 的错误。当建议短语与输入短语匹配时,如果超过该百分比的术语被认为是错误的,则该建议会被排除。 +- **confidence**:默认值为 1.0,取值范围为 [0, 1]。该参数控制建议结果的置信度阈值。只有得分高于此阈值的建议才会返回。较高的值意味着只有得分接近或高于输入短语的建议才会显示。 +- **collate**:该参数用于修剪建议结果,仅保留那些与给定查询匹配的建议。它接受一个匹配查询作为参数,并且只有当建议的文本与该查询匹配时,才会返回该建议。还可以在查询参数的 "params" 对象中添加更多字段。当参数 "prune" 设置为 true 时,响应中会增加一个 "collate_match" 字段,指示建议结果中是否存在匹配所有更正关键词的匹配项。 +- **direct_generator**:该参数控制候选生成器的行为。Phrase Suggester 使用候选生成器生成给定文本中每个项的可能建议项列表。目前,只有一种候选生成器可用,即 direct_generator。它以文本中的每个项单独调用 Term Suggester 来生成候选项,并将生成器的输出与建议结果进行打分。 + +## Completion Suggester + +Completion Suggester 是一种用于实现自动补全功能的建议器。它基于预定义的文本片段,为用户提供与输入文本匹配的建议。 + +Completion Suggester 支持三种查询:前缀查询(prefix),模糊查询(fuzzy),正则表达式查询(regex)。 + +Completion Suggester也是最常使用的Suggester。 + +主要针对的应用场景就是"Auto Completion"。 此场景下用户每输入一个字符的时候,就需要即时发送一次查询请求到后端查找匹配项,在用户输入速度较高的情况下对后端响应速度要求比较苛刻。 + +因此实现上它和前面两个Suggester采用了不同的数据结构。 + +**索引并非通过倒排来完成,而是将analyze过的数据编码成FST和索引一起存放,对于一个open状态的索引,FST会被ES整个装载到内存里的,进行前缀查找速度极快。但是FST只能用于前缀查找,这也是Completion Suggester的局限所在** + +使用Completion Suggester需要注意以下两点: + +- 内存代价太大,性能高是通过大量的内存换来的。 +- 只能前缀搜索,假如输入的不是前缀,召回率可能很低。 + +Completion Suggester 需要对字段进行特定的映射来支持自动补全功能。以下是为使用 Completion Suggester 所需的映射配置: + +1. **type**:将字段类型设置为 "completion"。 +2. **analyzer**:为字段指定一个适当的分析器。建议使用 "simple" 分析器,因为它会保留完整的输入字符串作为术语的后缀,并用于生成建议。 +3. **search_analyzer**:对搜索查询应用的分析器。通常,与索引时使用的相同的分析器一起使用。 + +以下是一个示例映射配置: + +```json +{ + "mappings": { + "properties": { + "suggestion_field": { + "type": "completion", + "analyzer": "simple", + "search_analyzer": "simple" + } + } + } +} +``` + +请注意,Completion Suggester 只能在专门为自动补全而设计的字段上使用。它不适用于常规的文本字段。 + +以下是一个使用 Completion Suggester 的请求示例模板: + +```json +POST /_search +{ + "suggest": { + "": { + "prefix": "", + "completion": { + "field": "" + } + } + } +} +``` + +以下是一个具体的示例,演示如何使用 Completion Suggester 进行自动完成建议: + +```json +POST my_index/_search +{ + "suggest": { + "my-suggestion": { + "prefix": "th", + "completion": { + "field": "title_suggest" + } + } + } +} +``` + +在这个示例中,我们发送了一个建议请求,要求根据用户输入的前缀 `"th"` 提供自动完成建议。Completion Suggester 将在 `title_suggest` 字段中查找与前缀匹配的建议结果。 + +## Context Suggester + +Context Suggester允许在生成建议时考虑额外的上下文信息。与 Completion Suggester 不同,Context Suggester 可以根据特定的上下文条件来过滤和排序建议结果。 + +Context Suggester是建立在Completion Suggester基础之上的,可以看成是Completion Suggester的一种补充。 + +Context Suggester 支持两种类型的上下文: + +- **Category Context**:允许为建议结果定义一个或多个分类标签,并使用这些标签进行过滤。这样可以确保生成的建议结果与特定的类别相关联。例如,如果您正在构建一个电子商务应用程序,可以使用 Category Context 将建议限制为特定的产品类别,如衣物、鞋类等。 +- **Geo Location Context**:允许您基于地理位置信息进行建议。您可以提供经纬度坐标,并根据这些坐标过滤建议结果。这对于需要基于用户当前位置生成建议的应用程序非常有用,比如附近的商铺或景点推荐。 + +Context Suggester 中,有几个重要的参数可以用来指定上下文条件和设置建议行为。下面是一些常用的参数: + +- **name**:上下文名称,用于标识特定的上下文条件。 +- **type**:上下文类型,可以是 `"category"` 或 `"geo"`,分别表示分类标签上下文和地理位置上下文。 +- **path**:对于嵌套对象,用于指定包含上下文条件的字段路径。 + +**请求示例:** + +```json +POST /my-index/_search +{ + "suggest": { + "my-suggestion": { + "prefix": "Pro", + "completion": { + "field": "suggestions", + "context": { + "category": { + "path": "category.sub_category" + } + } + } + } + } +} +``` + +在上述示例中,我们向索引 `my-index` 发送了一个搜索请求,并使用了 Context Suggester。 + +- `field` 参数设置为 `"suggestions"`,表示要从该字段中获取建议。 +- `context.path` 参数设置为 `"category.sub_category"`,表示要从文档的 `category.sub_category` 字段中提取上下文信息。 + +这样,Context Suggester 将根据搜索的前缀和上下文信息生成相应的建议结果。 + +- **context**:上下文值,根据上下文类型和值的数据类型进行指定。可以是文本、数字、布尔值等。 +- **boost**:可选参数,用于调整上下文的重要性。默认情况下,所有上下文都具有相同的权重。 +- **precision**:仅适用于 Geo Location Context,用于指定经纬度坐标的精度。 +- **neighbors**:仅适用于 Geo Location Context,用于指定返回结果时附近的邻居数量。 + +通过这些参数,可以配置 Context Suggester 来满足特定的需求。例如,可以定义多个不同的上下文条件,并为每个上下文条件指定不同的权重,以影响建议结果的排序顺序。还可以使用 path 参数来处理嵌套对象中的上下文条件。 + +当使用 Context Suggester 时,可以通过以下请求示例向 Elasticsearch 插入文档: + +``` +POST /my-index/_doc/1 +{ + "title": "Product 1", + "suggestions": [ + { + "input": "Product 1", + "weight": 10, + "contexts": { + "category": ["electronics"], + "location": ["New York"] + } + }, + { + "input": "Phone", + "weight": 5, + "contexts": { + "category": ["electronics", "communication"], + "location": ["Seattle"] + } + } + ] +} +``` + +这个请求用于向名为 "my-index" 的索引插入一篇文档。该文档的ID是 "1",包含了一个 "title" 字段和一个 "suggestions" 字段。 + +"suggestions" 字段是一个数组,其中包含了两个建议项。每个建议项都有一个 "input" 属性表示建议的文本,一个可选的 "weight" 属性表示权重值,以及一个 "contexts" 对象表示建议的上下文信息。 + +具体解释如下: + +- "title": "Product 1" 表示这篇文档的标题是 "Product 1"。 +- "suggestions":[...] 是一个包含两个建议项的数组。 +- 第一个建议项: + - "input":"Product 1" 表示第一个建议项的文本是 "Product 1"。 + - "weight":10 表示给予这个建议项的权重是 10。 + - "contexts":{...} 表示这个建议项的上下文信息。 + - "category":["electronics"] 表示这个建议项属于 "electronics" 类别。 + - "location":["New York"] 表示这个建议项的位置是 "New York"。 +- 第二个建议项: + - "input":"Phone" 表示第二个建议项的文本是 "Phone"。 + - "weight":5 表示给予这个建议项的权重是 5。 + - "contexts":{...} 表示这个建议项的上下文信息。 + - "category":["electronics", "communication"] 表示这个建议项属于 "electronics" 和 "communication" 类别。 + - "location":["Seattle"] 表示这个建议项的位置是 "Seattle"。 + +接下来,让我给出一个关于如何发送请求并获取响应的示例: + +**请求:** + +```json +POST /my-index/_search +{ + "suggest": { + "my-suggestion": { + "prefix": "Pro", + "completion": { + "field": "suggestions", + "context": { + "category": "electronics", + "location": "New York" + } + } + } + } +} +``` + +在上述示例中,我们发送了一个搜索请求,并指定了一个自定义的建议器名称 `"my-suggestion"`。我们设置了前缀为 `"Pro"`,并在 `completion` 参数中指定了要使用的字段名和上下文信息。 + +**响应:** + +```json +{ + "suggest": { + "my-suggestion": [ + { + "text": "Pro", + "offset": 0, + "length": 3, + "options": [ + { + "text": "Product 1", + "_index": "my-index", + "_type": "_doc", + "_id": "1", + "_score": 10, + "_source": { + "title": "Product 1" + }, + "contexts": { + "category": ["electronics"], + "location": ["New York"] + } + } + ] + } + ] + } +} +``` + +在响应结果中,将看到根据输入前缀 `"Pro"` 检索到的一个建议项。该建议项具有文本、偏移量、长度等属性,并包含相关的元数据,如源文档的信息和上下文信息。 \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\240\270\345\277\203\346\246\202\345\277\265.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\240\270\345\277\203\346\246\202\345\277\265.md" new file mode 100644 index 0000000..659c26a --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\240\270\345\277\203\346\246\202\345\277\265.md" @@ -0,0 +1,383 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + +[TOC] + +**开个新的坑,创作关于Elasticsearch的系列文章** + +首先,让我们简单的了解一下Elasticsearch: + +Elasticsearch是一个开源的搜索和分析引擎,支持近实时的大数据存储、搜索和分析。它基于Apache Lucene项目,提供全文搜索及能力强大的分布式多用户搜索引擎,同时配备RESTful web接口。它不仅能执行复杂查询,还能高效处理复杂的数据分析。 + +由于其出色的大数据处理能力,Elasticsearch被广泛应用于需要快速搜索和分析大量结构化和非结构化数据的业务场景。 + +众多公司都广泛使用Elasticsearch,而我们学习它,是因为它是当前最流行的搜索和数据分析解决方案之一。 + +这是该系列的首篇文章,本章将介绍Elasticsearch的基本概念和相关术语,以帮助您初步理解并熟悉Elasticsearch。 + +注:所用的Elasticsearch版本为7.4。 + +## 节点 + +ElasticSearch 是一种分布式搜索和分析引擎,它的核心是 Lucene。 ES 具有分布式特性,意味着它可以在很多不同的服务器上运行,这些服务器被称为 "节点"。 + +- 每个Elasticsearch节点实际上就是一个Java进程,就是一个Elasticsearch的实例。 +- 一个节点 ≠一台服务器,意味着一台服务器上可以启动多个Elasticsearch的实例。 + +集群节点角色可以在配置文件`elasticsearch.yml`中通过`node.roles`配置,如果配置了节点角色,那么该节点将只会执行配置的角色功能。 + +根据角色的不同,Elasticsearch 的节点可以分为以下几种类型: + +### master:候选节点 + +所谓master节点,就是在主节点down机的时候,可以参与选举,取而代之的节点。 + +举个例子: + +**主节点好比班长,在班长不在的时候(主节点down机了),要选举出一个临时班长(master中选举)** + +master节点不仅有选举权还有被选举权,每个master节点主要负责索引创建、索引删除、追踪节点信息和决定分片分配节点等。 + +配置方法: + +```yaml +node.roles: [ master ] +``` + +### data:数据节点 + +**数据节点顾名思义就是存放数据的节点,数据节点负责存储文档数据和数据的CRUD操作** + +因此该节点是CPU和IO密集型,需要实时监控该节点的资源信息,以免过载。 + +数据节点又可分为:data_content,data_hot,data_warm,data_code。 + +- **data_content**:数据内容节点,目录节点负责存储常量数据,且不随着时间的推移,改变数据的温层(hot、warm、cold)。且该节点的查询优先级是高于其它IO操作,所以该节点search和aggregations都会较快一些。 +- **data_hot**:热节点,保存热数据,经常会被访问,用于存储最近频繁搜索和修改的时序数据。 +- **data_code**:冷节点,保存冷数据,很少会被访问,当数据不再更新,那么可以将该数据移动到冷数据节点,冷数据节点用于存储只读,且访问频率较低的数据。该节点机器性能可以低一点。 +- **data_warm**:温节点,介于热节点和冷节点之间,当数据访问频率下降,可以将其移动到温节点,温节点用于存储修改较少,但仍然有查询的数据,查询的频率肯定比热点节点要少。 + +配置方法: + +```yaml +node.roles: [ data ] +``` + +### Ingest:预处理节点 + +Ingest 节点是 Elasticsearch 的一种特殊类型的节点,用于预处理文档。预处理可能包括执行各种转换和修改,例如增加新字段、改变已有字段的值、移除字段、更改字段的数据类型等。 + +在 Elasticsearch 中,此类预处理操作是由 Ingest Pipeline 来完成的。一个 Ingest Pipeline 是一系列的处理器(processor),每一个处理器完成特定的任务。你可以定义多个处理器,然后按照特定的顺序执行。 + +要配置 Elasticsearch 使其具有 Ingest 能力,需要在 `elasticsearch.yml` 文件中设置如下: + +```yaml +node.roles: [ ingest ] +``` + +以上设置表示该节点将作为 Ingest 节点。 + +以下是创建 Ingest Pipeline 的简单示例: + +```json +PUT _ingest/pipeline/my_pipeline_id +{ + "description" : "my pipeline", + "processors" : [ + { + "set" : { + "field": "new_field", + "value": "new_value" + } + }, + { + "remove" : { + "field": "old_field" + } + } + ] +} +``` + +这个 pipeline 包含两个处理器,第一个处理器在文档中添加了一个新字段,并设置了它的值;第二个处理器删除了一个旧字段。 + +之后在索引文档时,可以使用这个 pipeline: + +```json +PUT /my_index/_doc/my_id?pipeline=my_pipeline_id +{ + "field1" : "value1", + "old_field" : "value2" +} +``` + +在这个例子中,被索引的文档将被 Ingest pipeline 提前处理,添加新字段并删除旧字段。 + +### ml:机器学习节点 + +Elasticsearch的机器学习(ML)节点用于运行各种机器学习作业,例如异常检测或数据帧分析。这些节点特性在 Elasticsearch 的集群设置中进行配置。 + +配置 elasticsearch.yml:打开每个节点的 'elasticsearch.yml' 文件,并添加或修改以下设置: + +```yaml +node.roles: [ ml ] +xpack.ml.enabled: true +``` + +这些设置会使节点成为一个机器学习节点。 + +注意:在生产环境中,你应确保你的机器学习节点有足够的内存和 CPU 来处理你的机器学习工作负载。如果你尝试运行一个过于复杂或者数据量过大的机器学习作业,可能会导致节点崩溃或者过载。 + +### remote_ cluster_ client:远程候选节点 + +远程候选节点可以作为远程集群的客户端,其作用在于帮助在本地集群与远程集群之间进行通信。当你希望模拟跨集群搜索或者跨集群复制时,这个节点角色就会派上用场。 + +配置: + +``` +node.roles: [ remote_cluster_client ] +``` + +然后,你需要在`elasticsearch.yml`中设置远程集群的信息: + +```yaml +cluster: + remote: + my_remote_cluster: + seeds: 127.0.0.1:9300 +``` + +在此示例中,“my_remote_cluster”是远程集群的别名,而“seeds”是远程集群中的种子节点的地址列表。你可以根据实际情况来更改这些值。 + +注意:在某些环境中,可能需要额外的网络配置才能确保节点之间的正常通信。 + +### transform:转换节点 + +转换节点(Transform)是一种将 Elasticsearch 索引数据进行统计分析并产生新的索引的功能。它可以用来执行复杂的聚合查询,并将结果持久化到新的 Elasticsearch 索引中。这个过程可以定期运行,也可以根据需求随时启动或停止。 + +以下是创建一个 Transform 的基本步骤: + +1. 使用 Kibana 或者 Elasticsearch API 来创建一个 Transform。你需要指定源索引(source index),目标索引(destination index),以及 Transform 的配置。 +2. 在 Transform 的配置中,你需要指定聚合查询(aggregations)以及群组字段(group by fields)。这些配置决定了怎样对源索引进行统计分析并生成新的索引。 + +一个简单的 Transform 配置示例如下: + +```json +PUT _transform/my_transform +{ + "source": { + "index": ["my_source_index"] + }, + "dest": { + "index": "my_dest_index" + }, + "pivot": { + "group_by": { + "user_id": { + "terms": { + "field": "user_id" + } + } + }, + "aggregations": { + "total_clicks": { + "sum": { + "field": "clicks" + } + } + } + }, + "frequency": "1m", + "sync": { + "time": { + "field": "timestamp", + "delay": "60s" + } + } +} +``` + +在上面的示例中,Transform 是从 `my_source_index` 中的数据计算每个 `user_id` 的点击次数总和,并将结果保存到 `my_dest_index` 中。这个 Transform 每分钟运行一次,并且在处理含有 `timestamp` 字段的最新数据时会有 60 秒的延迟。 + +更多关于 Elasticsearch Transform 的详细信息,建议参考 Elasticsearch 官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/7.4/transform-apis.html + +### voting_ only:仅投票节点 + +在master选举过程中,仅投票节点顾名思义就是只参与主节点选举过程,但不会被选为主节点。 + +**"voting-only"节点主要用来解决"split brain"(脑裂)的问题** + +在某些情况下,可能会发生两个或更多节点都认为自己是主节点的情况,这就是所谓的脑裂。通过增加"voting-only"节点,可以增加主节点选举的“选票”,从而降低脑裂的风险。 + +要将一个节点配置为"voting-only"节点,需要在该节点的`elasticsearch.yml`配置文件中设置以下属性: + +```yaml +node.roles: [voting_only] +``` + +保存并重启该节点后,它就会成为一个"voting-only"节点。 + +### coordinating only:协调节点 + +协调节点主要负责根据集群状态路由分发搜索,不参与索引和搜索操作,不存储数据,只负责将请求路由到适当的节点(例如数据节点或主节点),并根据结果组织响应返回给客户端。 + +**此外每个节点都自带协调节点功能,即便没有去专门配置,任何Elasticsearch节点默认都能成为协调节点** + +## 分片 + +分片的思想在很多分布式应用和海量数据处理的场景中都非常常见,通常来说,面对海量数据的存储,单个节点显得力不从心。 + +**通俗解释,分片就是将数据拆分多份,放到不同的服务器节点** + +Elasticsearch里的分片分为两种: + +- **主分片(Primary Shard)**:这是最初创建索引时所设定的分片。主要用于数据持久化,可以通过配置文件或者API进行设置。 +- **副本分片(Replica Shard)**:这是从主分片复制出来的分片,用于提高数据的可用性和查询性能。副本不会与其对应的主分片放在同一节点上,以防止单点故障。 + +### 主分片 + +ES可以把一个完整的索引分成多个分片,这样的好处是可以把一个大的索引拆分成多个,分布到不同的节点上。构成分布式搜索。 + +**分片的数量只能在索引创建前指定,并且索引创建后不能更改** + +ES的分片数量在索引创建时设定是因为ES将每个索引的数据分布在多个分片上以实现数据的水平扩展。这种分布是基于数据的哈希值进行的,这样可以保证数据的均匀分布。一旦索引被创建并且数据开始写入,这种数据的分布就已经确定下来了。 + +当客户端发起创建document的时候,ES需要确定这个document放在该index的哪个shard上。这个过程就是**数据路由**。 + +**路由算法:shard = hash(routing) % number_of_primary_shards** + +这里的`routing`指的就是document的id,如果`number_of_primary_shards`在查询的时候取余发生了变化,则无法获取到该数据。 + +并且如果在索引创建后改变分片的数量,就需要重新计算所有数据的哈希值并且在分片之间迁移数据。这不仅会消耗大量的计算和IO资源,而且在数据迁移过程中还可能会影响查询的准确性。因此,ES设计决定在索引创建时就确定分片的数量,而且创建后不能更改。 + +然而,虽然原始分片的数量在创建后不能更改,但是你可以通过**reindex**操作将数据复制到一个新的索引中,这个新的索引可以有不同的分片数量。 + +### 副本分片 + +副本分片代表索引的副本,ES可以设置多个索引的副本,副本的作用一是提高系统的容错性,当某个节点某个分片损坏或丢失时可以从副本中恢复。二是提高ES的查询效率,ES会自动对搜索请求进行负载均衡。 + +- 每个主分片和其副本分片不能存在于同一个节点上,所以最低的可用配置是两个节点互为主备。 +- 副本分片是不能直接写入数据的,只能通过主分片做数据同步。 + +以下是如何在创建索引时配置主分片和副本分片的示例: + +```json +PUT /my_index +{ + "settings": { + "number_of_shards": 3, # 主分片数量 + "number_of_replicas": 2 # 副本分片数量 + } +} +``` + +这个设置会创建一个名为`my_index`的新索引,它有3个主分片和2个副本。也就是说,总共有9个分片 (3主 * (1原始 + 2副本))。 + +你也可以在索引创建后修改其副本分片数: + +```json +PUT /my_index/_settings +{ + "number_of_replicas": 1 +} +``` + +这将`my_index`索引的副本数从2更改为1。 + +请注意,虽然你可以在索引创建后更改副本分片的数量,但不能更改主分片的数量。因此,在创建索引时,需要仔细考虑主分片的数量。 + +## 集群状态 + +集群健康状态(Cluster Health)描述了集群的总体健康状况,分为 "Green"、"Yellow" 和 "Red"。 + +- Green:主/副分片都已经分配好且可用,集群处于最健康的状态100%可用。 +- Yellow:主分片可用,但是至少有一个副本是未分配的。这种情况下数据也是完整的,但是集群的高可用性会被弱化。 +- Red:至少有一个不可用的主分片。此时只是部分数据可以查询,已经影响到了整体的读写,需要重点关注。 + +## 健康值检查 + +在Elasticsearch中,你可以使用两种主要的API来检查集群的健康状况:`_cat/health`和`_cluster/health`。虽然它们提供了相似的信息,但是它们的输出格式不同。 + +`_cat/health`:该API以简洁的表格式返回关于集群健康的信息。它易于阅读和理解。 + +示例:`GET _cat/health?v` + +这将返回如下所示的输出: + +``` +epoch timestamp cluster status node.total node.data shards pri relo init unassign pending_tasks max_task_wait_time active_shards_percent +1605102382 13:59:42 elasticsearch green 3 3 12 6 0 0 0 0 - 100.0% +``` + +返回参数说明: + +| 参数 | 含义 | +| --------------------- | -------------------------- | +| epoch | 自Unix Epoch以来的秒数 | +| timestamp | 当前时间戳 | +| cluster | 集群名称 | +| status | 集群状态(绿色、黄色或红色) | +| node.total | 集群中的节点总数 | +| node.data | 集群中承载数据的节点数 | +| shards | 集群中的分片总数 | +| pri | 集群中的主要分片数量 | +| relo | 正在进行重定位的分片数量 | +| init | 初始化的分片数量 | +| unassign | 未分配的分片数量 | +| pending_tasks | 等待执行的集群级任务数量 | +| max_task_wait_time | 等待任务的最长时间 | +| active_shards_percent | 活动分片占总分片的百分比 | + +- `_cluster/health`:这个API 以JSON格式返回关于集群健康的详细信息。它提供更丰富的数据,并因此更适合编程访问。 + +示例:`GET _cluster/health` + +这将返回如下所示的输出: + +```json +{ + "cluster_name" : "elasticsearch", + "status" : "yellow", + "timed_out" : false, + "number_of_nodes" : 1, + "number_of_data_nodes" : 1, + "active_primary_shards" : 12, + "active_shards" : 12, + "relocating_shards" : 0, + "initializing_shards" : 0, + "unassigned_shards" : 2, + "delayed_unassigned_shards": 0, + "number_of_pending_tasks" : 0, + "number_of_in_flight_fetch": 0, + "task_max_waiting_in_queue_millis": 0, + "active_shards_percent_as_number": 85.7 +} +``` + +返回参数说明: + +| 参数 | 描述 | +| ---------------------------------- | ------------------------------------------------------------ | +| `cluster_name` | 集群的名称。 | +| `status` | 集群的状态,它可能的值有:`green`、`yellow` 或者 `red`。`green` 表示所有主要和副本分片都是活动的。`yellow` 表示所有主要分片是活动的但不是所有副本都是活动的。`red` 表示至少一个主要分片不是活动的。 | +| `timed_out` | 如果请求超时,该值为 true。 | +| `number_of_nodes` | 集群中的节点数。 | +| `number_of_data_nodes` | 集群中执行数据相关操作的节点数。 | +| `active_primary_shards` | 当前活动的主分片数。 | +| `active_shards` | 当前活动的分片数。 | +| `relocating_shards` | 正在重新定位的分片数。 | +| `initializing_shards` | 初始化中的分片数。 | +| `unassigned_shards` | 未分配的分片数。 | +| `delayed_unassigned_shards` | 延迟未分配的分片数。 | +| `number_of_pending_tasks` | 等待执行的集群级别更改的数量。 | +| `number_of_in_flight_fetch` | 当前正在进行的获取操作数。 | +| `task_max_waiting_in_queue_millis` | 在执行队列中等待的任务的最长时间(以毫秒为单位)。 | +| `active_shards_percent_as_number` | 活动分片所占的百分比。 | + +## 索引和文档 + +ES中索引可以类比为关系型数据库中的Table,在7.0版本之前index由若干个type组成,type实际上是文档的逻辑分类,而文档是ES存储的最小单元。文档(doc)可以类比为关系型数据库中的行,每个文档都有一个文档id。 + +**7.0及之后弱化了type的概念,7.x版本index只有一个type:_doc** + diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\250\241\347\263\212\346\220\234\347\264\242.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\250\241\347\263\212\346\220\234\347\264\242.md" new file mode 100644 index 0000000..1221ce1 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\250\241\347\263\212\346\220\234\347\264\242.md" @@ -0,0 +1,302 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +在 Elasticsearch 中,模糊搜索是一种近似匹配的搜索方式。它允许找到与搜索词项相似但不完全相等的文档。 + +## 前缀匹配:prefix + +前缀匹配通过指定一个前缀值,搜索并匹配索引中指定字段的文档,找出那些以该前缀开头的结果。 + +在 Elasticsearch 中,可以使用 `prefix` 查询来执行前缀搜索。其基本语法如下: + +```json +{ + "query": { + "prefix": { + "field_name": { + "value": "prefix_value" + } + } + } +} +``` + +其中,`field_name` 是要进行前缀搜索的字段名,`prefix_value` 是要匹配的前缀值。 + +**注意**:前缀搜索匹配的是term,而不是field,换句话说前缀搜索匹配的是分析之后的词项,并且不计算相关度评分。 + +**优点:** + +- 快速:前缀搜索使用倒排索引加速匹配过程,具有较高的查询性能。 +- 灵活:可以基于不同的字段进行前缀搜索,适用于各种数据模型。 + +**缺点:** + +- 前缀无法通配:前缀搜索只能匹配以指定前缀开始的文档,无法进行通配符匹配。 +- 高内存消耗:如果前缀值过长或前缀匹配的文档数量过多,将占用较大的内存资源,并且前缀搜索是没有缓存的。 + +### index_prefixes + +`index_prefixes`参数允许对词条前缀进行索引,以加速前缀搜索。它接受以下可选设置: + +- **min_chars**:索引的最小前缀长度(包含),必须大于0,默认值为2。 +- **max_chars**:索引的最大前缀长度(包含),必须小于20,默认值为5。 + +index_prefixe可以理解为在索引上又建了层索引,会为词项再创建倒排索引,会加快前缀搜索的时间,但是会浪费大量空间,本质还是空间换时间。 + +## 通配符匹配:wildcard + +通配符匹配允许使用通配符来匹配文档中的字段值,是一种基于模式匹配的搜索方法,它使用通配符字符来匹配文档中的字段值。 + +通配符字符包括 `*` 和 `?`,其中 `*` 表示匹配任意数量(包括零个)的字符,而 `?` 则表示匹配一个字符。 + +在通配符搜索中,可以在搜索词中使用通配符字符,将其替换为要匹配的任意字符或字符序列。通配符搜索可以应用于具有文本类型的字段。 + +**注意**:通配符搜索和前缀搜索一样,匹配的都是分析之后的词项。 + +**请求示例:** 以下是一个使用通配符搜索的示例请求: + +``` +GET /my_index/_search +{ + "query": { + "wildcard": { + "title.keyword": { + "value": "elast*" + } + } + } +} +``` + +在上述示例中,我们对名为 `my_index` 的索引执行了一个通配符搜索。我们指定了要搜索的字段为 `title.keyword`,并使用 `elast*` 作为通配符搜索词。这将匹配 `title.keyword` 字段中以 `elast` 开头的任意字符序列。 + +## 正则表达式匹配:regexp + +正则表达式匹配(regexp)是一种基于正则表达式模式进行匹配的搜索方法,它允许使用正则表达式来匹配文档中的字段值。 + +**用途:** 正则表达式匹配在以下情况下非常有用: + +- 高级模式匹配:当需要更复杂的模式匹配时,正则表达式匹配提供了更多的灵活性和功能。 +- 模糊搜索:通过使用通配符和限定符,可以进行更精确的模糊匹配。 + +**优缺点:** + +- 优点: + - 强大的模式匹配:正则表达式匹配提供了强大且灵活的模式匹配功能,可以满足各种复杂的搜索需求。 + - 可定制性:通过使用正则表达式,您可以根据具体需求编写自定义的匹配规则。 +- 缺点: + - 性能:正则表达式匹配的性能较低,尤其是在大型索引上进行正则表达式匹配可能会导致搜索延迟和资源消耗增加。 + - 学习成本高:使用正则表达式需要一定的学习和理解,对于不熟悉正则表达式的人来说可能会有一定的难度。 + +**语法**: + +```json +GET /_search +{ + "query": { + "regexp": { + "": { + "value": "", + "flags": "ALL", + } + } + } +} +``` + +**请求示例:** 以下是一个使用正则表达式匹配的示例请求: + +``` +GET /my_index/_search +{ + "query": { + "regexp": { + "title.keyword": { + "value": "elast.*", + "flags": "ALL" + } + } + } +} +``` + +在上述示例中,我们对名为 `my_index` 的索引执行了一个正则表达式匹配。我们指定要搜索的字段为 `title.keyword`,并使用 `elast.*` 作为正则表达式匹配模式。这将匹配 `title.keyword` 字段中以 `elast` 开头的字符序列,并且后面可以是任意字符。 + +**注意**:regexp查询的性能可以根据提供的正则表达式而有所不同。为了提高性能,应避免使用通配符模式,如 `.` 或 `.?+` 未经前缀或后缀。 + +### flags + +正则表达式匹配的 `flags` 参数用于指定正则表达式的匹配选项。它可以修改正则表达式的行为以进行更灵活和精确的匹配。 + +**语法:** 在正则表达式匹配的查询中,`flags` 参数是一个字符串,它可以包含多个选项,并用逗号分隔。每个选项都由一个字母表示。 + +以下是常用的 `flags` 参数选项及其说明: + +- `ALL`:启用所有选项,相当于同时启用了 `ANYSTRING`, `COMPLEMENT`, `EMPTY`, `INTERSECTION`, `INTERVAL`, `NONE`, `NOTEMPTY`, 和 `NOTNONE`。 +- `ANYSTRING`:允许使用 `.` 来匹配任意字符,默认情况下 `.` 不匹配换行符。 +- `COMPLEMENT`:求反操作,匹配除指定模式外的所有内容。 +- `EMPTY`:匹配空字符串。 +- `INTERSECTION`:允许使用 `&&` 运算符来定义交集。 +- `INTERVAL`:允许使用 `{}` 来定义重复数量的区间。 +- `NONE`:禁用所有选项,相当于不设置 `flags` 参数。 +- `NOTEMPTY`:匹配非空字符串。 +- `NOTNONE`:匹配任何内容,包括空字符串。 + +flags参数用到的场景比较少,做下了解即可。 + + +## 模糊匹配:fuzzy + +模糊查询(Fuzzy Query)是 Elasticsearch 中一种近似匹配的搜索方式,用于查找与搜索词项相似但不完全相等的文档。基于编辑距离(Levenshtein 距离)计算两个词项之间的差异。 + +它通过允许最多的差异量来匹配文档,以处理输入错误、拼写错误或轻微变体的情况。 + +**用途**:纠正拼写错误,模糊查询可用于纠正用户可能犯的拼写错误,可以提供宽松匹配,使搜索结果更加全面。 + +- 混淆字符 (**b**ox → fox) +- 缺少字符 (**b**lack → lack) +- 多出字符 (sic → sic**k**) +- 颠倒次序 (a**c**t → **c**at) + +请求示例: + +```json +GET /my_index/_search +{ + "query": { + "fuzzy": { + "title": { + "value": "quick", + "fuzziness": "2" + } + } + } +} +``` + +`fuzziness`是编辑距离,即:**编辑成正确字符所需要挪动的字符的数量** + +### 参数 + +- **value**:必须,关键词。 +- **fuzziness**:编辑距离,范围是(0,1,2),并非越大越好,过大召回率高但结果不准确,默认是:AUTO,即自动从0~2取值。 + - 两段文本之间的Damerau-Levenshtein距离是使一个字符串与另一个字符串匹配所需的插入、删除、替换和调换的数量。 + - 距离公式:Levenshtein是lucene的概念,ES做了改进,使用的是基于Levenshtein的Damerau-Levenshtein,比如:axe=>aex。 Levenshtein会算作2个距离,而Damerau-Levenshtein只会算成1个距离。 +- **transpositions**:可选,布尔值,指示编辑是否包括两个相邻字符的变位(ab→ba),默认为true,使用的是Damerau-Levenshtein,如果为false,就会使用Levenshtein去计算。 + +## 短语前缀:match_phrase_prefix + +先来了解下match_phrase,match_phrase检索有如下特点: + +- match_phrase会分词。 +- 被检索字段必须包含match_phrase中的所有词项并且顺序必须是相同的。 +- 默认被检索字段包含的match_phrase中的词项之间不能有其他词项。 + +`match_phrase_prefix`与`match_phrase`相同,但是它多了一个特性,就是它允许在文本的最后一个词项(term)上的前缀匹配。 + +如果是一个单词,比如a,它会匹配文档字段所有以a开头的文档,如果是一个短语,比如 "this is ma" ,他会先在倒排索引中做以ma做前缀搜索,然后在匹配到的doc中以 "this is" 做match_phrase查询。 + +`match_phrase_prefix` 查询是一种结合了短语匹配和前缀匹配的查询方式。它用于在某个字段中匹配包含指定短语前缀的文档。 + +具体来说,`match_phrase_prefix` 查询会将查询字符串分成两部分:前缀部分和后缀部分。然后它会先对前缀部分进行短语匹配,找到以该短语开头的文档片段;接下来,针对符合前缀匹配的文档片段,再对后缀部分进行前缀匹配,从而进一步筛选出最终匹配的文档。 + +以下是 `match_phrase_prefix` 查询的示例: + +``` +GET /my_index/_search +{ + "query": { + "match_phrase_prefix": { + "title": { + "query": "quick brown f", + "max_expansions": 10 + } + } + } +} +``` + +解释: + +- 在上述示例中,我们执行了一个 `match_phrase_prefix` 查询。 +- 查询字段为 `title`,我们要求匹配的短语是 "quick brown f"。 +- `max_expansions` 参数用于控制扩展的前缀项数量(默认为 50)。这里我们设置为 10,表示最多扩展 10 个前缀项进行匹配。 + +`match_phrase_prefix` 查询适用于需要同时支持短语匹配和前缀匹配的场景。例如,当用户输入一个搜索短语的前缀时,可以使用该查询来获取相关的文档结果。 + +### 参数 + +- **analyzer**:指定何种分析器来对该短语进行分词处理。 +- **max_expansions**:限制匹配的最大词项,有点类似SQL中的limit,默认值是50。 +- **boost**:用于设置该查询的权重。 +- **slop**:允许短语间的词项(term)间隔,slop 参数告诉 match_phrase 查询词条相隔多远时仍然能将文档视为匹配,相隔多远意思就是说为了让查询和文档匹配你需要移动词条多少次,默认是0。 + +## ngram & edge ngram + +ngram 和 edge ngram 是两种用于分析和索引文本的字符级别的分词器。 + +- **ngram**:ngram 分词器将输入的文本按照指定的长度切割成一系列连续的字符片段。例如,对于字符串 "Hello",使用 2-gram(双字符)分词器会生成 ["He", "el", "ll", "lo"]。 +- **edge ngram**:edge ngram 分词器是 ngram 分词器的一种特殊形式,它只会产生从单词开头开始的 ngram 片段。例如,对于字符串 "Hello",使用 2-gram(双字符)edge ngram 分词器会生成 ["He", "el"]。 edge ngram作用类似fuzzy,但是性能要比fuzzy好,当然也更占用磁盘空间,原因是因为edge ngram对更细粒度的token创建了索引。 + +参数: + +- **min_gram**:创建索引所拆分字符的最小阈值。 +- **max_gram**:创建索引所拆分字符的最大阈值。 + +以下是一个示例来说明如何在 Elasticsearch 中使用 ngram 和 edge ngram 分词器: + +```json +PUT /my_index +{ + "settings": { + "analysis": { + "analyzer": { + "my_ngram_analyzer": { + "tokenizer": "my_ngram_tokenizer" + }, + "my_edge_ngram_analyzer": { + "tokenizer": "my_edge_ngram_tokenizer" + } + }, + "tokenizer": { + "my_ngram_tokenizer": { + "type": "ngram", + "min_gram": 2, + "max_gram": 4 + }, + "my_edge_ngram_tokenizer": { + "type": "edge_ngram", + "min_gram": 2, + "max_gram": 10 + } + } + } + }, + "mappings": { + "properties": { + "title": { + "type": "text", + "analyzer": "my_ngram_analyzer" + }, + "keyword": { + "type": "text", + "analyzer": "my_edge_ngram_analyzer" + } + } + } +} +``` + +在上述示例中,我们创建了一个名为 `my_index` 的索引,定义了两个不同的分词器和对应的字段映射: + +- `my_ngram_analyzer` 使用了 ngram 分词器,适用于处理 `title` 字段。 +- `my_edge_ngram_analyzer` 使用了 edge ngram 分词器,适用于处理 `keyword` 字段。 + +通过在查询时指定相应的分析器,可以使用这些分词器来进行文本搜索、前缀搜索等操作。 + +注意:ngram 作为 tokenizer 的时候会把空格也包含在内,而作为 token filter 时,空格不会作为处理字符。 \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\267\261\345\272\246\345\210\206\351\241\265\351\227\256\351\242\230.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\267\261\345\272\246\345\210\206\351\241\265\351\227\256\351\242\230.md" new file mode 100644 index 0000000..00ea092 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\346\267\261\345\272\246\345\210\206\351\241\265\351\227\256\351\242\230.md" @@ -0,0 +1,370 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +ES的深度分页问题指的是在大数据集和大页数的情况下,通过持续向后翻页来获取查询结果的一种性能问题。当页码非常高时,ES需要遍历大量文档才能找到正确的分页位置,导致性能和查询速度变慢。 + +## 深度分页(Deep Paging) + +分页是Elasticsearch中最常见的查询场景之一,正常情况下分页代码如下所示: + +```json +GET my_index/_search +{ + "from": 0, + "size": 5 +} +``` + +以下是一个示例响应输出,具体结果会根据实际数据而有所不同: + +```json +{ + "took" : 10, + "timed_out" : false, + "_shards" : { + "total" : 1, + "successful" : 1, + "skipped" : 0, + "failed" : 0 + }, + "hits" : { + "total" : { + "value" : 100, + "relation" : "eq" + }, + "max_score" : 1.0, + "hits" : [ + { + "_index" : "my_index", + "_type" : "_doc", + "_id" : "1", + "_score" : 1.0, + "_source" : { + "title" : "Document 1", + "content" : "This is the content of document 1." + } + } + ...... + ] + } +} +``` + +在上述示例中,响应包含了以下信息: + +- took:执行搜索所花费的时间(以毫秒为单位)。 +- timed_out:指示搜索是否超时。 +- _shards:索引的分片信息。 +- hits:包含了搜索结果的对象。 + - `total`:匹配到的文档总数。 + - `max_score`:最高得分的文档的分值。 + - `hits`:实际匹配到的文档数组。 + +但是当我们查询的数据页数特别大, `from + size`大于 `10000`的时候,就会出现问题。 + +```json +GET my_index/_search +{ + "from": 10000, + "size": 5 +} +``` + +报错信息如下所示: + +> "reason": "Result window is too large, from + size must be less than or equal to: [10000] but was [10005]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting." + +报错信息的解释为当前查询的结果超过了 `10000`的最大值,这个错误表示请求中的偏移量(`from`)加上大小(`size`)超过了索引级别参数 `index.max_result_window` 所允许的限制。 + +默认情况下,该限制为10000,由 `max_result_window` 参数控制。在示例中,请求指定的`from`值为10000加上`size`值为5,总计为10005,超过了默认限制。 + +## 深度分页的性能问题和危害 + +首先我们要达成一个共识: + +分页查询的时候数据肯定是按照某种顺序排列的,ES中如果不人工指定排序字段,那么最终结果将按照相关度评分排序。 + +**分布式系统都面临着同一个问题,数据的排序不可能在同一个节点完成** + +举个例子:比如我想在一个拥有10万名考生的索引中查询成绩排在10001~10100位的100名考生信息。 + +这个看似简单的查询实际上并不简单。 + +假设我们有一个名为"exam_info"的索引,其中存放着10万名考生的考试信息。 + +由于Elasticsearch的分布式特性和数据分片策略,索引数据在写入时无法预知后续业务查询的具体排序规则,因此数据的排序是随机的。 + +而且,为了提高数据的准确性,在Elasticsearch中,数据会被均匀地分布在多个分片中。 + +假设现在有5个分片,并且每个分片中有2万条有效数据。根据需求,我们需要查询成绩排在10001到10100位的一百名考生的信息。为了实现这个目标,首先需要按照成绩进行倒序排列,然后查询按照成绩排序的10001到10100位的学生信息。 + +在单机数据库中,这个查询逻辑相对简单,只需将10万名学生的成绩排序,然后从前10100条数据中取出第10001~10100条数据,即按照每页100名学生的方式查询第101页的数据。 + +**然而,在分布式数据库中,情况就不同了,考生的成绩被分散保存在每个分片中,无法保证要查询的这一百名考生的成绩都在同一个分片中** + +实际上,结果很可能分布在每个分片中。换句话说,从任意一个分片中取出的前10100名考生的成绩,都不一定是总成绩的前10100名。 + +**为了解决这个问题,唯一的方法是从每个分片中取出当前分片的前10100名考生的成绩,然后进行汇总(合并排序),再从汇总后的数据中查询前10100名的成绩。只有这样才能确保查询到的成绩是整个索引中的前10100名** + +要理解这个过程,可以类比为从保存世界所有国家短跑运动员成绩的索引中查询短跑世界前三名。 + +每个国家对应一个分片的数据,每个国家会选出成绩最好的前三位运动员参加最后的竞争。然后,从每个国家选出的前三名运动员中再次选出全球前三名。只有经过这两个阶段的筛选和排序,才能得到确切的世界前三名。 + +现在知道为什么深度分页会导致性能问题了吧。 + +**每次有序的查询都会在每个分片中执行单独的查询,然后进行数据的二次排序,而这个二次排序的过程是发生在Heap中的,也就是说当你单次查询的数量越大,那么堆内存中汇总的数据也就越多,对内存的压力也就越大** + +这里的单次查询的数据量取决于你查询的是第几条数据而不是查询了几条数据,比如你希望查询的是第 `10001~10100`这一百条数据,但是ES必须将前 `10100`条全部取出进行二次查询。 + +因此,如果查询的数据排序越靠后,就越容易导致OOM(Out Of Memory)情况的发生,频繁的深分页查询会导致频繁的FGC。 + +ES为了避免用户在不了解其内部原理的情况下而做出错误的操作,设置了一个阈值,即 `max_result_window`,其默认值为 `10000`,通过设定一个合理的阈值,避免初学者分页查询时由于单页数据过大而导致OOM。其作用是为了保护堆内存不被错误操作导致溢出。 + + `max_result_window` 的合理大小是需要通过各项指标参数来衡量确定的,比如用户量、数据量、物理内存的大小、分片的数量等等。通过监控数据和分析各项指标从而确定一个最佳值,并非越大越好。 + +## 深度分页解决方案 + +### 滚动查询:Scroll Search + +Scroll Search是一种用于处理大量数据的分批次查询机制。通过使用滚动搜索,可以在不影响性能的情况下逐批次地获取结果集。 + +假设我们有一个名为"exam_info"的索引,其中存放着10万名考生的考试信息。我们希望按照成绩进行倒序排序,并获取前100名考生的信息。 + +示例输入: + +```json +GET /exam_info/_search?scroll=5m +{ + "size": 100, + "sort": [ + { "score": "desc" } + ] +} +``` + +参数解释: + +- `scroll`:定义滚动搜索的时间间隔。这里设置为5分钟,在5分钟内完成整个滚动搜索操作。 +- `size`:每个滚动搜索批次返回的文档数量。这里设置为100,表示每次获取100个考生的信息。 +- `sort`:指定按照成绩字段("score")进行倒序排序。 + +示例输出: + +```json +{ + "_scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAACsFlRlQjNqSVh0VzIwdXk4UnhOTmdSc2cAAAAAADFLW0xjb3VkT1dHcG9uejZtZURxS3oxMw==", + "hits": { + "total": 100000, + "max_score": null, + "hits": [ + { "name": "John", "score": 98 }, + { "name": "Alice", "score": 97 }, + { "name": "Bob", "score": 95 }, + ... + ] + } +} +``` + +输出解释: + +- `_scroll_id`:滚动搜索的标识符,用于后续获取下一批次结果。 +- `hits.total`:符合查询条件的总文档数。这里为10万。 +- `hits.hits`:当前批次返回的文档列表,每个文档包含考生的姓名("name")和成绩("score")。 + +在获得第一批结果后,可以使用滚动搜索的Scroll API来获取下一批结果,直到获取完整的结果集。 + +示例输入: + +```json +GET /_search/scroll +{ + "scroll": "5m", + "scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAAAACsFlRlQjNqSVh0VzIwdXk4UnhOTmdSc2cAAAAAADFLW0xjb3VkT1dHcG9uejZtZURxS3oxMw==" +} +``` + +示例输出: + +```json +{ + "_scroll_id": "DXF1ZXJ5VGhlbkZldGNoBQAAAAAAAACsFlRlQjNqSVh0VzIwdXk4UnhOTmdSc2cAAAAAADFLW0xjb3VkT1dHcG9uejZtZURxS3oxMw==", + "hits": { + "total": 100000, + "max_score": null, + "hits": [ + { "name": "Eric", "score": 94 }, + { "name": "Catherine", "score": 93 }, + { "name": "David", "score": 92 }, + ... + ] + } +} +``` + +继续使用Scroll API获取后续批次的结果,直到滚动搜索结束。 + +相关参数的含义: + +- `scroll`:定义滚动搜索的时间间隔。指定一个合适的时间段,确保在这个时间内能够完成整个滚动搜索操作。默认为1分钟,时间单位应越小越好,够当前查询使用即可。 + +时间单位: + +| `d` | Days | +| -------- | ------------ | +| `h` | Hours | +| `m` | Minutes | +| `s` | Seconds | +| `ms` | Milliseconds | +| `micros` | Microseconds | +| `nanos` | Nanoseconds | + +- `size`:每个滚动搜索批次返回的文档数量。 + + + +Scroll Search 无法保存索引状态,原因是滚动搜索是一种临时的、游标式的查询机制,仅用于获取大量数据的分批次结果。它并不会保留索引状态或缓存查询结果。 + +当执行滚动搜索时,Elasticsearch会创建一个滚动上下文(scroll context),该上下文存储了关于初始查询的一些信息,包括查询条件、排序方式等。然后,每次使用滚动上下文来获取下一批结果时,Elasticsearch都会根据该上下文重新执行查询以返回新的结果。这样可以确保在整个滚动搜索过程中,能够按顺序逐步获取完整的结果集。 + +然而,滚动搜索并不会保存查询结果或索引的快照。一旦滚动上下文被使用完毕(超过滚动时间间隔或已经遍历完所有结果),它就会被丢弃,并且之前返回的结果将不能再重现。如果需要持久化查询结果或经常使用相同的滚动上下文进行查询,可能需要考虑其他方法,如将结果存储在自定义的数据结构中或使用游标分页等技术。 + +注意: + +- Scroll上下文的存活时间是滚动的,下次执行查询会刷新,也就是说,不需要足够长来处理所有数据,它只需要足够长来处理前一批结果。保持旧段处于活动状态意味着需要更多的磁盘空间和文件句柄。确保您已将节点配置为具有充足的空闲文件句柄。 +- 为防止因打开过多Scrolls而导致的问题,ES不允许用户打开超过一定限制的Scrolls。默认情况下,打开Scrolls的最大数量为 500。此限制可以通过 `search.max_open_scroll_context`集群设置进行更新 。 + +Scroll 超时后,搜索上下文会自动删除。然而,保持Scrolls打开是有代价的,因此一旦不再使用就应明确清除Scroll上下文。 + +```json +#清除单个 +DELETE /_search/scroll +{ + "scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ==" +} + +#清除多个 +DELETE /_search/scroll +{ + "scroll_id" : [ + "scroll_id1", + "scroll_id2" + ] +} + +#清除所有 +DELETE /_search/scroll/_all +``` + +总而言之,滚动搜索是一种方便的分批次查询机制,但不适合长期保存查询结果或索引状态。它主要用于处理大量数据的查询,以提高性能和效率。 + +### Search After + +Search After 是一种基于游标的分页查询机制,用于获取大量数据的连续结果。与滚动搜索不同,Search After适用于持久化保存查询状态,并支持随时获取下一页结果。 + +假设我们有一个名为"exam_info"的索引,其中存放着10万名考生的考试信息。我们希望按照成绩进行倒序排序,并获取前100名考生的信息。 + +示例输入: + +```json +GET /exam_info/_search +{ + "size": 100, + "sort": [ + { "score": "desc" } + ] +} +``` + +参数解释: + +- `size`:每页返回的文档数量。这里设置为100,表示每次获取100个考生的信息。 +- `sort`:指定按照成绩字段("score")进行倒序排序。 + +示例输出: + +```json +{ + "took": 5, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": 100000, + "max_score": null, + "hits": [ + { "name": "John", "score": 98 }, + { "name": "Alice", "score": 97 }, + { "name": "Bob", "score": 95 }, + ... + ] + } +} +``` + +输出解释: + +- `took`:查询所花费的时间,单位为毫秒。 +- `hits.total`:符合查询条件的总文档数。这里为10万。 +- `hits.hits`:当前页返回的文档列表,每个文档包含考生的姓名("name")和成绩("score")。 + +在获得第一页结果后,可以使用Search After来获取下一页的结果。 + +示例输入: + +```json +GET /exam_info/_search +{ + "size": 100, + "sort": [ + { "score": "desc" } + ], + "search_after": [97] +} +``` + +参数解释: + +- `size`:每页返回的文档数量。与初始请求保持一致。 +- `sort`:指定按照成绩字段进行倒序排序。与初始请求保持一致。 +- `search_after`:指定上一页最后一条数据的排序值,以此作为游标进行下一页查询。 + +示例输出: + +```json +{ + "took": 2, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": 100000, + "max_score": null, + "hits": [ + { "name": "Eva", "score": 94 }, + { "name": "Daniel", "score": 93 }, + { "name": "Catherine", "score": 92 }, + ... + ] + } +} +``` + +Search After 和 Scroll Search 的主要区别如下: + +- 结果排序:Search After依赖排序字段进行分页,需要指定相应的排序方式。而Scroll Search可以根据查询条件对结果进行排序。 +- 时间限制:Search After没有时间限制,可按需获取结果。而Scroll Search需要设置滚动时间间隔,超过该时间将失去滚动上下文。 + +总结起来,ES的深度分页在处理大规模数据集时是一项非常有用的功能,深度分页查询可能会面临一些性能和可靠性方面的挑战,需要根据具体情况进行权衡和优化。 \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204CRUD.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204CRUD.md" new file mode 100644 index 0000000..2102c25 --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204CRUD.md" @@ -0,0 +1,326 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + +[TOC] + +本篇主要是介绍Elasticsearch中索引的基本操作API,即增删改查(CRUD)。 + +## 创建索引 + +```JSON +PUT /my_index?pretty +``` + +`?pretty`是一个可选参数,如果加上,Elasticsearch 将返回格式化(即缩进、换行等使结果更易读)过的 JSON。 + +输出示例: + +```json +{ + "acknowledged" : true, + "shards_acknowledged" : true, + "index" : "my_index" +} +``` + +这个输出表示索引已成功创建。`"acknowledged": true` 表示请求已被接受,`"shards_acknowledged": true` 表示所有的分片都已经准备就绪,`"index": "my_index"` 是你刚才创建的索引名称。 + +## 删除索引 + +```JSON +DELETE /my_index?pretty +``` + +假设 `my_index` 索引存在并已成功删除,则输出如下: + +```json +{ + "acknowledged" : true +} +``` + +这个响应表示Elasticsearch已确认删除请求。 + +**注意:该操作是不可逆的,一旦删除,所有存储在索引中的数据都将被永久移除,因此在执行此操作时务必谨慎** + +## 查询数据 + +请求: + +```json +GET /my_index/_search +{ + "query": { + "match": { + "field_name": "my_value" + } + } +} +``` + +在此示例中,我们在名为 `my_index` 的索引上进行搜索,查找字段 `field_name` 中值为 `my_value` 的文档。 + +响应: +Elasticsearch返回的响应包括一系列关于查询的信息,例如查询所花费的时间、是否超时、命中的文档数等。同时,返回的结果也会包括所有匹配的文档。 + +```json +{ + "took": 30, + "timed_out": false, + "_shards": { + "total": 5, + "successful": 5, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 1, + "relation": "eq" + }, + "max_score": 1.0, + "hits": [ + { + "_index": "my_index", + "_type": "_doc", + "_id": "1", + "_score": 1.0, + "_source": { + "field_name": "my_value" + } + } + ] + } +} +``` + +请注意,以上只是基本的示例,实际ES查询可能会复杂得多,包含过滤、聚合、排序等多种操作。 + +获取所有索引数据的信息 + +```JSON +GET _cat/indices?v +``` + +示例输出: + +``` +health status index uuid pri rep docs.count docs.deleted store.size pri.store.size +green open .kibana_task_manager_1 C9SW_Y7cQ8-TJQGArKRcDA 1 0 2 0 31.8kb 31.8kb +yellow open my_index 7V75Rtf1QBCslQvWWPOS2A 1 1 0 0 283b 283b +green open .apm-agent-configuration en6N1awvRZSLySqh0yjleA 1 0 0 0 283b 283b +green open .kibana_1 9-gHntOQTCeM8RqViBAaog 1 0 8 1 19.1kb 19.1kb +``` + +返回的结果会包含以下列: + +- `health`:索引的健康状态。它可以是"green"(一切正常),"yellow"(至少所有主分片都是可用的,但不是所有副本分片都可用)或者"red"(有主分片无法使用)。 +- `status`:索引的状态。通常情况下,可能的值是"open"或"close"。 +- `index`:索引的名称。 +- `uuid`:代表索引的唯一标识符。 +- `pri`:主分片的数量。 +- `rep`:每个主分片的副本数。 +- `docs.count`:存储在索引中的文档数量。 +- `docs.deleted`:已删除但尚未完全从存储中移除的文档数量。 +- `store.size`:索引当前占用的总物理存储空间。 +- `pri.store.size`:主分片占用的物理存储空间。 + +查询指定文档id + +```JSON +GET /my_index/_doc/doc_id +``` + +返回如下: + +```json +{ + "_index": "my_index", + "_type": "_doc", + "_id": "1", + "_version": 3, + "_seq_no": 2, + "_primary_term": 2, + "found": true, + "_source": { + "field1": "123", + "field2": "456" + } +} +``` + +这个命令会返回一个包含以下字段的 JSON 响应: + +- `_index`:文档所在的索引。 +- `_type`:文档的类型。在 7.x 版本中,这通常是 `_doc`。 +- `_id`:文档的 ID。 +- `_version`: 文档的版本号。每当文档更新时,此数字都会增加。 +- `_seq_no`:序列号,每次对文档进行操作时此数字会增加。 +- `_primary_term`: 主要期限数,主要用于处理并发控制。 +- `found`:如果找到了文档,则此值为 true;否则,为 false。 +- `_source`: 文档的原始内容。 + +如果没有找到与给定 ID 匹配的文档,Elasticsearch 会返回一个状态码为 404 的响应,并且 `found` 字段的值将为 false。 + +## 添加 & 更新数据 + +```JSON +PUT /index/_doc/doc_id +{ + JSON数据 +} + +//例如:PUT /my_index/_doc/1 +//{ +// "field1": "123", +// "field2": "456" +//} +``` + +PUT既可以用于添加数据,也可以用于更新数据,比如我想更新文档 1 的name字段为:小明,可以这么写: + +```JSON +PUT /my_index/_doc/1 +{ +"name": "小明" +} +``` + +**注意:PUT既可以用于插入,也可以用于更新,所以PUT的更新是全量更新,而不是部分更新。也就是上面的语句执行之后,文档会被直接替换,只会有name字段,字段值为小明** + +如果我们只想部分更新文档中的字段,可以使用POST,示例如下: + +```JSON +POST /index/_update/1 +{ + "doc": { + "name": "小明" + } +} +``` + +这个命令只会更新文档中的 name 字段为小明。其他字段还是保留原样。 + +## cat命令 + +cat命令在ES中会经常使用,下面介绍cat命令中常用的几个命令。 + +### 参数 + +cat命令组成形式是:`GET /_cat/indices?format=json&pretty`, `?`之前是命令,之后是参数,多个参数用`&`分隔。 + +参数有下: + +```JSON +//v 显示更加详细的信息 +GET /_cat/master?v +//help 显示命令结果字段说明 +GET /_cat/master?help +//h 显示命令结果想要展示的字段 +GET /_cat/master?h=ip,node +GET /_cat/master?h=i*,node +//format 显示命令结果展示格式,支持格式类型:text json smile yaml cbor +GET /_cat/indices?format=json&pretty +//s 显示命令结果按照指定字段排序 +GET _cat/indices?v&s=index:desc,docs.count:desc +``` + +### 常用命令 + +**aliases :显示别名** + +```JSON +GET /_cat/aliases +``` + +获取所有索引别名,如果想获得某个索引的别名可以使用:`GET index/alias`。 + +**allocation :显示每个节点的分片数和磁盘使用情况** + +```JSON +GET /_cat/allocation +``` + +**count :显示整个集群或者索引的文档个数** + +```JSON +GET /_cat/count +GET /_cat/count/index +``` + +**fielddata :显示每个节点字段所占的堆空间** + +```JSON +GET /_cat/fielddata +GET /_cat/fielddata?fields=name,addr +``` + +**health :显示集群是否健康** + +```JSON +GET /_cat/health +``` + +**indices :显示索引的情况** + +```JSON +GET /_cat/indices +GET /_cat/indices/index +``` + +**master: 显示master节点信息** + +```JSON +GET /_cat/master +``` + +**nodes :显示所有node节点信息** + +```JSON +GET /_cat/nodes +``` + +**recovery :显示索引恢复情况** + +当索引迁移的任何时候都可能会出现恢复情况,例如,快照恢复、复制更改、节点故障或节点启动期间。 + +```JSON +GET /_cat/recovery +``` + +**thread_pool :显示每个节点线程运行情况** + +```JSON +GET /_cat/thread_pool +GET /_cat/thread_pool/bulk +GET /_cat/thread_pool/bulk?h=id,name,active,rejected,completed +``` + +**shards :显示每个索引各个分片的情况** + +展示索引的各个分片,主副分片,文档个数,所属节点,占存储空间大小等信息。 + +```JSON +GET /_cat/shards +GET /_cat/shards/index +GET _cat/shards?h=index,shard,prirep,state,unassigned.reason +``` + +分片的状态:`INITIALIZING`初始化;`STARTED`分配完成;`UNASSIGNED`不能分配;可以通过`unassigned.reason`属性查看不能分配的原因。 + +**segments :显示每个segment的情况** + +包括属于索引,节点,主副,文档数等 + +```JSON +GET /_cat/segments +GET /_cat/segments/index +``` + +**templates :显示每个template的情况** + +```JSON +GET /_cat/templates +GET /_cat/templates/mytempla* +``` \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204\346\211\271\351\207\217\346\223\215\344\275\234.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204\346\211\271\351\207\217\346\223\215\344\275\234.md" new file mode 100644 index 0000000..70f6b1e --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204\346\211\271\351\207\217\346\223\215\344\275\234.md" @@ -0,0 +1,211 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +Elasticsearch 提供了 `_mget` 和 `_bulk` API 来执行批量操作,它允许你在单个 HTTP 请求中进行多个索引获取/删除/更新/创建操作。这种方法比发送大量的单个请求更有效率。 + +## 基于 mget 的批量查询 + +mget(multi-get) API用于批量检索多个文档。它可以通过一次请求获取多个文档的内容,并提供了一些参数来控制检索行为。下面是mget API的请求示例、响应示例以及一些常用参数的含义: + +请求示例: + +```json +POST /_mget +{ + "docs": [ + { + "_index": "my_index", + "_id": "1" + }, + { + "_index": "my_index", + "_id": "2" + } + ] +} +``` + +上述示例中,我们向`my_index`索引发出一个mget请求,要求检索id为1和2的两个文档。 + +响应示例: + +```json +{ + "docs": [ + { + "_index": "my_index", + "_id": "1", + "_source": { + "field1": "value1", + "field2": "value2" + }, + "found": true + }, + { + "_index": "my_index", + "_id": "2", + "_source": { + "field1": "value3", + "field2": "value4" + }, + "found": true + } + ] +} +``` + +上述示例中,响应结果中包含了每个请求文档的结果。每个结果都有`_source`字段,其中包含了文档的实际内容。同时,还有一个`found`字段指示是否找到了对应的文档。 + +以下是一些常用的mget参数及其含义: + +- `_index`:指定索引名称,表示要检索的文档所在的索引。 +- `_id`:指定文档的唯一标识符,用于唯一确定要检索的文档。 +- `_source`:设置为false可以禁用返回文档的内容,只返回元数据信息。默认为true,返回完整的文档内容。 +- `stored_fields`:指定要返回的存储字段(stored fields),用逗号分隔多个字段名。这些字段必须在映射中设置了`store`属性才能被返回。 +- `_source_includes`和`_source_excludes`:允许选择性地包含或排除返回文档中的特定字段,以控制返回结果的内容。 +- `routing`:指定文档的路由值,用于决定将文档存储在哪个分片上。如果索引设置了自定义路由策略,必须提供正确的路由值。 + +这些参数可以通过请求体中的每个文档对象进行设置,例如: + +```json +{ + "_index": "my_index", + "_id": "1", + "_source": false, + "stored_fields": "field1" +} +``` + +## 基于 bulk 的批量增删改 + +bulk API允许执行批量的索引、删除和更新操作。它可以通过一次请求同时处理多个操作,提高数据的写入效率。 + +bulk API中,请求是通过一行一行的JSON数据进行定义的。每个操作(索引、删除、更新)都需要按照特定格式写在一行中。 + +格式要求如下: + +1. 每个操作必须以一个操作描述符开始,例如`index`、`delete`、`update`。 +2. 操作描述符后面必须跟着一个JSON对象,该对象包含操作所需的参数和数据。 +3. 每个操作及其对应的JSON数据必须用换行符分隔。 + +示例: + +``` +{操作描述符} +{JSON数据} +{操作描述符} +{JSON数据} +... +``` + +注意以下几点: + +- 请求数据中的每一行都必须是有效的JSON格式,且不能有多余的空格或换行符。 +- 在一个bulk请求中,可以包含任意数量的操作。 +- bulk请求可以一次性执行多个操作,提高效率,但也会增加单个请求的复杂性和长度。 + +下面是bulk API的请求示例、响应示例以及一些常用参数的含义。 + +请求示例: + +```json +POST /_bulk +{"index":{"_index":"my_index","_id":"1"}} +{"field1":"value1","field2":"value2"} +{"delete":{"_index":"my_index","_id":"2"}} +{"update":{"_index":"my_index","_id":"3"}} +{"doc":{"field1":"updated_value"}} +``` + +上述示例展示了一个包含三个操作的bulk请求: + +1. 索引(index)操作:将一个新文档插入到`my_index`索引中,指定唯一标识符为1。 +2. 删除(delete)操作:从`my_index`索引中删除唯一标识符为2的文档。 +3. 更新(update)操作:将`my_index`索引中唯一标识符为3的文档进行更新。 + +响应示例: + +```json +{ + "took": 15, + "errors": false, + "items": [ + { + "index": { + "_index": "my_index", + "_id": "1", + "_version": 1, + "result": "created", + "status": 201 + } + }, + { + "delete": { + "_index": "my_index", + "_id": "2", + "_version": 2, + "result": "deleted", + "status": 200 + } + }, + { + "update": { + "_index": "my_index", + "_id": "3", + "_version": 2, + "result": "updated", + "status": 200 + } + } + ] +} +``` + +上述示例展示了每个操作的响应结果。每个结果都包含了与对应操作相关的元数据信息,如索引名称、文档ID、版本号、操作结果(如创建、删除、更新)以及HTTP状态码。 + +以下是一些常用的bulk参数及其含义: + +- `index`:指定要执行索引操作的索引名称和文档ID。 +- `delete`:指定要执行删除操作的索引名称和文档ID。 +- `update`:指定要执行更新操作的索引名称和文档ID。 +- `doc`:在更新操作中,用于指定要更新的字段和值。 +- `retry_on_conflict`:在并发更新时,设置重试次数以处理冲突,默认为0,表示不进行重试。 +- `pipeline`:指定在索引操作期间使用的管道ID,用于预处理文档。 + +这些参数需要在每个操作的请求行中进行设置,例如: + +```json +{"index":{"_index":"my_index","_id":"1","pipeline":"my_pipeline"}} +``` + +## filter_path + +在 Elasticsearch 中,`filter_path`参数用于过滤返回的响应内容,可以用于减小 Elasticsearch 返回的数据量。当你指明一个或多个路径时,返回的 JSON 对象就只会包含这些路径下的键,它接收一个逗号分隔的列表,其中包含了你想要返回的 JSON 对象内的路径。这个参数支持通配符(`*`)匹配和数组元素(`[]`)匹配。列如: + +``` +POST /_bulk?filter_path=items.*.error +``` + +上述请求中的 `filter_path=items.*.error` 会让 Elasticsearch 仅返回 `_bulk` API 调用结果中的错误信息。`items.*.error` 这个路径表示,在返回的响应中,匹配到所有存在 `error` 字段的 `items`。 + +这样做有两个主要好处: + +1. 它可以提升 Elasticsearch 的性能,因为少量的数据意味着更快的序列化和反序列化。 +2. 它可帮助你聚焦于感兴趣的部分,不必处理无关的数据。 + +请注意,`*` 是通配符,代表任何值。 + +以下是一些其他 `filter_path` 的示例: + +- `filter_path=took`: 这个请求仅返回执行请求所花费的时间(以毫秒为单位)。 +- `filter_path=items._id,items._index`: 这个请求仅返回每个 item 的 `_id` 和 `_index` 字段。 +- `filter_path=items.*.error`: 这个请求会返回所有包含 `error` 字段的 items。 +- `filter_path=hits.hits._source`: 这个请求仅返回搜索结果中的原始文档内容。 +- `filter_path=_shards, hits.total`: 这个请求返回关于 `shards` 的信息和命中的总数。 +- `filter_path=aggregations.*.value`: 这个请求仅返回每个聚合的值。 + +请注意,如果你在 `filter_path` 中指定了多个字段,你需要使用逗号将它们分隔开。 \ No newline at end of file diff --git "a/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\256\241\347\220\206.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\256\241\347\220\206.md" new file mode 100644 index 0000000..b82b4ad --- /dev/null +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\256\241\347\220\206.md" @@ -0,0 +1,444 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + + +[TOC] + +在Elasticsearch中,索引是对数据进行组织和存储的基本单元。索引管理涉及创建、配置、更新和删除索引,以及与索引相关的操作,如数据导入、搜索和聚合等。这些关键任务直接影响着系统性能、数据可用性和查询效率。 + +本文将深入探讨ES索引管理的重要性和最佳实践。我们将介绍索引模板的概念及其用途,了解如何通过索引别名实现无缝切换和版本控制。我们还将探讨滚动索引的概念,它可以帮助应对长期运行的查询和保持数据的时效性。 + +## 常用索引API + +### _cat + +通过使用 `_cat API`,可以快速查看和监控集群的状态、索引的健康情况、节点间分片的分配情况等。这些接口提供了轻量级和易于使用的方式来获取集群信息,为管理员和开发人员提供了便利。 + +| API | 描述 | +| ----------------------------- | ------------------------------------ | +| `_cat/allocation` | 获取分片分配信息 | +| `_cat/count` | 获取索引文档计数信息 | +| `_cat/fielddata` | 获取字段数据缓存信息 | +| `_cat/health` | 获取集群健康状态信息 | +| `_cat/indices` | 获取索引信息 | +| `_cat/master` | 获取主节点信息 | +| `_cat/nodeattrs` | 获取节点属性信息 | +| `_cat/nodes` | 获取节点信息 | +| `_cat/pending_tasks` | 获取挂起任务信息 | +| `_cat/plugins` | 获取插件信息 | +| `_cat/recovery` | 获取分片恢复信息 | +| `_cat/repositories` | 获取仓库信息 | +| `_cat/thread_pool` | 获取线程池信息 | +| `_cat/shards` | 获取分片信息 | +| `_cat/snapshots` | 获取快照信息 | +| `_cat/tasks` | 获取任务信息 | +| `_cat/templates` | 获取索引模板信息 | +| `_cat/segments` | 获取段信息 | +| `_cat/aliases/{alias}` | 根据别名获取指定索引的别名信息 | +| `_cat/indices/{index}` | 根据索引名称获取指定索引的详细信息 | +| `_cat/shards/{index}` | 根据索引名称获取指定索引的分片信息 | +| `_cat/recovery/{index}` | 根据索引名称获取指定索引的恢复信息 | +| `_cat/segments/{index}` | 根据索引名称获取指定索引的段信息 | +| `_cat/tasks/{task_id}` | 根据任务ID获取指定任务的详细信息 | +| `_cat/snapshots/{repository}` | 根据仓库名称获取指定仓库中的快照信息 | +| `_cat/nodeattrs/{node_id}` | 根据节点ID获取指定节点的属性信息 | + +### _cluster + +通过使用 `_cluster API`,可以获得集群的健康状况、资源使用情况,进行集群级别的配置和管理操作。这些接口提供了对集群的细粒度控制和监控能力,帮助您保持集群的稳定性、优化性能,并进行故障排除和调试。 + +请注意,访问 `_cluster API` 需要具有适当的权限。使用这些接口时,请确保遵循 Elasticsearch 的安全最佳实践,并谨慎处理敏感信息。 + +| API | 描述 | +| --------------------------------------- | -------------------------- | +| `_cluster/allocation/explain` | 解释分片分配相关信息 | +| `_cluster/health` | 获取集群健康状态信息 | +| `_cluster/pending_tasks` | 获取挂起任务信息 | +| `_cluster/reroute` | 重新路由分片 | +| `_cluster/state` | 获取集群状态信息 | +| `_cluster/stats` | 获取集群统计信息 | +| `_cluster/settings` | 获取或更改集群级别的设置 | +| `_cluster/recovery` | 获取正在进行的分片恢复信息 | +| `_cluster/nodes/hot_threads` | 获取热线程信息 | +| `_cluster/nodes/info` | 获取节点信息 | +| `_cluster/nodes/reload_secure_settings` | 重新加载安全设置 | +| `_cluster/nodes/stats` | 获取节点统计信息 | +| `_cluster/pending_tasks` | 获取待处理的任务列表 | +| `_cluster/remote/info` | 获取远程集群连接信息 | + +### 判断索引是否存在 + +```JSON +HEAD +``` + +### 打开和关闭索引 + +在生产环境有时要禁止索引做读写操作,此时可以对索引执行关闭。 + +**打开索引** + +```JSON +POST /_open +``` + +**关闭索引** + +```JSON +POST /_close +``` + +## 索引压缩 + +索引压缩并不是指压缩索引的大小,而是压缩索引的分片。 + +例如:有一个名为 `my_index`的索引有6个主分片,压缩为只有2个主分片,称之为索引压缩。 + +### 前提条件 + +- 进行压缩的时候,索引必须是只读状态。 +- 目标索引的所有主分片必须位于同一节点(因为创建目标索引时会将段从源索引硬链接到目标索引,而硬链接是不支持跨节点的。如果文件系统不支持硬链接,则会将所有segment file都复制到新索引中,复制过程很耗时)。 +- 索引的健康状态必须为Green。 +- 目标索引不能已存在,避免重名。 +- 目标索引的分片数量必须为源索引的约数,比如我源索引分片数为6,那么目标索引的分片数只能为:1,2,3,6。 +- 目标节点所在的服务器确保有足够大的磁盘空间。 + +### 操作步骤 + +1. **备份数据,以防数据丢失,不做强制要求** + +```json +POST _reindex +{ + "source": { + "index": "source_index" + }, + "dest": { + "index": "target_index" + } +} +``` + +注意:索引比较大的情况下,`_reindex` 操作可能耗时会比较久。 + +2. **设置副本数为0** + +```json +"index.number_of_replicas": 0 +``` + +关闭副本数主要是为了只做一份数据的压缩,重复数据没必要同步两次。 + +3. **设置只读** + +设置索引为只读状态,在索引压缩的时候,数据是不可写的。 + +```json +"index.blocks.write": true +``` + +4. **迁移数据** + +```json +"index.routing.allocation.require._name": "target_node" +``` + +`index.routing.allocation.require`是Elasticsearch中的索引级别设置,用于指定分配索引分片的要求条件。通过设置该参数,可以控制分配策略,将索引的分片分配到特定的节点或节点标签。 + +具体使用方式如下: + +设置分片的要求条件: + +```json +PUT /my_index/_settings +{ + "index.routing.allocation.require.node_type": "hot" +} +``` + +上述示例将索引`my_index`的分片要求分配到拥有`node_type=hot`标签的节点上。 + +5. **执行压缩命令** + +```json +POST /my_index/_shrink/target_index +{ + "settings": { + "index.number_of_replicas": 1, + "index.number_of_shards": 3, + //索引压缩算法 + "index.codec": "best_compression" + } +} +``` + +`reindex`和`shrink`是Elasticsearch中用于重新索引和缩减索引的两个不同操作,它们具有以下区别: + +Reindex(重新索引): + +- `reindex`操作用于将数据从一个索引复制到另一个索引,并可以在此过程中进行转换、筛选或重塑数据。 +- 通过`reindex`操作,可以更改索引的映射、调整分片设置、修改文档内容等。 +- `reindex`操作是非破坏性的,原始索引和目标索引同时存在,可以逐步迁移数据。 + +Shrink(缩减索引): + +- `shrink`操作用于减少索引的分片数量,将一个大的索引缩减为更小的索引。 +- 通过`shrink`操作,可以将原始索引的分片合并为较少数量的目标索引分片。 +- `shrink`操作通常用于优化索引性能、减少资源占用、提高查询效率等。注意,缩减索引会导致一定的数据迁移开销。 + +关键区别: + +- `reindex`是复制数据并对其进行转换的过程,可以在任何时候执行,并且不需要目标索引事先存在。它允许更灵活地处理数据,并且可以应用各种转换逻辑。 +- `shrink`是减少索引分片数量的操作,只能在满足特定条件的情况下执行。它主要用于优化索引性能和资源利用。 + +总结来说,`reindex`用于数据的复制和转换,而`shrink`用于缩减索引的分片数量以提高性能。具体选择哪个操作取决于需求和目标。 + +6. **恢复索引** + +```json +PUT target_index/_settings +{ + //允许分配到任意节点 + "index.routing.allocation.require._name": null, + "index.blocks.write": false +} +``` + +索引压缩完成,此时可以逐步将业务从源索引切流到目标索引上,可以使用alias来完成切流过程。 + +## 索引别名 + +索引别名是个非常重要并且非常实用的功能 + +### 别名作用 + +**官方描述** + +索引别名是用于引用一个或多个现有索引的辅助名称,大多数 Elasticsearch API 接受索引别名来代替索引。 + +Elasticsearch 的所有 API 都会自动将别名转换为实际的索引名称。一个别名也可以映射到多个索引,当指定它时,别名会自动扩展为别名索引。别名也可以与搜索时自动应用的过滤器和路由值相关联。别名不能与索引同名。 + +**保护索引** + +索引相对于调用者是隐藏的。 + +### 使用场景 + +索引别名在Elasticsearch中具有以下用途和使用场景: + +- 通过使用别名,可以为不同版本的索引创建不同的别名,并在切换版本时轻松进行索引升级和回滚。 +- 可以先创建一个新的索引并将别名指向新索引,然后平滑地将读写操作从旧索引切换到新索引。 +- 通过别名,可以将多个索引组合成一个逻辑集合,并对集合进行查询或操作。这样做可以方便地处理大量数据,实现数据分片和分布式搜索。 +- 使用别名可以为不同的用户、应用程序或租户创建独立的别名,以实现数据的隔离和多租户支持。 +- 别名还可以用于按照特定规则将请求路由到不同的索引,以实现负载均衡或按时间范围进行数据分片。 + +这些只是一些使用场景的例子,实际上别名功能非常灵活,可以根据具体需求进行定制。索引别名为用户提供了更大的灵活性和可管理性,使得在进行索引维护、升级或数据操作时更加方便和安全。 + +### 使用 + +**语法** + +```json +POST /_aliases +``` + +下面是一个使用`_aliases` API 创建和删除别名的示例,以及相应的输入和输出: + +**创建别名** + +- 输入: + + ```json + POST /_aliases + { + "actions": [ + { "add": { "index": "my_index", "alias": "my_alias" } } + ] + } + ``` + +- 输出: + + ```json + { + "acknowledged": true + } + ``` + + 解释:通过以上输入,将索引`my_index`与别名`my_alias`进行关联。输出中的`acknowledged`字段为`true`表示操作已成功。 + +**删除别名** + +- 输入: + + ```json + POST /_aliases + { + "actions": [ + { "remove": { "index": "my_index", "alias": "my_alias" } } + ] + } + ``` + +- 输出: + + ```json + { + "acknowledged": true + } + ``` + + 解释:通过以上输入,解除索引`my_index`与别名`my_alias`的关联。输出中的`acknowledged`字段为`true`表示操作已成功。 + + +注意: + +- 一个索引可以绑定多个别名,一个别名也可以绑定多个索引。 +- 别名不能和索引名相同。 + +## 索引模版 + +索引模板(Index Template)是在Elasticsearch中用于自动创建和配置索引的一种机制。它允许你定义索引的设置、映射和别名等,并在新索引满足特定条件时自动应用这些配置。 + +索引模板的主要目的是为了简化索引管理和维护工作,并确保新创建的索引具有一致的结构和配置。 + +索引模板在企业生产实践中常配合滚动索引(Rollover Index)、索引的生命周期管理(ILM:Index lifecycle management)、数据流一起使用。 + +以下是索引模板的核心概念和用法: + +- 索引名称模式(index_patterns): + - 索引模板通过`index_patterns`参数定义一个或多个与索引名称匹配的模式。 + - 模式通常包含一个固定的前缀和一个通配符,如`my_index-*`,其中`*`表示通配符部分。 + - 当新索引的名称与模式匹配时,该模板将被应用到新索引上。 +- 设置和配置(settings): + - 索引模板可以定义新索引的各种设置和配置,例如分片数量、副本数量、刷新间隔等。 + - 这些设置将自动应用到新索引上,确保新索引的行为与模板定义的一致。 +- 映射(mappings): + - 通过索引模板,您可以定义新索引的字段映射、属性和数据类型等。 + - 索引模板中的映射配置将自动应用到新索引上,确保新索引的结构与模板定义的一致。 +- 别名(aliases): + - 索引模板可以定义一个或多个别名,用于管理和访问新创建的索引。 + - 别名可以被用作查询、索引操作等,而不需要直接指定特定的索引名称。 + +通过使用索引模板,可以根据业务需求和索引管理的最佳实践来定义一套统一的索引创建和配置规则。当创建新的满足模式匹配条件的索引时,Elasticsearch会自动应用该模板,并创建带有预定义设置、映射和别名的索引。 + +下面是一个示例请求,用于创建名为`my_index_template`的索引模板: + +```json +PUT _index_template/my_index_template +{ + "index_patterns": ["my_index-*"], + "template": { + "settings": { + "number_of_shards": 5, + "number_of_replicas": 1 + }, + "mappings": { + "properties": { + "field1": { + "type": "text" + }, + "field2": { + "type": "keyword" + } + } + }, + "aliases": { + "my_alias": {} + } + } +} +``` + +在上述示例中: + +- `_index_template/my_index_template`表示要创建的索引模板的名称。 +- `index_patterns`参数设置了匹配的索引名称模式,这里使用了通配符`*`来适配任意后缀。 +- `template`字段指定了要应用于新索引的设置、映射和别名等配置。 +- `settings`定义了新索引的分片数为5,副本数为1。 +- `mappings`指定了新索引的字段映射配置,其中`field1`为文本类型,`field2`为关键字类型。 +- `aliases`定义了一个别名`my_alias`,用于访问该索引。 + +通过发送以上请求,您可以创建一个名为`my_index_template`的索引模板。当新创建的索引名称满足`my_index-*`的模式时,该模板将自动应用到新索引上,并使新索引具有预定义的设置、映射和别名。 + +## 滚动索引 + +滚动索引(Rollover Index)是Elasticsearch中用于管理索引自动切换和维护的一种机制。它允许在索引达到预定义条件时,自动创建新的索引并将写入流量切换到新索引上,以实现索引的平滑滚动和分片维护。 + +使用滚动索引的主要场景是处理大容量和高吞吐量的数据,并确保索引的性能和可伸缩性。 + +下面是滚动索引(Rollover Index)的基本工作流程: + +1. 创建索引模板(Index Template): + - 首先,你需要创建一个索引模板,其中包含指定条件以触发滚动操作的设置。这些条件可以是基于时间、文档数量、索引大小等来定义。 +2. 创建初始索引(Initial Index): + - 使用索引模板创建初始索引,该索引将用于接收初始的数据写入流量。 +3. 监控索引状态: + - 持续监控当前索引的状态和指标,如文档数量、索引大小等。 + - 当索引达到预定义的条件时,即满足了滚动的触发条件,就会开始进行滚动操作。 +4. 触发滚动操作: + - 当索引满足触发条件时,Elasticsearch将自动创建新的索引,同时将写入流量切换到新索引上。 + - 新索引可以具有更高的分片数、新的配置参数和优化设置,以确保整个系统的性能和可用性。 +5. 后续维护和操作: + - 一旦滚动操作完成,你可以对旧索引进行必要的维护操作,如关闭、删除或备份。 + - 您还可以使用滚动名称(Alias)来管理所有滚动的索引,以便于查询和操作。 + +通过使用滚动索引(Rollover Index),可以实现自动化的索引管理和平滑的索引切换,同时为数据处理和存储提供了更好的可伸缩性和性能。它特别适用于需要处理大量数据并保证系统稳定性的场景,如日志记录、时间序列数据等。 + +### 触发条件 + +滚动索引(Rollover Index)的触发条件是通过索引模板中的一些参数来定义的。以下是常用的触发条件及其参数: + +- 文档数量触发条件: + - `max_docs`:指定索引中的文档数量上限,达到该值时触发滚动操作。 +- 索引大小触发条件: + - `max_size`:指定索引的大小上限,达到该值时触发滚动操作。 + - `max_size_bytes`:与`max_size`类似,但使用字节数表示索引大小上限。 +- 时间触发条件: + - `max_age`:指定索引的最大存储时间,超过该时间时触发滚动操作。 + - `max_age_seconds`:与`max_age`类似,但以秒为单位表示存储时间上限。 +- 自定义触发条件: + - `conditions`:允许您自定义其他触发条件,例如基于字段的特定规则或复杂逻辑判断。 + +这些触发条件参数可以在索引模板中进行配置,以根据具体需求定义何时触发滚动操作。可以同时使用多个触发条件,也可以选择仅使用其中部分来触发滚动操作。 + +例如,以下是一个示例索引模板的定义,其中包含了文档数量和时间两个触发条件: + +```json +PUT _template/my_template +{ + "index_patterns": ["my_index*"], + "settings": { + "number_of_shards": 5 + }, + "mappings": { + "_source": { + "enabled": true + } + }, + "aliases": { + "my_alias": {} + }, + "rollover": { + "max_docs": 100000, + "max_age": "7d" + } +} +``` + +以上示例中,当索引满足文档数量达到10万或存储时间超过7天的条件之一时,将触发滚动操作。 + +或者也可以直接调用 `_rollover` API: + +```json +# index_alias 必须是别名 而不能是索引的本名 +POST /index_alias/_rollover +{ + "conditions": { + "max_age": "7d", + "max_docs": 2, + "max_size": "5gb" + } +} +``` \ No newline at end of file diff --git "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\350\201\232\345\220\210\346\237\245\350\257\242.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\350\201\232\345\220\210\346\237\245\350\257\242.md" similarity index 65% rename from "docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\350\201\232\345\220\210\346\237\245\350\257\242.md" rename to "docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\350\201\232\345\220\210\346\237\245\350\257\242.md" index 0dc2100..e639203 100644 --- "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\350\201\232\345\220\210\346\237\245\350\257\242.md" +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\350\201\232\345\220\210\346\237\245\350\257\242.md" @@ -1,28 +1,44 @@ -## 概念 +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) -聚合(aggs)不同于普通查询,是目前学到的第二种大的查询分类,第一种即“query”,因此在代码中的第一层嵌 +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) -套由“query”变为了“aggs”。**用于进行聚合的字段必须是exact value,分词字段不可进行聚合**,对于text字段如 +[TOC] -果需要使用聚合,需要开启fielddata,但是通常不建议,因为fielddata是将聚合使用的数据结构由磁盘 +聚合查询是 Elasticsearch 中一种强大的数据分析工具,用于从索引中提取和计算有关数据的统计信息。聚合查询可以执行各种聚合操作,如计数、求和、平均值、最小值、最大值、分组等,以便进行数据汇总和分析。 -(doc_values)变为了堆内存(field_data),大数据的聚合操作很容易导致OOM。 +下面是一些常见的聚合查询类型: -## doc values 和 fielddata +- **Metric Aggregations(指标聚合)**:这些聚合操作返回基于字段值的度量结果,如求和、平均值、最小值、最大值等。常见的指标聚合包括 Sum、Avg、Min、Max、Stats 等。 +- **Bucket Aggregations(桶聚合)**:类比SQL中的group by,主要用于统计不同类型数据的数量,这些聚合操作将文档划分为不同的桶(buckets),并对每个桶中的文档进行聚合计算。常见的桶聚合包括 Terms(按字段值分组)、Date Histogram(按时间间隔分组)、Range(按范围分组)等。 +- **Pipeline Aggregations(管道聚合)**:这些聚合操作通过在其他聚合结果上执行额外的计算来产生新的聚合结果。例如,使用 Moving Average 聚合可以计算出移动平均值。 -在 Elasticsearch 中,聚合操作主要依赖于 doc values 或 fielddata 来进行。 +聚合查询通常与查询语句结合使用,可以在查询结果的基础上进行进一步的数据分析和统计。聚合查询语法使用 JSON 格式,可以通过 Elasticsearch 的 REST API 或各种客户端库进行发送和解析。 -1. **Doc values**:对于大多数字段类型,Elasticsearch 使用 doc values 进行排序和聚合。doc values 是一种在磁盘上的、列式存储的数据结构,适用于稀疏字段,也就是字段中有很多不同的值。它们默认开启,并且不能被禁用。 -2. **Fielddata**:对于TEXT字段,doc values 默认是关闭的,因为文本字段通常包含很多不同的值,使用 doc values 会消耗大量内存。这时候,如果需要对文本字段进行聚合或排序,Elasticsearch 使用 fielddata。fielddata 是一个将所有文档的字段值加载到内存的数据结构,使用它可以使得聚合、排序和脚本运行更快,但代价是消耗更多的内存。 +聚合查询支持嵌套,即一个聚合内部可以包含别的子聚合,从而实现非常复杂的数据挖掘和统计需求。 -当执行聚合操作时,Elasticsearch 需要访问所有匹配文档的字段值。对于非文本字段,默认情况下 +在ES中,用于进行聚合的字段可以是exact value也可以是分词字段,对于分词字段,可以使用特定的聚合操作来进行分组聚合,例如Terms Aggregation、Date Histogram Aggregation等。 -Elasticsearch 使用 doc values 来实现。对于文本字段,必须首先启用 fielddata。然而,由于 fielddata 占用大量内存,Elasticsearch 默认禁用了它。 +对于text字段的聚合,可以通过开启fielddata来实现,但通常不建议这样做,因为fielddata会将聚合使用的数据结构从磁盘(doc_values)转换为堆内存(field_data),在处理大量数据时容易导致内存溢出(OOM)问题。 -对于文本字段,fielddata 默认是禁用的。如果你确实需要对一个文本字段启用 fielddata(虽然大多数场景下不推荐这么做,因为可能导致内存消耗过大),你可以通过更新映射(mapping)来实现。以下是如何在 `my_field` 字段上启用 fielddata 的示例: +如果需要在text字段上执行聚合,可以考虑在该字段上添加.keyword子字段,并使用该子字段进行聚合操作,以获得更准确的结果。 + +## doc_values & fielddata + +在 Elasticsearch 中,聚合操作主要依赖于 doc_values 或 fielddata 来进行。 + +- **Doc Values(文档值)**:Doc Values 是一种以列式存储格式保存字段值的数据结构,它用于支持快速的聚合、排序和统计操作。Doc Values 在磁盘上存储,并被加载到 JVM 堆内存中进行计算。它们适用于精确值(如 keyword 类型)和数字类型的字段,在大多数情况下是默认启用的。 +- **Fielddata(字段数据)**:Fielddata 是一种将字段值加载到堆内存中的数据结构,它用于支持复杂的文本分析和聚合操作。Fielddata 适用于文本类型的字段,例如 text 类型,因为它们需要进行分词和分析。但是,由于 Fielddata 需要大量的堆内存资源,特别是在处理大数据集时,容易导致内存溢出(OOM)的问题,因此不建议随意启用。 + +在设计索引时,需要根据字段类型和使用场景的不同,合理选择是否启用 Doc Values 或 Fielddata,以平衡性能和资源消耗的需求。 + +当执行聚合操作时,Elasticsearch 需要访问所有匹配文档的字段值。对于非文本字段,默认情况下Elasticsearch 使用 doc values 来实现。对于文本字段,必须首先启用 fielddata。然而,由于 fielddata 占用大量内存,Elasticsearch 默认禁用了它。 + +如果你确实需要对一个文本字段启用 fielddata(虽然大多数场景下不推荐这么做,因为可能导致内存消耗过大),你可以通过更新映射(mapping)来实现。 + +以下是如何在 `my_field` 字段上启用 fielddata 的示例: ```JSON -PUT my-index/_mapping +PUT my_index/_mapping { "properties": { @@ -34,11 +50,11 @@ PUT my-index/_mapping } ``` -注意,更改 fielddata 设置只会影响新的数据,已经索引的数据不会受到更改。如果你想让更改生效,需要重新索引(reindex)你的数据。 +**注意:更改 fielddata 设置只会影响新的数据,已经索引的数据不会受到更改。如果你想让更改生效,需要重新索引(reindex)你的数据** -另外,一般情况下,建议你使用 mapping 中的 `keyword` 类型来进行聚合、排序或脚本,而不是启用 `text` 类型的 fielddata。这是因为 `keyword` 类型字段默认开启了 doc values,比在 `text` 上启用 fielddata 更加高效且节省内存。 +另外,一般情况下,建议使用 mapping 中的 `keyword` 类型来进行聚合、排序或脚本,而不是启用 `text` 类型的 fielddata。这是因为 `keyword` 类型字段默认开启了 doc values,比在 `text` 上启用 fielddata 更加高效且节省内存。 -## multi-fields(多字段)类型 +## multi-fields 在 Elasticsearch 中,一个字段有可能是 multi-fields(多字段)类型,这意味着同一份数据可以被索引为不同类型的字段。常见的情况就是,一个字段既被索引为 `text` 类型用于全文搜索,又被索引为 `keyword` 类型用于精确值搜索、排序和聚合。 @@ -48,15 +64,9 @@ PUT my-index/_mapping 如果你的字段没有 `.keyword` 子字段,那可能是在定义 mapping 时没有包含这一部分,或者这个字段的类型本身就是 `keyword`。 -## 聚合分类 +## 分桶聚合 -- 分桶聚合(Bucket agregations):类比SQL中的group by的作用,主要用于统计不同类型数据的数量。 -- 指标聚合(Metrics agregations):主要用于最大值、最小值、平均值、字段之和等指标的统计。 -- 管道聚合(Pipeline agregations):用于对聚合的结果进行二次聚合,如要统计绑定数量最多的标签bucket,就是要先按照标签进行分桶,再在分桶的结果上计算最大值。 - -### 分桶聚合 - -分桶(bucketing)聚合是一种特殊类型的聚合,它将输入文档集合中的文档分配到一个或多个桶中,每个桶都对应于一个键(key)。 +分桶(Bucket)聚合是一种特殊类型的聚合,它将输入文档集合中的文档分配到一个或多个桶中,每个桶都对应于一个键(key)。 下面是一些常用的分桶聚合类型: @@ -90,7 +100,30 @@ GET /blog/_search Elasticsearch 将返回一个包含每个作者以及他们所写的文章数量的列表。注意,由于 Elasticsearch 默认只返回前十个桶,如果你的数据中有更多的作者,可能需要设置 `size` 参数来获取更多的结果。 -### 指标聚合 +### Histogram + +`histogram` 是桶聚合的一种类型,它可以按照指定的间隔将数字字段的值划分为一系列桶。每个桶代表了这个区间内的所有文档。 + +以下是一个例子,我们根据价格字段创建一个间隔为 50 的直方图: + +```JSON +GET /products/_search +{ + "size": 0, + "aggs" : { + "prices" : { + "histogram" : { + "field" : "price", + "interval" : 50 + } + } + } +} +``` + +在这个例子中,“prices” 是一个 histogram 聚合,它以 50 为间隔将产品的价格划分为一系列的桶。 + +## 指标聚合 在 Elasticsearch 中,指标聚合是对数据进行统计计算的一种方式,例如求和、平均值、最小值、最大值等。以下是一些常用的指标聚合类型: @@ -115,14 +148,32 @@ GET /sales/_search } ``` -在这个查询中: +### Percentiles -- `"size": 0` 表示我们只对聚合结果感兴趣,不需要返回任何具体的搜索结果。 -- `"aggs"` (或者 `"aggregations"`) 块定义了我们的聚合。 -- `"average_price"` 是我们自己为这个聚合命名的标签,可以用任何你喜欢的标签名。 -- `"avg": { "field": "price" }` 定义了我们执行的聚合类型以及对哪个字段进行聚合。在这里,我们告诉 Elasticsearch 使用 `avg` 聚合,并且对 `price` 字段的值进行计算。Elasticsearch 将返回一个包含所有销售记录平均价格的结果。 +`percentiles` 是指标聚合的一种,它用于计算数值字段的百分位数。给定一个列表百分比,Elasticsearch 可以计算每个百分比下的数值。 + +以下是一个例子,我们计算价格字段的 1st, 5th, 25th, 50th, 75th, 95th, and 99th 百分位数: + +```JSON +GET /products/_search +{ + "size": 0, + "aggs" : { + "price_percentiles" : { + "percentiles" : { + "field" : "price", + "percents" : [1, 5, 25, 50, 75, 95, 99] + } + } + } +} +``` + +在这个例子中,“price_percentiles” 是一个 percentiles 聚合,它计算了价格在各个百分位点的数值。 -#### 去重 +注意,对于大数据集,计算精确的百分位数可能需要消耗大量资源。因此,Elasticsearch 默认使用一个名为 `TDigest` 的算法来提供近似的计算结果,同时还能保持内存使用的可控性。 + +### cardinality 如果你想在 Elasticsearch 中进行去重操作,可以使用 `terms` 聚合加上 `cardinality` 聚合。这是一个示例,假设我们有一个包含user_id的 "users" 索引,并且我们想要知道有多少唯一的 user_id: @@ -145,11 +196,13 @@ GET /users/_search - `"distinct_user_ids"` 是我们自己为这个聚合命名的标签。 - `"cardinality": { "field": "user_id.keyword" }` 使用了 `cardinality` 聚合,该聚合会返回指定字段(在这里是 `user_id.keyword`)的不同值的数量。 -Elasticsearch 将返回一个结果,告诉我们有多少个不同的 user_id。请注意,`cardinality` 聚合可能并不总是完全精确,特别是对于大型数据集,因为它在内部使用了一种叫做 HyperLogLog 的算法来近似计算基数,这种算法会在保持内存消耗相对较小的情况下提供接近准确的结果。如果你需要完全精确的结果,可能需要考虑其他方法,例如使用脚本或者将数据导出到外部系统进行处理。 +Elasticsearch 将返回一个结果,告诉我们有多少个不同的 user_id。请注意,`cardinality` 聚合可能并不总是完全精确,特别是对于大型数据集,因为它在内部使用了一种叫做 `HyperLogLog` 的算法来近似计算基数,这种算法会在保持内存消耗相对较小的情况下提供接近准确的结果。如果你需要完全精确的结果,可能需要考虑其他方法,例如使用脚本或者将数据导出到外部系统进行处理。 + +## 管道聚合 -### 管道聚合 +在 Elasticsearch 中,管道聚合(pipeline aggregations)是指这样一种聚合:它以其他聚合的结果作为输入,并进行进一步处理。 -在 Elasticsearch 中,管道聚合(pipeline aggregations)是指这样一种聚合:它以其他聚合的结果作为输入,并进行进一步处理。常见的管道聚合包括: +常见的管道聚合包括: - `avg_bucket` - `sum_bucket` @@ -198,7 +251,7 @@ GET /sales/_search 返回的结果中会包含每个月的平均销售价格,以及所有月份中平均销售价格的最大值。 -### 嵌套聚合 +## 嵌套聚合 嵌套聚合就是在聚合内使用聚合,在 Elasticsearch 中,嵌套聚合通常用于处理 nested 类型的字段。nested 类型允许你将一个文档中的一组对象作为独立的文档进行索引和查询,这对于拥有复杂数据结构(例如数组或列表中的对象)的场景非常有用。 @@ -236,9 +289,11 @@ GET /users/_search 请注意,在处理 nested 数据时,你需要确保 mapping 中相应的字段已经被设置为 nested 类型,否则该查询可能无法按预期工作。 -## 基于查询结果和聚合 & 基于聚合结果的查询 +## 基于查询结果的聚合 & 基于聚合结果的查询 -基于查询结果的聚合: 在这种情况下,我们首先执行一个查询,然后对查询结果进行聚合。例如,如果我们要查询所有包含某关键字的文档,并计算它们的平均价格,可以这样做: +基于查询结果的聚合:在这种情况下,我们首先执行一个查询,然后对查询结果进行聚合。 + +例如,如果我们要查询所有包含某关键字的文档,并计算它们的平均价格,可以这样做: ```JSON GET /products/_search @@ -260,7 +315,9 @@ GET /products/_search 在上述例子中,我们首先通过 `match` 查询找到描述中包含 "laptop" 的所有产品,然后对这些产品的价格进行平均值聚合。 -基于聚合结果的查询(Post-Filter): 这种情况下,我们先执行聚合,然后基于聚合的结果执行过滤操作。这通常用于在聚合结果中应用一些额外的过滤条件。例如,如果我们想对所有产品进行销售数量聚合,然后从结果中过滤出销售数量大于10的产品,可以这样做: +基于聚合结果的查询:这种情况下,我们先执行聚合,然后基于聚合的结果执行过滤操作。 + +这通常用于在聚合结果中应用一些额外的过滤条件。例如,如果我们想对所有产品进行销售数量聚合,然后从结果中过滤出销售数量大于10的产品,可以这样做: ```JSON GET /sales/_search @@ -290,6 +347,8 @@ GET /sales/_search ## 聚合排序 +### count + 在 Elasticsearch 中,聚合排序允许你基于某一聚合的结果来对桶进行排序。例如,你可能希望查看销售量最高的10个产品,可以使用 `terms` 聚合以及其 `size` 和 `order` 参数来实现: ```JSON @@ -316,7 +375,7 @@ GET /sales/_search 返回的结果将包含销售量最高的前10个产品的 ID 列表。 -需要注意的是,由于 Elasticsearch 默认会对桶进行优化,所以在使用 `size` 参数时可能无法得到完全准确的结果。如果需要更精确的结果,可以在请求中设置 `"size": 0` ,然后使用 `composite` 聚合来分页获取所有结果。 +### term `_term` 在 Elasticsearch 的聚合排序中用来指定按照词条(即桶的键)来排序。 @@ -336,7 +395,7 @@ GET /sales/_search } ``` -在这个例子中,`products` 是一个 `terms` 聚合,用于按 `product_id` 对销售记录进行分组,然后通过 `"order": { "_term": "asc" }` 指定了按照 `product_id` 的值升序排序这些桶。 +在这个例子中,通过 `"order": { "_term": "asc" }` 指定了按照 `product_id` 的值升序排序这些桶。 返回的结果将包含按照 `product_id` 升序排列的产品 ID 列表,每个产品 ID 对应一个桶,并且每个桶内包含对应产品的销售记录。 @@ -356,54 +415,4 @@ GET /sales/_search } } } -``` - -## **Histogram & Percentiles ** - -### **Histogram 聚合** - -`histogram` 是一个类型的桶聚合,它可以按照指定的间隔将数字字段的值划分为一系列桶。每个桶代表了这个区间内的所有文档。 - -以下是一个例子,我们根据价格字段创建一个间隔为 50 的直方图: - -```JSON -GET /products/_search -{ - "size": 0, - "aggs" : { - "prices" : { - "histogram" : { - "field" : "price", - "interval" : 50 - } - } - } -} -``` - -在这个例子中,“prices” 是一个 histogram 聚合,它以 50 为间隔将产品的价格划分为一系列的桶。 - -### **Percentiles 聚合** - -`percentiles` 是一种度量聚合,它用于计算数值字段的百分位数。给定一个列表百分比,Elasticsearch 可以计算每个百分比下的数值。 - -以下是一个例子,我们计算价格字段的 1st, 5th, 25th, 50th, 75th, 95th, and 99th 百分位数: - -```JSON -GET /products/_search -{ - "size": 0, - "aggs" : { - "price_percentiles" : { - "percentiles" : { - "field" : "price", - "percents" : [1, 5, 25, 50, 75, 95, 99] - } - } - } -} -``` - -在这个例子中,“price_percentiles” 是一个 percentiles 聚合,它计算了价格在各个百分位点的数值。 - -注意,对于大数据集,计算精确的百分位数可能需要消耗大量资源。因此,Elasticsearch 默认使用一个名为 `TDigest` 的算法来提供近似的计算结果,同时还能保持内存使用的可控性。 \ No newline at end of file +``` \ No newline at end of file diff --git "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\350\204\232\346\234\254\346\237\245\350\257\242.md" "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\350\204\232\346\234\254\346\237\245\350\257\242.md" similarity index 76% rename from "docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\350\204\232\346\234\254\346\237\245\350\257\242.md" rename to "docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\350\204\232\346\234\254\346\237\245\350\257\242.md" index 10c32c3..5e4d2e3 100644 --- "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\350\204\232\346\234\254\346\237\245\350\257\242.md" +++ "b/docs/md/es/\344\270\200\350\265\267\345\255\246Elasticsearch\347\263\273\345\210\227-\350\204\232\346\234\254\346\237\245\350\257\242.md" @@ -1,3 +1,7 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + [TOC] Elasticsearch的 Scripting 是一种允许你使用脚本来评估自定义表达式的功能。通过它,你可以实现更复杂的查询、数据处理以及柔性调整索引结构等。 @@ -6,24 +10,13 @@ Elasticsearch支持多种脚本语言。在 ES 中,脚本语言主要是 Painl 以下是一些常见的使用脚本的场景: -1. **计算字段(Field Calculations)**:你可以使用脚本在查询时动态地改变或添加字段的值。 -2. **脚本查询(Script Queries)**:在查询中使用脚本进行复杂的条件判断。 -3. **脚本聚合(Script Aggregations)**:使用脚本进行更复杂的聚合计算。 +- **计算字段**:你可以使用脚本在查询时动态地改变或添加字段的值。 +- **脚本查询**:在查询中使用脚本进行复杂的条件判断。 +- **脚本聚合**:使用脚本进行更复杂的聚合计算。 使用脚本时需要注意的是,由于涉及到运行时的计算,过度或者不恰当的使用脚本可能会对性能造成影响。另外,由于脚本具有执行任意代码的能力,因此需要确保脚本的使用在一个安全的环境中,并且只运行信任的脚本。 -## 概念 - -Scripting是Elasticsearch支持的一种专门用于复杂场景下支持自定义编程的强大的脚本功能,ES支持多种脚本语言,如painless,其语法类似于Java,也有注释、关键字、类型、变量、函数等,其就要相对于其他脚本高出几倍的性能,并且安全可靠,可以用于内联和存储脚本。 - -### 支持的语言 - -- **groovy**:ES 1.4.x-5.0的默认脚本语言。 -- **painless**:JavaEE使用java语言开发,.Net使用C#/F#语言开发,Flutter使用Dart语言开发,同样,ES 5.0+版本后的Scripting使用的语言默认就是painless,painless是一种专门用于Elasticsearch的简单语言,用于内联和存储脚本,是ES 5.0+的默认脚本语言。 -- expression:每个文档的开销较低,表达式的作用更多,可以非常快速地执行,甚至比编写native脚本还要快,支持javascript语法的子集。缺点:只能访问数字,布尔值,日期和geo_point字段,存储的字段不可用。 -- mustache:提供模板参数化查询。 - -### Painless特点 +## Painless特点 优点: @@ -37,7 +30,7 @@ Scripting是Elasticsearch支持的一种专门用于复杂场景下支持自定 - 相较于DSL性能低 - 不适用于复杂的业务场景 -### 简单例子 + 以下是一个在 Elasticsearch 查询中使用脚本的简单例子。这个例子将会搜索名为 "my_index" 的索引,寻找字段 "price" 和 "tax" 之和大于 100 的文档。 @@ -65,11 +58,11 @@ GET /my_index/_search 这个查询将返回所有 "price" 和 "tax" 之和大于 100 的文档。 -## Scripting的CRUD +## CRUD 以下是一些使用 Painless 脚本进行的 Elasticsearch CRUD 操作实例: -### insert(新增) +**insert(新增)** ```Java POST product/_update/6 { @@ -87,7 +80,7 @@ POST product/_update/6 { 因此,整个请求的意思是,在 "product" 索引中,找到 ID 为 6 的文档,并在其 "tags" 字段中添加一个新的元素 '无线充电'。 -### update(更新) +**update(更新)** ```Java POST product/_update/2 { @@ -100,7 +93,7 @@ POST product/_update/2 { - `POST product/_update/2` 是 HTTP 请求的一部分,它告诉 Elasticsearch 在 "product" 索引中更新 ID 为 2 的文档。 - `"script": "ctx._source.price-=1"` 是请求体,其中的脚本用于执行实际的更新操作。在这个例子中,脚本将当前文档(由 `_source` 指定)的 "price" 字段减去 1。 -### delete(删除) +**delete(删除)** ```Java POST product/_update/10 { @@ -117,7 +110,7 @@ POST product/_update/10 { - `"script"` 部分中的 `"lang": "painless"` 指定了脚本的语言为 Painless。 - `"source": "ctx.op='delete'"` 是脚本的主体内容。这里,`ctx.op` 是一个特殊变量,表示待执行的操作。当它被设置为 'delete' 时,指示 Elasticsearch 删除当前操作中的文档。 -### upsert(更新插入) +**upsert(更新插入)** ```Java POST product/_update/15 { @@ -141,7 +134,7 @@ POST product/_update/15 { 整个请求的意思是在 "product" 索引中查找 ID 为 15 的文档并使其 "price" 字段增加 100。如果该文档不存在,则会插入一个新的文档,其 "name"、"desc" 和 "price" 字段的值分别为 "小米手机10"、"充电贼快掉电更快" 和 1999。 -### search(查询) +**search(查询)** ```Java GET product/_search { @@ -167,7 +160,7 @@ GET product/_search { Elasticsearch 会把编译过的脚本储存在缓存中,以提高重复执行同一脚本的性能。当你再次运行相同的脚本时,Elasticsearch 可以直接从缓存中获取已编译的脚本,而不需要再次编译。但是频繁编译脚本会到来性能问题。可以使用参数化脚本动态传参,解决脚本编译的性能问题。 -**参数化脚本在 Elasticsearch 中,是指在编写脚本时使用占位符,并在执行脚本时为这些占位符提供实际值。参数化脚本可以增加脚本的灵活性,并能防止脚本注入攻击**。 +**参数化脚本在 Elasticsearch 中,是指在编写脚本时使用占位符,并在执行脚本时为这些占位符提供实际值。参数化脚本可以增加脚本的灵活性,并能防止脚本注入攻击** 在脚本中,你可以通过 `params` 对象访问到传递的参数。 @@ -229,65 +222,12 @@ GET product / _search { - `GET product/_search` 是 HTTP 请求的一部分,告诉 Elasticsearch 在 "product" 索引中进行搜索。 -- ``` - "script_fields" - ``` - - 部分定义了两个脚本字段:"price" 和 "discount_price"。 - +- `"script_fields"`部分定义了两个脚本字段:"price" 和 "discount_price"。 - "price" 脚本字段返回每个文档的原始 "price" 字段值; - "discount_price" 脚本字段返回一个由四个元素组成的数组。数组中的每个元素都是 "price" 字段值与不同折扣率的乘积。这个脚本字段使用了参数化脚本,参数包括四个不同的折扣率:"discount_8", "discount_7", "discount_6" 和 "discount_5"。 因此,整个请求的意思是,在 "product" 索引中搜索所有的文档,并为每个文档计算原始价格和不同折扣率下的价格,然后将这些计算结果作为 "price" 和 "discount_price" 字段返回。 -## 脚本模版 - -在 Elasticsearch 中,脚本模板就是将脚本的源代码作为字符串存储,在运行时使用参数替换占位符以创建实际的脚本。脚本模板使得你可以重用相同的脚本逻辑,并通过提供不同的参数值来改变其行为。 - -这种方式与参数化脚本略有不同,参数化脚本只在已经定义的脚本中替换参数。而脚本模板则更加灵活,可以在整个脚本中替换参数,甚至可以改变脚本的结构。 - -脚本模板的一个主要应用场景是搜索请求。你可能希望根据用户的输入来调整查询的某部分,但又不希望每次都重写整个查询。在这种情况下,你可以创建一个脚本模板,并在其中使用占位符来代表可变的部分。然后,你只需要提供必要的参数就可以执行查询,而无需每次都手动修改查询的源码。 - -这种做法可以简化代码,增强代码的可读性和可维护性,并且降低了因为拼接字符串导致的错误风险。 - -下面是一个例子: - -```Java -POST _scripts / calculate_discount { - "script": { - "lang": "painless", - "source": "doc.price.value * params.discount" - } -} -``` - -这个 Elasticsearch 请求创建了一个名为 `calculate_discount` 的脚本模板。该模板包含一个简单的脚本,用于计算一个文档字段(假设为 "price")的折扣价。"price" 字段值与参数 `params.discount` 相乘,得到折扣后的价格。 - -这个模板可以在许多不同的地方使用,例如在搜索请求中作为脚本字段或者在更新请求中。只需要提供不同的 `discount` 参数就可以得到不同的折扣价,而无需每次都修改整个脚本源码。 - -以下是如何在搜索请求中使用这个模板的示例: - -```Java -GET /products/_search -{ - "query": { - "match_all": {} - }, - "script_fields": { - "discounted_price": { - "script": { - "id": "calculate_discount", - "params": { - "discount": 0.9 - } - } - } - } -} -``` - -在这个搜索请求中,我们使用了 `calculate_discount` 模板,并提供了一个折扣参数 `0.9`。这个请求会返回所有 "products" 索引中的文档,并且每个文档都会包含一个新的字段 "discounted_price",它的值是原始 "price" 字段值的 90%。 - ## 函数式编程 Elasticsearch 的脚本语言 Painless 支持函数式编程。函数式编程是一种编程范式,它让你能够编写出更加简洁清晰的代码。函数可以作为参数传递给其他函数,也可以从其他函数中返回。 @@ -325,7 +265,7 @@ POST _scripts/calculate_total 如果你需要启用这个功能,可以在 Elasticsearch 的配置文件(`elasticsearch.yml`)里加入以下行: -``` +```yaml script.painless.regex.enabled: true ``` @@ -355,10 +295,10 @@ GET /_search 注意正则表达式需要两个反斜杠进行转义,因为 JSON 语法本身也需要对反斜杠进行转义。如果没有 JSON 语法的转义需求,在 Painless 中写正则表达式时只需要一个反斜杠即可。 -## 聚合中使用script +## 聚合查询中使用Script ```Java -GET product / _search { +GET product/_search { "query": { "constant_score": { "filter": { @@ -396,7 +336,7 @@ GET product / _search { 这样执行之后,你将得到价格小于或等于1000的所有产品,以及每个产品的标签数量。 -## doc 和params +## doc & params ### doc和params的用法 @@ -460,7 +400,7 @@ GET product / _search { } ``` -1. `doc['field'].value` 是从Lucene索引中读取字段值,这种方式速度快,效率高。然而,它把数据加载到内存中,可能会增加内存使用。此外,它只能用于简单类型字段,无法处理复杂类型(如object或nested)。 -2. `params['_source']['field']` 是从原始的 `_source` 字段获取数据。这种方式可以访问所有类型的字段,包括复杂类型。但是,这要求加载和解析整个原始JSON文档,因此执行效率较低。 +- `doc['field'].value` 是从Lucene索引中读取字段值,这种方式速度快,效率高。然而,它把数据加载到内存中,可能会增加内存使用。此外,它只能用于简单类型字段,无法处理复杂类型(如object或nested)。 +- `params['_source']['field']` 是从原始的 `_source` 字段获取数据。这种方式可以访问所有类型的字段,包括复杂类型。但是,这要求加载和解析整个原始JSON文档,因此执行效率较低。 所以,如果你的字段是简单类型,并且你关心查询的性能,那么优先使用 `doc['field'].value`。如果你需要处理复杂类型字段或者未索引的字段,那么可以使用 `params['_source']['field']`。 \ No newline at end of file diff --git "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-Mapping.md" "b/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-Mapping.md" deleted file mode 100644 index de34686..0000000 --- "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-Mapping.md" +++ /dev/null @@ -1,290 +0,0 @@ -[TOC] - -这篇讲解Elasticsearch中非常重要的一个概念Mapping,Mapping是索引必不可少的组成部分。 - -## Mapping 的基本概念 - -**Mapping 也称之为映射,定义了 ES 的索引结构、字段类型、分词器等属性,是索引必不可少的组成部分**。 - -ES 中的 mapping 有点类似与关系型数据库中“表结构”的概念,在 MySQL 中,表结构里包含了字段名称,字段的类型还有索引信息等。在 Mapping 里也包含了一些属性,比如字段名称、类型、字段使用的分词器、是否评分、是否创建索引等属性。 - -### 查看索引 Mapping - -```JSON -//查看索引完整的mapping -GET /index/_mappings -//查看索引指定字段的mapping -GET /index/_mappings/field/ -``` - -## 字段数据类型 - -映射的数据类型也就是 ES 索引支持的数据类型,其概念和 MySQL 中的字段类型相似,但是具体的类型和 MySQL 中有所区别,最主要的区别就在于 ES 中支持可分词的数据类型,如:Text 类型,可分词类型是用以支持全文检索的,这也是 ES 生态最核心的功能。 - -### 数字类型 - -- **long**:64 位有符号整形。 -- **integer**:32 位有符号整形。 -- **short**:16 位有符号整形。 -- **byte**:8位有符号整形。 -- **double**:双精度 64位浮点类型。 -- **float**:单精度 64位浮点类型。 -- **half_float**:半精度 64位浮点类型。 -- **scaled_float**:缩放类型浮点数,按固定 double 比例因子缩放。 -- **unsigned_long**:无符号 64 位整数。 - -### 基本数据类型 - -- **binary**:Base64 字符串二进制值。 -- **boolean**:布尔类型,接收 ture 和 false 两个值。 -- **alias**:字段别名。 - -### Keywords 类型 - -- **keyword**:适用于索引结构化的字段,可以用于过滤、排序、聚合。keyword类型的字段只能通过精确值搜索到。如 Id、姓名这类字段应使用 keyword。 -- **constant_keyword**:始终包含相同值的关键字字段。 -- **wildcard**:可针对类似 grep 的场景。 - -### Dates(时间类型) - -- **date**:JSON 没有日期数据类型,因此 Elasticsearch 中的日期可以是以下三种: - - 包含格式化日期的字符串:例如 "2015-01-01"、 "2015/01/01 12:10:30"。 - - 时间戳:表示自"1970年 1 月 1 日"以来的毫秒数/秒数。 - - date_nanos:此数据类型是对 date 类型的补充。但是有一个重要区别。date 类型存储最高精度为毫秒,而date_nanos 类型存储日期最高精度是纳秒,但是高精度意味着可存储的日期范围小,即:从大约 1970 到 2262。 - -### 对象类型 - -- **object**:非基本数据类型之外,默认的 json 对象为 object 类型。 -- **flattened**:单映射对象类型,其值为 json 对象。 -- **nested** :嵌套类型。 -- **join**:父子级关系类型。 - -### 空间数据类型 - -- **geo_point**:纬度和经度点。 -- **geo_shape**:复杂的形状,例如多边形。 -- **point**:任意笛卡尔点。 -- **shape**:任意笛卡尔几何。 - -### 文档排名类型 - -- **dense_vector**:记录浮点值的密集向量。 -- **rank_feature**:记录数字特征以提高查询时的命中率。 -- **rank_features**:记录数字特征以提高查询时的命中率。 - -### 文本搜索类型 - -- **text**:文本类型。 -- **annotated-text:**包含特殊文本标记,用于标识命名实体。 -- **completion** :用于自动补全,即搜索推荐。 -- **search_as_you_type:** 类似文本的字段,经过优化为提供按类型完成的查询提供现成支持。 -- **token_count**:文本中的标记计数。 - -## 两种映射类型 - -### 自动映射:Dynamic Field Mapping - -| **field type** | **dynamic** | -| -------------- | ---------------------------------- | -| true/false | boolean | -| 小数 | float | -| 数字 | long | -| object | object | -| 数组 | 取决于数组中的第一个非空元素的类型 | -| 日期格式字符串 | date | -| 数字类型字符串 | float/long | -| 其他字符串 | text + keyword | - -除了上述字段类型之外,其他类型都必须显式映射,也就是必须手工指定,因为其他类型ES无法自动识别。 - -### 显式映射 Expllcit Field Mapping - -例如: - -```JSON -PUT test_mapping -{ - "mappings": { - "properties": { - "title": { - "type": "text" - }, - "name": { - "type": "text", - "fields": { - "name2": { - "type": "keyword", - "ignore_ above": 256 - } - } - }, - "age": "byte" - } - } -} -``` - -## 映射参数 - -- **index**:是否对创建对当前字段创建倒排索引,默认 true,如果不创建索引,该字段不会通过索引被搜索到,但是仍然会在 source 元数据中展示。 -- **analyzer**:指定分析器(character filter、tokenizer、Token filters)。 -- **boost**:对当前字段相关度的评分权重,默认1。 -- **coerce**:是否允许强制类型转换,为 true的话 “1”能被转为 1, false则转不了。 -- **copy_to**:该参数允许将多个字段的值复制到组字段中,然后可以将其作为单个字段进行查询。 -- **doc_values**:为了提升排序和聚合效率,默认true,如果确定不需要对字段进行排序或聚合,也不需要通过脚本访问字段值,则可以禁用doc值以节省磁盘空间(不支持text和annotated_text)。 -- **dynamic**:控制是否可以动态添加新字段 - - **true** 新检测到的字段将添加到映射中(默认)。 - - **false** 新检测到的字段将被忽略。这些字段将不会被索引,因此将无法搜索,但仍会出现在_source返回的匹配项中。这些字段不会添加到映射中,必须显式添加新字段。 - - **strict** 如果检测到新字段,则会引发异常并拒绝文档。必须将新字段显式添加到映。 -- **eager_global_ordinals**:用于聚合的字段上,优化聚合性能,但不适用于 Frozen indices。 - - **Frozen indices**(冻结索引):有些索引使用率很高,会被保存在内存中,有些使用率特别低,宁愿在使用的时候重新创建,在使用完毕后丢弃数据,Frozen indices 的数据命中频率小,不适用于高搜索负载,数据不会被保存在内存中,堆空间占用比普通索引少得多,Frozen indices是只读的,请求可能是秒级或者分钟级。 -- **enable**:是否创建倒排索引,可以对字段操作,也可以对索引操作,如果不创建索引,仍然可以检索并在_source元数据中展示,谨慎使用,该状态无法修改。**enable的作用和index类似,区别就是enable可以对全局进行设置**。例如: - -```JSON -PUT my_index -{ - "mappings": { - "enabled": false - } -} -``` - -- **fielddata**:查询时内存数据结构,在首次用当前字段聚合、排序或者在脚本中使用时,需要字段为fielddata数据结构,并且创建倒排索引保存到堆中。 -- **fields**:给field创建多字段,用于不同目的(全文检索或者聚合分析排序)。 -- **format**:格式化。例如: - -```JSON -"date": { - "type": "date", - "format": "yyyy-MM-dd" -} -``` - -- **ignore_above**:超过长度将被忽略。 -- **ignore_malformed**:忽略类型错误。 -- **index_options**:控制将哪些信息添加到反向索引中以进行搜索和突出显示。仅用于text字段。 -- **Index_phrases**:提升 exact_value 查询速度,但是要消耗更多磁盘空间。 -- **Index_prefixes**:前缀搜索。 - - **min_chars**:前缀最小长度> 0,默认 2(包含) - - **max_chars**:前缀最大长度< 20,默认 5(包含) -- **meta**:附加元数据。 -- **normalizer**:normalizer 参数用于解析前(索引或者查询时)的标准化配置。 -- **norms**:是否禁用评分(在 filter 和聚合字段上应该禁用)。 -- **null_value**:为 null 值设置默认值。 -- **position_increment_gap**:参考:https://blog.csdn.net/wlei0618/article/details/128189190 -- **properties**:除了mapping还可用于object的属性设置。 -- **search_analyzer**:设置单独的查询时分析器,**如果定义了analyzer而没有定义search_analyzer,则search_analyzer的值默认会和analyzer保持一致,如果两个都没有定义,则默认是:"standard"。analyzer针对的是元数据,而search_analyzer针对的是传入的搜索词**。 -- **similarity**:为字段设置相关度算法,和评分有关。支持BM25、classic(TF-IDF)、boolean。 -- **store**:设置字段是否仅查询。 -- **term_vector**:运维参数。 - -## Text 和 Keyword 类型 - -### Text 类型 - -#### 概述 - -当一个字段是要被全文搜索的,比如 Email 内容、产品描述,这些字段应该使用 text 类型。设置 text 类型以后,字段内容会被分析,在生成倒排索引以前,字符串会被分析器分成一个一个词项。text类型的字段不用于排序,很少用于聚合。 - -#### 注意事项 - -- 适用于全文检索:如 match 查询。 -- 文本字段会被分词。 -- 默认情况下,会创建倒排索引。 -- 自动映射器会为 Text 类型创建 Keyword 字段。 - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMvaqlT5NT0m2n3F5E1sljQH2OKwFiaibIolpkHJa0iazFkAmmpN3hVkpibWa6ahlO4oE8XdEn32o5gLA/640?wx_fmt=png) - -### Keyword 类型 - -#### 概述 - -Keyword 类型适用于不分词的字段,如姓名、Id、数字等。如果数字类型不用于范围查找,用 Keyword 的性能要高于数值类型。 - -#### 语法和语义 - -如当使用 keyword 类型查询时,其字段值会被作为一个整体,并保留字段值的原始属性。 - -```JSON -GET index/_search -{ - "query": { - "match": { - "title.keyword": "测试文本值" - } - } -} -``` - -#### 注意事项 - -- Keyword 不会对文本分词,会保留字段的原有属性,包括大小写等。 -- Keyword 仅仅是字段类型,而不会对搜索词产生任何影响。 -- Keyword 一般用于需要精确查找的字段,或者聚合排序字段。 -- Keyword 通常和 Term 搜索一起用。 -- Keyword 字段的 ignore_above 参数代表其截断长度,默认 256,**如果超出长度,字段值会被忽略,而不是截断,忽略指的是会忽略这个字段的索引,搜索不到,但数据还是存在的**。 - -## 映射模板 - -### 简介 - -之前讲过的映射类型或者字段参数,都是为确定的某个字段而声明的,如果希望对符合某类要求的特定字段制定映射,就需要用到映射模板:Dynamic templates。映射模板有时候也被称作:自动映射模板、动态模板等。 - -**之前设置mapping的时候,我们明确知道字段名字,但是当我们不确定字段名字的时候该怎么设置mapping?映射模板就是用来解决这种场景的**。 - -### 用法 - -#### 基本语法 - -```JSON -"dynamic_templates": [ - { - "my_template_name": { - ... match conditions ... - "mapping": { ... } - } - }, - ... -] -``` - -#### Conditions参数 - -- **match_mapping_type** :主要用于对数据类型的匹配。 -- **match 和 unmatch**:用于对字段名称的匹配。 - -### 案例 - -```JSON -PUT test_dynamic_template - -{ - "mappings": { - "dynamic_templates": [ - { - "integers": { - "match_mapping_type": "long", - "mapping": { - "type": "integer" - } - } - }, - { - "longs_as_strings": { - "match_mapping_type": "string", - "match": "num_*", - "unmatch": "*_text", - "mapping": { - "type": "keyword" - } - } - } - ] - } -} -``` - -以上代码会产生以下效果: - -- 所有 long 类型字段会默认映射为 integer。 -- 所有文本字段,如果是以 num_ 开头,并且不以 _text 结尾,会自动映射为 keyword 类型。 \ No newline at end of file diff --git "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-Query DSL.md" "b/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-Query DSL.md" deleted file mode 100644 index feaace6..0000000 --- "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-Query DSL.md" +++ /dev/null @@ -1,486 +0,0 @@ -[TOC] - -DSL是Domain Specific Language的缩写,指的是为特定问题领域设计的计算机语言。这种语言专注于某特定领域的问题解决,因而比通用编程语言更有效率。 - -在Elasticsearch(ES)中,DSL指的是Elasticsearch Query DSL,一种以JSON形式表示的查询语言。通过这种语言,用户可以构建复杂的查询、排序和过滤数据等操作。这些查询可以是全文搜索、分面/聚合搜索,也可以是结构化的搜索。 - -## 查询上下文 - -使用query关键字进行检索,倾向于相关度搜索,故需要计算评分。搜索是Elasticsearch最关键和重要的部分。 - -在查询上下文中,一个查询语句表示一个文档和查询语句的匹配程度。无论文档匹配与否,查询语句总能计算出一个相关性分数在`_score`字段上。 - -## 相关度评分:_score - -相关度评分用于对搜索结果排序,评分越高则认为其结果和搜索的预期值相关度越高,即越符合搜索预期值,默认情况下评分越高,则结果越靠前。在7.x之前相关度评分默认使用TF/IDF算法计算而来,7.x之后默认为BM25。 - -## 源数据:_source - -source字段包含索引时原始的JSON文档内容,字段本身不建立索引(因此无法进行搜索),但是会被存储,所以当执行获取请求是可以返回source字段。 - -虽然很方便,但是source字段的确会对索引产生存储开销,因此可以禁用source字段,达到节省存储开销的目的。可以通过以下接口进行关闭。 - -```JSON -PUT my_index -{ - "mappings": { - "_source": { - "enabled": false - } - } -} -``` - -但是需要注意的是这么做会带来一些弊端,_source禁用会导致如下功能无法使用: - -- 不支持update、update_by_query和reindex API。 -- 不支持高亮。 -- 不支持reindex、更改mapping分析器和版本升级。 - -**总结:在禁用source之前,应该仔细考虑是否需要进行此操作。如果只是希望降低存储的开销,可以压缩索引比禁用source更好。** - -## 数据源过滤器 - -例如,假设你的应用只需要获取部分字段(如"name"和"price"),而其他字段(如"desc"和"tags")不经常使用或者数据量较大,导致传输和处理这些额外的数据会增加网络开销和处理时间。在这种情况下,通过设置includes和excludes可以有效地减少每次请求返回的数据量,提高效率。例如: - -```JSON -PUT product -{ - "mappings": { - "_source": { - "includes": ["name", "price"], - "excludes": ["desc", "tags"] - } - } -} -``` - -**Including:**结果中返回哪些field。 - -**Excluding:**结果中不要返回哪些field,不返回的field不代表不能通过该字段进行检索,因为元数据不存在不代表索引不存在,Excluding优先级比Including更高。 - -> 需要注意的是,尽管这些设置会影响搜索结果中_source字段的内容,但并不会改变实际存储在Elasticsearch中的数据。也就是说,"desc"和"tags"字段仍然会被索引和存储,只是在获取源数据时不会被返回。 - -在mapping中定义这种方式不推荐,因为mapping不可变。我们可以在查询过程中使用_source指定返回的字段,如下: - -```JSON -GET product/_search -{ - "_source": { - "includes": ["owner.*", "name"], - "excludes": ["name", "desc", "price"] - }, - "query": { - "match_all": {} - } -} -``` - -Elasticsearch的`_source`字段在查询时支持使用通配符(wildcards)来包含或排除特定字段。使得能够更灵活地操纵返回的数据。 - -关于规则,可以参考以下几点: - -- *:匹配任意字符序列,包括空序列。 -- ?:匹配任意单个字符。 -- [abc]: 匹配方括号内列出的任意单个字符。例如,[abc]将匹配"a", "b", 或 "c"。 - -请注意,通配符表达式可能会导致查询性能下降,特别是在大型索引中,因此应谨慎使用。 - -## 全文检索 - -全文检索是Elasticsearch的核心功能之一,它可以高效地在大量文本数据中寻找特定关键词。 - -在Elasticsearch中,全文检索主要依靠两个步骤:"分析"(Analysis)和"查询"(Search)。 - -1. 分析: 当你向Elasticsearch索引一个文档时,会进行"分析"处理,将原始文本数据转换成称为"tokens"或"terms"的小片段。这个过程可能包括如下操作: - - 切分文本(Tokenization) - - 将所有字符转换为小写(Lowercasing) - - 删除常见但无重要含义的单词(Stopwords) - - 提取词根(Stemming) -2. 查询: 当执行全文搜索时,查询字符串也会经过类似的分析过程,然后再与已经分析过的索引进行比对,找出匹配的结果并返回。 - -Elasticsearch提供了许多种全文搜索的查询类型,例如: - -- Match Query: 最基本的全文搜索查询。 -- Match Phrase Query: 用于查找包含特定短语的文档。 -- Multi-Match Query: 类似Match Query,但可以在多个字段上进行搜索。 -- Query String Query: 提供了丰富的搜索语法,可以执行复杂的、灵活的全文搜索。 - -### match:匹配包含某个term的子句 - -```JSON -GET product/_search -{ - "query": { - "match": { - "name": "xiaomi nfc phone" - } - } -} -``` - -上面的搜索语句,只要文档的"name"字段包含"xiaomi"、"nfc"或者"phone"中的任何一个词,就会被视为匹配。 - -### match_all:匹配所有结果的子句 - -`match_all` 是 Elasticsearch 中的一个查询类型,它匹配所有文档,不需要任何参数。 - -```JSON -GET product/_search -{ - "query": { - "match_all": {} - } -} -``` - -上面的语句等价于: - -```JSON -GET /product/_search -``` - -这个查询将会返回索引中的所有文档。这通常用于在没有特定搜索条件时获取所有的文档,或者与其他查询结合使用(如过滤器)。 - -需要注意,由于 `match_all` 查询可能返回大量的数据,所以一般在使用时都会与分页(pagination)功能结合起来,这样可以控制返回结果的数量,避免一次性加载过多数据导致的性能问题。例如,你可以使用 `from` 和 `size` 参数来限制返回结果: - -```JSON -GET /_search -{ - "query": { - "match_all": {} - }, - "from": 10, - "size": 10 -} -``` - -### multi_match:多字段条件 - -`multi_match` 查询是 Elasticsearch 中用来在多个字段上执行全文查询的功能。它接受一个查询字符串和一组需要在其中执行查询的字段列表。例如: - -```JSON -GET /_search -{ - "query": { - "multi_match" : { - "query": "xiaomi nfc phone", - "fields": [ "name", "description" ] - } - } -}` -``` - -这个查询会在 "name" 和 "description" 两个字段中查找包含 "xiaomi nfc phone" 的文档。 - -`multi_match` 还支持多种类型的匹配模式,如:`best_fields`, `most_fields`, `cross_fields`, `phrase`, `phrase_prefix`等。这些类型的行为略有不同,可以按照实际需求进行选择。 - -例如,“best_fields” 类型会从指定的字段中挑选分数最高的匹配结果计算最终得分,而“most_fields” 类型则会在每个字段中都寻找匹配项并将其分数累加起来。 - -需要注意的是,当使用 `multi_match` 查询时,如果字段不同,其权重可能也会不同。你可以通过在字段名后面添加尖括号(^)和权重值来调整特定字段的权重。例如,`"fields": [ "name^3", "description" ]`表示在"name"字段中的匹配结果权重是"description"字段的三倍。 - -### match_phrase:短语查询 - -`match_phrase` 是 Elasticsearch 中的一种全文查询类型,它用于精确匹配包含指定短语的文档。match_phrase 查询需要字段值中的单词顺序与查询字符串中的单词顺序完全一致。 - -例如: - -```JSON -GET /_search -{ - "query": { - "match_phrase": { - "message": "this is a test" - } - } -} -``` - -这个查询将会找到"message"字段中包含完整短语"this is a test"的所有文档。 - -此外,`match_phrase` 查询还有一个 `slop` 参数,可以定义词组中的词语可能存在的位置偏移量。例如,如果将 `slop` 设置为 1,则查询 "this is a test" 也可匹配 "this is test a",因为 "a" 和 "test" 只需移动一个位置即可匹配。 - -## Query String - -Query String Query是Elasticsearch中的一种查询方式,它允许你使用特定的搜索语法来进行复杂的、灵活的查询。 - -Query String Query是基于Lucene Query Parser解析器的,因此支持丰富的搜索语法,包括但不限于: - -- 基本文本查询: "quick brown fox" -- 逻辑操作符 (AND, OR, NOT): "quick AND brown" -- 范围查询: "age:[18 TO 30]" -- 通配符查询: "qu?ck br*wn" -- 分组: "(quick OR brown) AND fox" -- 字段指定查询: "title:quick" - -下面是几个例子: - -### 查询所有 - -```JSON -GET /product/_search -``` - -### 分页 - -```JSON -GET /product/_search?from=0&size=2&sort=price:asc -``` - -### 精准匹配 exact value - -```JSON -GET /product/_search?q=date:2021-06-01 -``` - -### _all搜索 相当于在所有有索引的字段中检索 - -all搜索与精准匹配就是带不带字段参数的区别,如果把index索引禁用,则all搜索不会去该字段上查询。 - -```JSON -GET /product/_search?q=2021-06-01 -``` - -## 精准查询-Term query - -精确查询用于查找包含指定精确值的文档,而不是执行全文搜索。 - -### term:匹配和搜索词项完全相等的结果 - -举个例子: - -```JSON -GET /_search -{ - "query": { - "term": { - "user": "kimchy" - } - } -} -``` - -这个查询会找到"user"字段精确匹配"kimchy"的所有文档。 - -需要注意的是,`term` 查询对大小写敏感,并且不会进行分词处理。也就是说,如果你在使用 `term` 查询时输入了一个完整的句子,它将尝试查找与这个完整句子精确匹配的文档,而不是把句子拆分成单词进行匹配。 - -#### term和match_phrase的区别 - -`term` 查询和 `match_phrase` 查询是 Elasticsearch 提供的两种查询方式,它们都用于查找文档,但主要的区别在于如何解析查询字符串以及匹配的精确度。 - -1. `term` 查询:这种查询对待查询字符串为一个完整的单位,不进行分词处理,并且大小写敏感。它可以在文本、数值或布尔类型字段上使用,通常用于精确匹配某个字段的确切值。 -2. `match_phrase` 查询:这种查询把查询字符串当作一种短语来匹配。查询字符串会被分词器拆分成单独的词项,然后按照词项在查询字符串中的顺序去匹配文档。只有当文档中的词项顺序与查询字符串中的顺序完全一致时才能匹配成功,match_phrase 查询通常对大小写不敏感,除非你的字段映射或索引设置更改了这个行为。 - -简单来说,`term` 查询更多的是做精确的、字面的匹配,而 `match_phrase` 则是做短语匹配,在搜索结果的精确度上,`term` 查询比 `match_phrase` 更高。 - -### terms:匹配和搜索词项列表中任意项匹配的结果 - -`terms` 查询用于匹配指定字段中包含一个或多个值的文档。这是一个精确匹配查询,不会像全文查询那样对查询字符串进行分析。 - -假设你有一个 "user" 的字段,并且你想找到该字段值为 "John" 或者 "Jane" 的所有文档,你可以使用 `terms` 查询: - -```JSON -GET /_search -{ - "query": { - "terms" : { - "user" : ["John", "Jane"], - "boost" : 1.0 - } - } -} -``` - -上面的查询将返回所有"user" 字段等于 "John" 或者 "Jane" 的文档。 - -其中`boost` 参数用于增加或减少特定查询的相对权重。它将改变查询结果的相关性分数(_score),以影响最终结果的排名。 - -例如,在上述 `terms` 查询中,`boost` 参数被设置为 1.0。这意味着如果字段 "user" 的值包含 "John" 或 "Jane",那么其相关性分数(_score)就会乘以 1.0。因此,这个设置实际上并没有改变任何东西,因为乘以 1 不会改变原始分数。但是,如果你将 `boost` 参数设置为大于 1 的数,那么匹配的文档的 _score 将会提高,反之则会降低。 - -### range:范围查找 - -range 查询允许你查找位于特定范围内的值。这对于日期、数字或其他可排序类型的字段非常有用。 - -下面的语句会查询出age字段大于等于10,小于等于20的文档。 - -例子1:假设你有一些表示博客文章的文档,每个文档都有一个发表日期,并且你想找出在特定日期范围内发布的所有文章,你可以使用 `range` 查询来实现这一目标 - -```JSON -GET /_search -{ - "query": { - "range" : { - "date" : { - "gte" : "2020-01-01", - "lte" : "2020-12-31", - "format": "yyyy-MM-dd" - } - } - } -} -``` - -在上面的查询中,`range` 查询被用来查找字段 "date" 的值在 "2020-01-01" 和 "2020-12-31"(包含)之间的所有文档。 - -`range` 查询支持以下运算符: - -- `gt`:大于 (greater than) -- `gte`:大于等于 (greater than or equal to) -- `lt`:小于 (less than) -- `lte`:小于等于 (less than or equal to) - -例子2:下面的语句会查询出date字段1天前的文档,其中now表示当前时间。 - -```JSON -GET product/_search -{ - "query": { - "range": { - "date": { - "gte": "now-1d/d", - "lt": "now/d" - } - } - } -} -``` - -例子3:下面的语句会查询出date字段小于当前时间,大于2021-04-15T08:00:00的文档。time_zone表示时区,意思就是原文档中的数据会被+8小时再去搜索,例如原文档有条数据是:2021-04-15。则该数据能被查询出来。 - -```JSON -GET product/_search -{ - "query": { - "range": { - "date": { - "time_zone": "+08:00", - "gte": "2021-04-15T08:00:00", - "lt": "now" - } - } - } -} -``` - -## 过滤器-Filter - -过滤器(Filter)是一种特殊类型的查询,它不关心评分 (_score),只关心是否匹配。基于这个原因,过滤器比标准的全文查询更快并且能被缓存。 - -一个典型的使用场景是布尔查询 (`bool`), 它有两个重要的部分:`must` 和 `filter`。`must` 部分用于全文搜索,`filter` 部分用于过滤结果。看一个例子: - -```JSON -GET /_search -{ - "query": { - "bool": { - "must": [ - { "match": { "title": "quick" }} - ], - "filter": [ - { "term": { "published": true }} - ] - } - } -} -``` - -在这个查询中,`bool` 查询包含了一个 `must` 子句和一个 `filter` 子句。`must` 子句会执行全文搜索并对结果进行评分。在这个例子中,它会找出所有标题包含"quick"的文章。 - -`filter` 子句则会在 `must` 子句的基础上进一步过滤结果。在这个例子中,它会筛选出那些已经发布的文章。这个过滤操作不会影响到评分,因为它只关心是否匹配。 - -总的来说,过滤器非常适合用于分类、范围查询或者确认某个字段是否存在等场景。过滤器的效率高并且可以被缓存,所以在大型数据集上性能表现良好。 - -### Filter缓存机制 - -在 Elasticsearch 中,过滤查询结果的缓存机制是非常重要的一个性能优化手段。由于过滤器(filter)只关心是否匹配,而不关心评分 (_score),因此它们的结果可以被缓存以提高性能。 - -每次 filter 查询执行时,Elasticsearch 都会生成一个名为 "bitset" 的数据结构,其中每个文档都对应一个位(0 或 1),表示这个文档是否与 filter 匹配。这个 bitset 就是被存储在缓存中的部分。 - -如果相同的 filter 查询再次执行,Elasticsearch 可以直接从缓存中获取这个 bitset,而不需要再次遍历所有的文档来找出哪些文档符合这个 filter。这大大提高了查询速度,并减少了 CPU 使用。 - -这种缓存策略特别适合那些重复查询的场景,例如用户界面的过滤器和类似的功能,因为他们通常会产生很多相同的 filter 查询。 - -然而,值得注意的是,虽然这种缓存可以显著改善查询性能,但也会占用内存空间。如果你有很多唯一的过滤条件,那么过滤器缓存可能会变得很大,从而导致内存问题。这就需要你对使用的过滤器进行适当的管理和限制。 - -另外,Elasticsearch 默认情况下会自动选择哪些过滤器进行缓存,考虑到查询频率和成本等因素。你也可以手动配置某个特定的 filter 是否需要进行缓存。 - -## 组合查询-Bool query - -组合查询可以组合多个查询条件,bool查询也是采用more_matches_is_better的机制,因此满足must和should子句的文档将会合并起来计算分值。 - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOyKiabe9ph3M760B405ANeTQKKL6ArXcTAUI1g5KV80ib0zNicy5L2usIJuPtDAGvsVa5ibs5Sch5GZw/640?wx_fmt=png) - -boot和minumum_should_match是参数,其他四个都是查询子句。 - -- **must**:必须满足子句(查询)必须出现在匹配的文档中,并将有助于得分。 -- **filter**:过滤器不计算相关度分数。 -- **should**:满足 or子句(查询)应出现在匹配的文档中。 -- **must_not**:必须不满足,不计算相关度分数 ,not子句(查询)不得出现在匹配的文档中。子句在过滤器上下文中执行,这意味着计分被忽略,并且子句被视为用于缓存。 - -例子1:下面的语句表示:包含"xiaomi"或"phone" 并且包含"shouji"的文档例子: - -```JSON -GET product/_search -{ - "query": { - "bool": { - "must": [ - { - "match": { - "name": "xiaomi phone" - } - }, - { - "match_phrase": { - "desc": "shouji" - } - } - ] - } - } -} -``` - -### should与must或filter一起使用 - -当 `should` 子句与 `must` 或 `filter` 子句一起使用时,这时候需要注意了!只要满足了 `must` 或 `filter` 的条件,`should` 子句就不再是必须的。换句话说,如果存在一个或者多个 `must` 或 `filter` 子句,那么 `should` 子句的条件会被视为可选。 - -然而,如果 `should` 子句与 `must_not` 子句单独使用(也就是没有 `must` 或 `filter`),则至少需要满足一个 `should` 子句的条件。 - -这里有一个例子来说明: - -```JSON -GET /_search -{ - "query": { - "bool": { - "must": [ - { "term": { "user": "kimchy" }} - ], - "filter": [ - { "term": { "tag": "tech" }} - ], - "should": [ - { "term": { "tag": "wow" }}, - { "term": { "tag": "elasticsearch" }} - ] - } - } -} -``` - -在这个查询中,`must` 和 `filter` 子句的条件是必须满足的,而 `should` 子句的条件则是可选的。如果匹配的文档同时满足 `should` 子句的条件,那么它们的得分将会更高。 - -那如果我们一起使用的时候想让should满足该怎么办?这时候`minimum_should_match` 参数就派上用场了。 - -### minimum_should_match - -minimum_should_match参数定义了在 `should` 子句中至少需要满足多少条件。 - -例如,如果你有5个 `should` 子句并且设置了 `"minimum_should_match": 3`,那么任何匹配至少三个 `should` 子句的文档都会被返回。 - -这个参数可以接收绝对数值(如 `2`)、百分比(如 `30%`)、和组合(如 `3<90%` 表示至少匹配3个或者90%,取其中较大的那个)等不同类型的值。 - -注意:如果 `bool` 查询中只有 `should` 子句(没有 `must` 或 `filter`),那么默认情况下至少需要匹配一个 `should` 条件,也就是`minimum_should_match`默认值是1,除非 `minimum_should_match` 明确设定为其他值。如果包含 `must` 或 `filter`的情况下`minimum_should_match`默认值 0。 - -所以我们可以在包含`must` 或 `filter`的情况下,设置`minimum_should_match`值来满足`should`子句中的条件。 \ No newline at end of file diff --git "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\346\240\270\345\277\203\346\246\202\345\277\265.md" "b/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\346\240\270\345\277\203\346\246\202\345\277\265.md" deleted file mode 100644 index d3f0ab3..0000000 --- "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\346\240\270\345\277\203\346\246\202\345\277\265.md" +++ /dev/null @@ -1,127 +0,0 @@ -[TOC] - -这章主要是对Elasticsearch中的基本概念以及涉及到的一些名词做下讲解,能够对Elasticsearch有一个初步的认识。 - -## 节点 - -- 每个Elasticsearch节点实际上就是一个Java进程,就是一个Elasticsearch的实例。 -- 一个节点 ≠一台服务器,也就是说我可以在一台服务器上启动多个Elasticsearch实例。 - -## 角色 - -集群节点角色可以在配置文件`elasticsearch.yml`中通过`node.roles`配置,如果配置了节点角色,那么该节点将只会执行配置的角色功能。 - -### master:候选节点 - -所谓master节点,就是在主节点down机的时候,可以参与选举,取而代之的节点。**举个例子:主节点好比班长,在班长不在的时候(主节点down机了),要选举出一个临时班长(master中选举)**。master节点不仅有选举权还有被选举权。每个master节点主要负责索引创建、索引删除、追踪节点信息和决定分片分配节点等。 - -配置节点(下面节点配置方法同): - -```text -node.roles: [ master ] -``` - -### data:数据节点 - -数据节点顾名思义就是存放数据的节点,数据节点负责存储文档数据和数据的CRUD操作。因此该节点是CPU和IO密集型,需要实时监控该节点资源信息,以免过载。数据节点又分为:**data_content,data_hot,data_warm,data_code**。 - -- data_content:数据内容节点,目录节点负责存储常量数据,且不随着时间的推移,改变数据的温层(hot、warm、cold)。且该节点的查询优先级是高于其它IO操作,所以该节点search和aggregations都会较快一些。 -- data_hot:热节点,保存热数据,经常会被访问,用于存储最近频繁搜索和修改的时序数据。 -- data_code:冷节点,保存冷数据,很少会被访问,当数据不再更新,那么可以将该数据移动到冷数据节点;冷数据节点用于存储只读,且访问频率较低的数据。该节点机器性能可以低一点。 -- data_warm:温节点,介于热节点和冷节点之间(温节点是我自己翻译的),当数据访问频率下降,可以将其移动到温节点,温节点用于存储修改较少,但仍然有查询的数据。查询的频率肯定比热点节点要少。 - -### Ingest:预处理节点 - -作用类似于Logstash中的Filter,Ingest其实就是管道的入口节点,比如说我们在做日志分析的时候,可以把日志输出的数据交给预处理节点做预处理。 - -### ml:机器学习节点 - -机器学习节点负责处理机器学习相关请求。 - -### remote_ cluster_ client:候选客户端节点 - -远程候选节点可以作为远程集群的客户端,主要负责搜索远程集群数据和同步两个集群间数据。 - -### transform:转换节点 - -转换节点会进行一种特殊操作,通过特定聚集语句计算,然后将结果写到新的索引中。 - -### voting_ only:仅投票节点 - -在master选举过程中,仅投票节点顾名思义就是仅仅投票,不会被选举为master。 - -### Coordinating only node:协调节点 - -协调节点主要负责根据集群状态路由分发搜索,路由分发bulk操作。**此外每个节点都是自带协调节点功能**。 - -## 分片 - -分片的思想在很多分布式应用和海量数据处理的场所非常常见,通常来说,面对海量数据的存储,单个节点显得力不从心。通俗解释,分片就是将数据拆分多份,放到不同的服务器节点。 - -Elasticsearch里的分片为为2种:**主分片和副本分片**。 - -### Shards主分片 - -es可以把一个完整的索引分成多个分片,这样的好处是可以把一个大的索引拆分成多个,分布到不同的节点上。构成分布式搜索。**分片的数量只能在索引创建前指定,并且索引创建后不能更改**。这里和索引分片的算法有关,因为是通过取模算法去判断分到哪,如果改变了就无法正常查询之前的索引。 - -当客户端发起创建document的时候,es需要确定这个document放在该index哪个shard上。这个过程就是数据路由。**路由算法:shard = hash(routing) % number_of_primary_shards**。这里的routing指的就是document的id,如果number_of_primary_shards在查询的时候取余发生的变化,无法获取到该数据。 - -### Replicas副本分片 - -代表索引副本,es可以设置多个索引的副本,副本的作用一是提高系统的容错性,当某个节点某个分片损坏或丢失时可以从副本中恢复。二是提高es的查询效率,es会自动对搜索请求进行负载均衡。 - -- 一个索引包含一个或多个分片,在7.0之前默认五个主分片,每个主分片一个副本;在7.0之后默认一个主分片。副本可以在索引创建之后修改数量,但是主分片的数量一旦确定不可修改,只能创建索引。 -- 每个分片都是一个Lucene实例,有完整的创建索引和处理请求的能力。 -- ES会自动在nodes上做分片均衡。 -- 一个doc不可能同时存在于多个主分片中,但是当每个主分片的副本数量不为一时,可以同时存在于多个副本中。 -- 每个主分片和其副本分片不能同时存在于同一个节点上,所以最低的可用配置是两个节点互为主备。 -- 副本分片是不能直接写入数据的,只能通过主分片做数据同步。 -- 增减节点时,shard会自动在nodes中负载均衡。 - -## 集群 - -上面所说的节点角色构成了整个集群。 - -### 集群状态 - -- Green:主/副分片都已经分配好且可用,集群处于最健康的状态100%可用。 -- Yellow:主分片可用,但是至少有一个副本是未分配的。这种情况下数据也是完整的,但是集群的高可用性会被弱化。 -- Red:至少有一个不可用的主分片。此时只是部分数据可以查询,已经影响到了整体的读写,需要重点关注。 - -### 健康值检查 - -```java -//查看集群健康状况 -_cat/health -_cluster/health -``` - -#### 返回参数说明 - -示例: - -```JSON -{ - "cluster_name" : "elastic-log-xxx", - "status" : "green", - "timed_out" : false, - "number_of_nodes" : 24, - "number_of_data_nodes" : 21, - "active_primary_shards" : 27777, - "active_shards" : 27804, - "relocating_shards" : 0, - "initializing_shards" : 0, - "unassigned_shards" : 0, - "delayed_unassigned_shards" : 0, - "number_of_pending_tasks" : 0, - "number_of_in_flight_fetch" : 0, - "task_max_waiting_in_queue_millis" : 0, - "active_shards_percent_as_number" : 100.0 -} -``` - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMvaqlT5NT0m2n3F5E1sljQaENH59POn5y5GFhhmfcu1zrSbvTM6Wy7JjBK7ABkep1RMROpTo9Jrw/640?wx_fmt=png) - -## 索引和文档 - -**es中索引类比为关系型数据库中的Table,在7.0版本之前index由若干个type组成,type实际上是文档的逻辑分类,而文档是es存储的最小单元。7.0及之后弱化了type的概念,7.x版本index只有一个type:_doc。文档(doc)可以类比为关系型数据库中的行,每个文档都有一个文档id**。 \ No newline at end of file diff --git "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204CRUD.md" "b/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204CRUD.md" deleted file mode 100644 index ec2ca63..0000000 --- "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204CRUD.md" +++ /dev/null @@ -1,231 +0,0 @@ -[TOC] - -这章主要是介绍Elasticsearch中索引的基本操作API,即增删改查(CRUD)。 - -## 创建索引 - -```JSON -PUT /index?pretty -``` - -?pretty可加可不加,主要就是对输出进行格式化,更加好看点。 - -## 删除索引 - -```JSON -DELETE /index?pretty -``` - -## 查询数据 - -查询当前索引的信息 - -```JSON -GET /index/_search -//_search:查询 index 索引下的所有信息。 -``` - -输出示例如下: - -```JSON -{ - //消耗时间 - "took": 11, - "timed_out": false, - "_shards" : { - "total": 1, - "successful" : 1, - "skipped": 0, - "failed": 0 -}, -"hits": { - "total": { - "value": 0, - "relation": "eq" - }, - "max_ score": null, - "hits": [] -} -} -``` - -获取所有索引数据的信息 - -```JSON -GET _cat/indices?v -``` - -查询指定文档id - -```JSON -GET /index/_doc/doc_id -``` - -## 添加 & 更新数据 - -```JSON -PUT /index/_doc/doc_id -{ - JSON数据 -} - -//例如:PUT /index/_doc/1 -//{ -// "field1": "value1", -// "field2": 123 -//} -``` - -PUT也可以用于更新数据,比如我有一个文档有两个字段:name和age。我想更新name为:小明,可以这么写: - -```JSON -PUT /index/_doc/1 -{ -"name": "小明" -} -``` - -**需要注意的是PUT既可以用于插入,也可以用于更新,所以PUT的更新是全量更新,而不是部分更新。也就是上面的语句执行之后,文档会被直接替换,只会有name字段,字段值为小明**。 - -如果我们想要部分更新的话,可以使用POST,示例如下: - -```JSON -POST /index/_doc/id/_update -{ - "doc": { - "name": "小明" - } -} -``` - -把PUT换位POST,并把更新的字段包进doc里,就能实现更新部分字段。除了上面那种写法外,还可以使用下面这种写法,更推荐使用下面这种写法: - -```JSON -POST /index/_update/1 -{ -"doc": { -"name": "小明" -} -} -``` - -## cat命令 - -cat命令在es中会经常使用,下面介绍cat命令中常用的几个命令。 - -### 公共参数 - -cat命令组成形式是:`GET /_cat/indices?format=json&pretty`, `?`之前是命令,之后是参数,多个参数用`&`分隔。公共参数有下: - -```JSON -//v 显示更加详细的信息 -GET /_cat/master?v -//help 显示命令结果字段说明 -GET /_cat/master?help -//h 显示命令结果想要展示的字段 -GET /_cat/master?h=ip,node -GET /_cat/master?h=i*,node -//format 显示命令结果展示格式,支持格式类型:text json smile yaml cbor -GET /_cat/indices?format=json&pretty -//s 显示命令结果按照指定字段排序 -GET _cat/indices?v&s=index:desc,docs.count:desc -``` - -## 常用命令 - -### aliases 显示别名 - -```JSON -GET /_cat/aliases -``` - -GET /_cat/aliases是获取所有别名,如果想获得某个索引的别名可以使用:`GET index/alias`。 - -### allocation 显示每个节点的分片数和磁盘使用情况 - -```JSON -GET /_cat/allocation -``` - -### count 显示整个集群或者索引的文档个数 - -```JSON -GET /_cat/count -GET /_cat/count/index -``` - -### fielddata 显示每个节点字段所占的堆空间 - -```JSON -GET /_cat/fielddata -GET /_cat/fielddata?fields=name,addr -``` - -### health 显示集群是否健康 - -```JSON -GET /_cat/health -``` - -### indices 显示索引的情况 - -```JSON -GET /_cat/indices -GET /_cat/indices/index -``` - -### master 显示master节点信息 - -```JSON -GET /_cat/master -``` - -### nodes 显示所有node节点信息 - -```JSON -GET /_cat/nodes -``` - -### recovery 显示索引恢复情况 - -当索引迁移的任何时候都可能会出现恢复情况,例如,快照恢复、复制更改、节点故障或节点启动期间。 - -```JSON -GET /_cat/recovery -``` - -### thread_pool 显示每个节点线程运行情况。 - -```JSON -GET /_cat/thread_pool -GET /_cat/thread_pool/bulk -GET /_cat/thread_pool/bulk?h=id,name,active,rejected,completed -``` - -### shards 显示每个索引各个分片的情况 - -展示索引的各个分片,主副分片,文档个数,所属节点,占存储空间大小 - -```JSON -GET /_cat/shards -GET /_cat/shards/index -GET _cat/shards?h=index,shard,prirep,state,unassigned.reason -``` - -分片的状态:`INITIALIZING`初始化;`STARTED`分配完成;`UNASSIGNED`不能分配;可以通过`unassigned.reason`属性查看不能分配的原因。 - -### segments 显示每个segment的情况 - -包括属于索引,节点,主副,文档数等 - -```JSON -GET /_cat/segments -GET /_cat/segments/index -``` - -### templates 显示每个template的情况 - -```JSON -GET /_cat/templates -GET /_cat/templates/mytempla* -``` \ No newline at end of file diff --git "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204\346\211\271\351\207\217\346\223\215\344\275\234.md" "b/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204\346\211\271\351\207\217\346\223\215\344\275\234.md" deleted file mode 100644 index f6a4ed4..0000000 --- "a/docs/md/es/\345\255\246\345\245\275Elasticsearch\347\263\273\345\210\227-\347\264\242\345\274\225\347\232\204\346\211\271\351\207\217\346\223\215\344\275\234.md" +++ /dev/null @@ -1,151 +0,0 @@ -[TOC] - -Elasticsearch 提供了 _bulk API 来执行批量操作,它允许你在单个 HTTP 请求中进行多个索引/删除/更新/创建操作。这种方法比发送大量的单个请求更有效率。 - -## 基于mget的批量查询 - -mget(多文档获取)是Elasticsearch中提供的一个API,用于一次性从同一个索引或者不同索引中检索多个文档。 - -例子一: - -以下是一个Elasticsearch的`mget`(多文档获取)操作示例。在这个示例中,我们将获取索引 `test-index` 中具有特定ID的多个文档。 - -```Java -GET /test-index/_mget -{ - "ids": ["1", "2"] -} -``` - -在上述请求中,我们正在获取ID为 "1" 和 "2" 的文档。 - -例子二: - -你也可以在不同的索引中获取文档,只需指定每个文档的 `_index` 和 `_id`: - -```Java -GET /_mget -{ - "docs": [ - { - "_index": "test-index", - "_id": "1" - }, - { - "_index": "another-index", - "_id": "2" - } - ] -} -``` - -在这个请求中,我们从 "test-index" 索引获取ID为 "1" 的文档,并从 "another-index" 索引获取ID为 "2" 的文档。 - -例子三: - -在以下的Elasticsearch `mget`(多文档获取)例子中,我们将从两个不同的索引获取文档,并且只返回特定的字段: - -```Java -GET /_mget -{ - "docs": [ - { - "_index": "test-index-1", - "_id": "1", - "_source": ["field1", "field2"] - }, - { - "_index": "test-index-2", - "_id": "2", - "_source": "field3" - } - ] -} -``` - -在这个请求中,我们从 "test-index-1" 索引获取ID为 "1" 的文档,并只返回 "field1" 和 "field2" 字段。同时,我们从 "test-index-2" 索引获取ID为 "2" 的文档,并只返回 "field3" 字段。 - -源过滤 (`_source`) 可以用来限制返回的字段。你可以提供一个字段的列表,或者一个单独的字段。注意,如果你请求的字段不存在,它将不会出现在响应中。 - -## 基于bulk的批量增删改 - -bulk基本格式如下: - -```Java -POST //_bulk -{"action": {"metadata"}} -{"data"} -``` - -bulk api对json的语法有严格的要求,除了delete外,每一个操作都要两个json串(metadata和business data),且每个json串内不能换行,非同一个json串必须换行,否则会报错。 - -bulk操作中,任意一个操作失败,是不会影响其他的操作的,但是在返回结果里,会告诉你异常日志。 - -### 增加 - -```Java -POST /_bulk -{ "create" : { "_index" : "product2", "_id" : "2" } } -{ "field1" : "value1", "field2" : "value2" } -``` - -在这个请求中,我们创建了一个新的文档,其在 "product2" 索引中的ID为 "2",并且包含两个字段 "field1" 和 "field2"。 - -请注意,这个操作都由两行组成:第一行包含操作类型(在这个示例中为 "create")和元数据;第二行包含要创建或索引的实际文档数据。 - -### 删除 - -删除文档,ES对文档的删除是懒删除机制,即标记删除(lazy delete原理)。 - -```Java -POST /_bulk -{ "delete" : { "_index" : "test-index", "_id" : "1" } } -{ "delete" : { "_index" : "test-index", "_id" : "2" } } -``` - -在这个请求中,我们从 "test-index" 索引中删除了ID为 "1" 和 "2" 的两个文档。 - -注意,每个 `delete` 操作仅由一行组成,这一行包含操作类型(在这个示例中为 "delete")以及元数据。 - -### 修改 - -```Java -POST /_bulk -{ "update" : { "_index" : "test-index", "_id" : "1" } } -{ "doc" : { "field1" : "new_value1", "field2" : "new_value2" }} -{ "update" : { "_index" : "test-index", "_id" : "2" } } -{ "doc" : { "field1" : "new_value3", "field2" : "new_value4" }} -``` - -在这个请求中,我们在 "test-index" 索引中更新了两个文档: - -- 我们更新了ID为 "1" 的文档,设置 "field1" 和 "field2" 字段的值为 "new_value1" 和 "new_value2"。 -- 我们也更新了ID为 "2" 的文档,设置 "field1" 和 "field2" 字段的值为 "new_value3" 和 "new_value4"。 - -## filter_path - -在Elasticsearch中,`filter_path`参数用于过滤返回的响应内容,可以用于减小 Elasticsearch 返回的数据量。当你指明一个或多个路径时,返回的JSON对象就只会包含这些路径下的键,它接收一个逗号分隔的列表,其中包含了你想要返回的 JSON 对象内的路径。这个参数支持通配符(`*`)匹配和数组元素(`[]`)匹配。列如: - -``` -POST /_bulk?filter_path=items.*.error -``` - -上述请求中的 `filter_path=items.*.error` 会让Elasticsearch仅返回 `_bulk` API调用结果中的错误信息。`items.*.error` 这个路径表示,在返回的响应中,匹配到所有存在 `error` 字段的 `items`。 - -这样做有两个主要好处: - -1. 它可以提升Elasticsearch的性能,因为少量的数据意味着更快的序列化和反序列化。 -2. 它可帮助你聚焦于感兴趣的部分,不必处理无关的数据。 - -请注意,`*` 是通配符,代表任何值。 - -以下是一些其他 `filter_path` 的示例: - -1. `filter_path=took`: 这个请求仅返回执行请求所花费的时间(以毫秒为单位)。 -2. `filter_path=items._id,items._index`: 这个请求仅返回每个 item 的 `_id` 和 `_index` 字段。 -3. `filter_path=items.*.error`: 这个请求会返回所有包含 `error` 字段的 items。 -4. `filter_path=hits.hits._source`: 这个请求仅返回搜索结果中的原始文档内容。 -5. `filter_path=_shards, hits.total`: 这个请求返回关于 `shards` 的信息和命中的总数。 -6. `filter_path=aggregations.*.value`: 这个请求仅返回每个聚合的值。 - -请注意,如果你在 `filter_path` 中指定了多个字段,你需要使用逗号将它们分隔开。 \ No newline at end of file diff --git "a/docs/md/java/ExecutorCompletionService\350\257\246\350\247\243.md" "b/docs/md/java/ExecutorCompletionService\350\257\246\350\247\243.md" new file mode 100644 index 0000000..55c7c07 --- /dev/null +++ "b/docs/md/java/ExecutorCompletionService\350\257\246\350\247\243.md" @@ -0,0 +1,153 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + +[TOC] + +## 摘要 + +ExecutorCompletionService 是Java并发编程中的一个有用的工具类,它实现了 CompletionService 接口。ExecutorCompletionService 将 Executor 和BlockingQueue 功能融合在一起,使用它可以提交我们的任务。这个任务委托给 Executor 执行,可以使用 ExecutorCompletionService 对象的 take() 和 poll() 方法获取结果。 + +本文将深入讲解 ExecutorCompletionService 的使用以及源码解析。 + +## ExecutorCompletionService适用场景 + +ExecutorCompletionService在以下场景中特别有用: + +- **并行任务处理**:当需要同时执行多个任务,并按照完成的顺序获取它们的结果时,可以使用ExecutorCompletionService来简化任务提交和结果获取的流程。 +- **高性能计算**:在需要进行大规模计算或复杂计算的场景中,可以将任务拆分成多个子任务,并使用ExecutorCompletionService来管理和获取子任务的结果。 + +假设现在有一批需要进行计算的任务,为了提高整批任务的执行效率,我们可以使用线程池来异步计算这些任务。通过向线程池中不断提交任务并保留与每个任务关联的Future对象。最后,我们可以遍历这些Future对象,并通过调用 get() 方法获取每个任务的计算结果。 + +**Future的不足** + +Future 没有办法回调,只能手动去调用,当通过 get() 方法获取线程的返回值时,会导致阻塞,也就是和当前这个 Future 关联的计算任务执行完成的时候才返回结果,新任务必须等待已完成任务的结果才能继续进行处理。 + +这样会浪费很多时间,因为我们不知道哪个线程先执行完了,只能挨个去获取结果,这样已经完成的线程会因为前面未完成的线程的耗时而无法提前进行汇总,最好是谁先执行完成,谁先返回。 + +而 ExecutorCompletionService 可以实现这样的效果,节省获取完成结果的时间,它的内部有一个先进先出的阻塞队列,用于保存已经执行完成的 Future,通过调用它的 take() 方法或 poll() 方法可以获取到一个已经执行完成的 Future,进而通过调用 Future 接口实现类的 get() 方法获取最终的结果。 + +**CompletionService的目标是任务谁先完成谁先获取,即结果按照完成先后顺序排序** + +## ExecutorCompletionService使用 + +ExecutorCompletionService 提供了一种方便的方式来处理一组异步任务,并按照完成的顺序获取它们的结果。它内部使用了Executor框架来执行任务,并且内部管理着一个已完成任务的阻塞队列,在结果获取上提供了更加灵活和高效的机制。 + +下面是一个简单的例子来演示ExecutorCompletionService的基本使用: + +```java +public class ExecutorCompletionServiceExample { + public static void main(String[] args) throws InterruptedException, ExecutionException { + ExecutorService executor = Executors.newFixedThreadPool(5); + CompletionService completionService = new ExecutorCompletionService<>(executor); + + // 提交任务 + for (int i = 0; i < 10; i++) { + final int taskId = i; + completionService.submit(() -> { + double sleepTime = Math.random() * 1000; + Thread.sleep((long) sleepTime); // 模拟耗时操作 + return "Task " + taskId + " completed,cost time: " + sleepTime; + }); + } + + // 获取结果 + for (int i = 0; i < 10; i++) { + Future future = completionService.take(); + String result = future.get(); + System.out.println(result); + } + + executor.shutdown(); + } +} +``` + +输出: + +```java +Task 2 completed,cost time: 170.01927312611775 +Task 3 completed,cost time: 460.9622858036789 +Task 1 completed,cost time: 563.24738180643 +Task 0 completed,cost time: 595.938819219159 +Task 5 completed,cost time: 480.4473056068137 +Task 4 completed,cost time: 748.2343208613524 +Task 6 completed,cost time: 370.4679098376097 +Task 7 completed,cost time: 270.45945981324905 +Task 9 completed,cost time: 336.5536570760892 +Task 8 completed,cost time: 577.5774464801026 +``` + +在上述代码中,我们创建了一个固定大小的线程池,并使用 ExecutorCompletionService 来提交和获取任务的结果。通过调用`completionService.submit()`方法来提交任务,并随机指定睡眠时间,来模拟任务执行的耗时,然后通过`completionService.take()`方法来获取已完成的任务结果。 + +可以看到是按照任务的执行耗时顺序去获取结果的。 + +## ExecutorCompletionService原理解析 + +ExecutorCompletionService 提供了两个构造函数,一个可以指定阻塞队列,另一个使用内部默认的阻塞队列,两个构造函数都需要传进线程池参数。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP4AeGiaIibscmL8AGee4KDbTHIeYd1RmMCn9PDAd6thkGaoquiaR0GB7jnbQbFk8AKMCib9ZV8IjuLRQ/640) + +提供了三个获取方法,可以看到都是从队列中获取。 + +- take()/poll() 方法的工作都委托给内部的已完成任务队列 completionQueue。 +- 如果队列中有已完成的任务, take() 方法就返回任务的结果,否则阻塞等待任务完成。 +- poll() 与 take() 方法不同,poll() 有两个版本: + - 无参的 poll() 方法:如果完成队列中有数据就返回,否则返回null。 + - 有参数的 poll() 方法:如果完成队列中有数据就直接返回,否则等待指定的时间,到时间后如果还是没有数据就返回null。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP4AeGiaIibscmL8AGee4KDbT3NyPwDRreuZeLk0y68NBwN09211oDT8KSO4chIlhIc5z8QVMQj9BFQ/640) + +两个提交任务方法,可以看到 submit() 方法最终会委托给内部的 executor 去执行任务,提交任务的时候会将任务封装成 QueueingFuture 对象。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP4AeGiaIibscmL8AGee4KDbTsYcu0NAwW2DgcqTfp4DdHc3WRbVGgZI3auWJJzBChM75ibL7jexTvtg/640) + +ExecutorCompletionService内部维护了 `QueueingFuture` 类,`QueueingFuture` 继承了 `FutureTask`,并重写了 `done(`) 方法, + +可以看到 done() 方法在任务完成的时候会将结果存进 已完成任务队列 completionQueue 中。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP4AeGiaIibscmL8AGee4KDbTObydfiaiboKvlvKsXyGCic7RzekzHgGbYSdxb9ibKAMkJtOiby6G29O9LJA/640) + +Futuretask 的 done() 方法是用来标记一个任务已经完成的方法。当一个 Futuretask 中的任务完成后,就会调用 done() 方法通知。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP4AeGiaIibscmL8AGee4KDbT5A6I07gXibMZwaqCcesF3T4rX8ObECdZA7eO3P1q9MyCkoAt9o2wVbQ/640) + +默认是空方法,不会执行任何动作。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP4AeGiaIibscmL8AGee4KDbTmdtibiaq3OibxPiaWfveAafqLdIzqJBBMXrOCJXgrKFAlaLu2ovfdjGoXw/640) + + + +**执行流程** + +当我们使用ExecutorCompletionService类时,它能够按照任务完成的顺序获取它们的结果,这是因为ExecutorCompletionService类内部结合了QueueingFuture类和done()方法的机制。以下是源码流程步骤解释: + +1. 提交任务: + - 我们通过submit方法将任务提交给ExecutorCompletionService。在提交任务时,ExecutorCompletionService会使用自定义的QueueingFuture类来包装任务,并将其交给底层线程池执行。 +2. QueueingFuture类: + - QueueingFuture类是ExecutorCompletionService的内部类,继承自FutureTask。它的构造方法接收一个Callable对象作为参数。 + - 在QueueingFuture类中,它重写了done()方法。done()方法会在任务执行完成后被调用。 +3. 任务执行完成时的处理: + - 当任务执行完成后,在底层线程池的Worker线程中,会调用QueueingFuture的done()方法。 + - 在done()方法中,QueueingFuture会首先调用父类FutureTask的done()方法,以触发对计算结果的获取。然后,它会将任务的结果存储到一个内部的BlockingQueue队列中(即completionQueue)。 +4. 获取任务结果: + - 当我们调用take方法获取任务结果时,它会从completionQueue队列中取出已完成的任务结果,并返回该结果。如果队列为空,则会阻塞等待,直到有任务完成并返回结果。 + - take方法内部会调用QueueingFuture的get()方法,从而触发对应任务的计算结果的获取。 +5. 保证按顺序获取结果: + - 由于completionQueue是一个阻塞队列,并且在done()方法中将任务结果按照完成的顺序放入队列中,因此我们可以通过按顺序获取队列中的任务结果,来保证按照任务完成的顺序获取它们的结果。 + +通过以上源码流程步骤,ExecutorCompletionService类能够按照任务完成的顺序获取结果。它利用QueueingFuture类包装任务并存储结果到阻塞队列中,在任务执行完成后,按照完成的顺序将结果放入队列,从而实现了按顺序获取结果的功能。 + +## 注意事项 + +在使用ExecutorCompletionService时,需要注意以下事项: + +- **合理选择线程池大小**:根据任务的数量和复杂性,合理选择线程池的大小,以充分利用系统资源并避免资源浪费。 +- **及时处理异常**:在任务执行过程中,如果发生异常,需要及时处理和记录异常信息,以保证程序的稳定性和可靠性。 +- **使用Future对象进行任务取消和超时控制**:通过使用Future对象的cancel方法,可以取消正在执行的任务。同时,可以通过调整 poll 方法的参数来设置超时时间,避免长时间等待任务结果而导致阻塞。 + +## 总结 + +ExecutorCompletionService是一个强大且灵活的工具类,能够简化异步任务的处理和结果获取过程。通过使用ExecutorCompletionService,我们可以更加高效地处理一组异步任务,并按照完成的顺序获取它们的结果。 + +本文介绍了ExecutorCompletionService的基本使用方法,并对其源码进行了解析。希望通过这篇文章能够帮助读者更好地理解和应用ExecutorCompletionService。 \ No newline at end of file diff --git "a/docs/md/java/\345\274\202\346\255\245\347\274\226\347\250\213\345\210\251\345\231\250\357\274\232CompletableFuture\346\267\261\345\272\246\350\247\243\346\236\220.md" "b/docs/md/java/\345\274\202\346\255\245\347\274\226\347\250\213\345\210\251\345\231\250\357\274\232CompletableFuture\346\267\261\345\272\246\350\247\243\346\236\220.md" new file mode 100644 index 0000000..dcddf56 --- /dev/null +++ "b/docs/md/java/\345\274\202\346\255\245\347\274\226\347\250\213\345\210\251\345\231\250\357\274\232CompletableFuture\346\267\261\345\272\246\350\247\243\346\236\220.md" @@ -0,0 +1,605 @@ +## 摘要 + +在异步编程中,我们经常需要处理各种异步任务和操作。Java 8引入的 CompletableFuture 类为我们提供了一种强大而灵活的方式来处理异步编程需求。CompletableFuture 类提供了丰富的方法和功能,能够简化异步任务的处理和组合。 + +本文将深入解析 CompletableFuture,希望对各位读者能有所帮助。 + +**CompletableFuture 适用于以下场景** + +- 并发执行多个异步任务,等待它们全部完成或获取其中任意一个的结果。 +- 对已有的异步任务进行进一步的转换、组合和操作。 +- 异步任务之间存在依赖关系,需要按照一定的顺序进行串行执行。 +- 需要对异步任务的结果进行异常处理、超时控制或取消操作。 + +## 如何使用 + +下面是一个演示 CompletableFuture 如何使用的代码示例: + +```java +public class CompletableFutureExample { + + public static void main(String[] args) { + // 创建CompletableFuture对象,并定义异步任务 + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + // 异步任务的逻辑代码 + // 在这里执行耗时操作或其他需要异步执行的任务 + try { + TimeUnit.SECONDS.sleep(2); // 模拟耗时操作 + } catch (InterruptedException e) { + e.printStackTrace(); + } + return "Hello, "; + }); + + // 添加任务完成后的回调方法 + CompletableFuture resultFuture = future.thenApplyAsync(result -> { + // 任务完成后的处理逻辑 + // result为上一步任务的结果 + return result + "World!"; + }); + + // 组合多个CompletableFuture对象 + CompletableFuture combinedFuture = future.thenCombine(resultFuture, (result1, result2) -> { + // 对多个CompletableFuture的结果进行组合处理 + return result1 + result2 + " Welcome to the CompletableFuture world!"; + }); + + // 异常处理 + CompletableFuture exceptionHandledFuture = combinedFuture.exceptionally(ex -> { + // 异常处理逻辑 + System.out.println("任务执行出现异常:" + ex.getMessage()); + return "Fallback Result"; + }); + + // 等待并获取任务的结果 + try { + String result = exceptionHandledFuture.get(); + System.out.println("任务的最终结果为:" + result); + } catch (InterruptedException | ExecutionException e) { + // 处理异常情况 + e.printStackTrace(); + } + } +} +``` + +结果输出: + +```java +任务的最终结果为:Hello, Hello, World! Welcome to the CompletableFuture world! +``` + +首先,我们创建了一个`CompletableFuture`对象`future`。在`future`中,我们使用`supplyAsync`方法定义了一个异步任务,其中 lambda表达式 中的代码会在另一个线程中执行。在这个例子中,我们模拟了一个耗时操作,通过`TimeUnit.SECONDS.sleep(2)`暂停了2秒钟。 + +然后,我们添加了一个回调方法`resultFuture`。在这个回调方法中,将前一个异步任务的结果作为参数进行处理,并返回处理后的新结果。在这个例子中,我们将前一个任务的结果与字符串 "World!" 连接起来,形成新的结果。 + +接下来,我们使用`thenCombine`方法组合了两个`CompletableFuture`对象:`future`和`resultFuture`。在这个组合任务中,我们将两个任务的结果进行组合处理,返回最终的结果。在这个例子中,我们将前两个任务的结果与字符串 " Welcome to the CompletableFuture world!" 连接起来。 + +此外,我们还处理了异常情况。通过`exceptionally`方法,我们定义了一个异常处理回调方法。如果在任务执行过程中发生了异常,我们可以在这里对异常进行处理,并返回一个默认值作为结果。 + +最后,我们使用`get`方法等待并获取最终的任务结果。需要注意的是,`get`方法可能会阻塞当前线程,直到任务完成并返回结果。在这个例子中,我们使用`try-catch`块捕获可能的异常情况,并打印出最终的任务结果。 + +这个例子只是部分展示了`CompletableFuture`的功能,实际上它比你想象的还要强大! + +## 源码解析 + +CompletableFuture 的源码非常庞大和复杂,涉及到并发、线程池、同步机制等多方面的知识。在这里,我们只重点介绍 CompletableFuture 的核心实现原理。 + +### 基本结构 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScO4YMibB6Xu8xMZ8QkQz8VE6zdA0AMoo6OHiaowFoic9ZRSrbyuKPbdxeW04bgE8tzasNFibSOLKDzehA/640) + +CompletableFuture 的作者是大名鼎鼎的 Doug Lea。CompletableFuture 类是实现了 Future 和 CompletionStage 接口的一个关键类。它可以表示异步计算的结果,并提供了一系列方法来操作和处理这些结果。 + +CompletableFuture 内部使用了一个属性`result`来保存计算结果,以及若干个属性`waiters`来保存等待结果的任务。当计算完成后,CompletableFuture将会通知所有等待结果的任务,并将结果传递给它们。 + +为了实现链式操作,CompletableFuture还定义了内部类:`Completion`, `UniCompletion`, 和 `BiCompletion`。 + +`Completion`, `UniCompletion`, 和 `BiCompletion` 是 `CompletableFuture` 内部用于处理异步任务完成的辅助类。 + +- `Completion` 是一个通用的辅助类,它包含了任务完成后的回调方法,以及处理异常的方法。 +- `UniCompletion` 是 `Completion` 的子类,是一元依赖的基类,用于处理单个任务的完成情况,并提供了更多的方法来处理结果和异常。 +- `BiCompletion` 是 `UniCompletion` 的子类,是二元依赖的基类,同时也是多元依赖的基类,用于处理两个任务的完成情况,并提供了更多的方法来组合和处理这两个任务的结果和异常。 + +这些辅助类在 `CompletableFuture` 的内部被使用,以实现异步任务的执行、结果的处理和组合等操作。它们提供了一种灵活的方式来处理异步任务的完成情况,并通过回调方法或其他一些方法来处理任务的结果和异常。 + +### 内部原理 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOmicjMp7XdT2Oaykl41iaArlvhpkDr9kJCKR64WTnEEgLPTzXuKo57wvOXib42AicRlVPYSh1pb4cavw/640) + +CompletableFuture中包含两个字段:**result** 和 **stack**。result 用于存储当前CF的结果,stack (Completion)表示当前CF完成后需要触发的依赖动作(Dependency Actions),去触发依赖它的CF的计算,依赖动作可以有多个(表示有多个依赖它的CF),以栈(Treiber stack)的形式存储,stack表示栈顶元素。 + +CompletableFuture 在设计思想上类似 “观察者模式,每个 CompletableFuture 都可以被看作一个被观察者,其内部有一个Completion类型的链表成员变量stack,用来存储注册到其中的所有观察者。当被观察者执行完成后会弹栈stack属性,依次通知注册到其中的观察者。 + +### 执行流程 + +CompletableFuture 的执行流程如下: + +1. 创建CompletableFuture对象:通过调用`CompletableFuture`类的构造方法或静态工厂方法创建一个新的CompletableFuture对象。 +2. 定义异步任务:使用`supplyAsync()`、`runAsync()`等方法定义需要在后台线程中执行的异步任务,这些方法接受一个 lambda表达式 或 Supplier/Runnable 接口作为参数。 +3. 启动异步任务:一旦CompletableFuture对象创建并定义了异步任务,任务会立即在后台线程中开始执行,并返回一个代表异步计算结果的CompletableFuture对象。 +4. 异步任务执行过程: + - 当异步任务完成时,它会设置自己的结果值,将状态标记为已完成。 + - 如果有其他线程在此之前调用了`complete()`、`completeExceptionally()`、`cancel()`等方法,可能会影响任务的最终状态。 +5. 注册回调方法: + - 使用`thenApply()`, `thenAccept()`, `thenRun()`等方法来注册回调函数,当异步任务完成或异常时,这些回调函数会被触发。 + - 回调函数也可以是异步的,通过`thenApplyAsync()`, `thenAcceptAsync()`, `thenRunAsync()`等方法注册。 +6. 组合多个CompletableFuture: + - 使用`thenCompose()`, `thenCombine()`, `allOf()`, `anyOf()`等方法,可以将多个CompletableFuture对象进行组合,形成更复杂的异步任务处理流程。 +7. 处理异常: + - 通过使用`exceptionally()`, `handle()`, `whenComplete()`等方法,可以注册异常处理函数,当异步任务出现异常时,这些处理函数会被触发。 +8. 等待结果: + - 使用`get()`或`join()`方法来阻塞当前线程,并等待CompletableFuture对象的完成并获取最终的结果。 + - `get()`方法会抛出可能的异常(InterruptedException, ExecutionException)。 + - `join()`方法与`get()`类似,但不会抛出 checked 异常。 +9. 取消任务:通过调用CompletableFuture对象的`cancel()`方法取消异步任务的执行。 + +请注意,以上步骤的顺序和具体实现可能略有不同,但大致上反映了CompletableFuture的执行流程。在实际应用中,我们可以根据需求选择适合的方法来处理异步任务的完成情况、结果、异常以及任务之间的关系。 + +## 方法介绍 + +CompletableFuture类提供了一系列用于处理和组合异步任务的方法。以下是这些方法的介绍: + +### 创建对象 + +创建一个 `CompletableFuture` 对象有以下几种方法: + +- 使用 `CompletableFuture` 的构造方法 + +```java +CompletableFuture future = new CompletableFuture<>(); +``` + +- 使用 `CompletableFuture` 的静态工厂方法 + +```java +CompletableFuture future = CompletableFuture.supplyAsync(() -> { + // 异步任务逻辑 + return "Result"; +}); + +CompletableFuture future = CompletableFuture.runAsync(() -> { + // 异步任务逻辑 +}); +``` + +- 使用转换方法 + +```java + +CompletableFuture transformedFuture = originalFuture.thenApply(result -> { + // 转换逻辑 + return result.length(); +}); + +originalFuture.thenAccept(result -> { + // 处理结果逻辑 + System.out.println("Result: " + result); +}); + +CompletableFuture runnableFuture = originalFuture.thenRun(() -> { + // 在结果完成后执行的操作 +}); +``` + +- 直接创建一个已完成状态的CompletableFuture + +```java +//CompletableFuture.completedFuture()直接创建一个已完成状态的CompletableFuture +CompletableFuture cf2 = CompletableFuture.completedFuture("result"); + +//先初始化一个未完成的CompletableFuture,然后通过complete()、completeExceptionally(),也完成该CompletableFuture +CompletableFuture cf = new CompletableFuture<>(); +cf.complete("success"); +``` + +- toCompletableFuture + +```JAVA +CompletionStage stage = CompletableFuture.supplyAsync(() -> 42); + +CompletableFuture future = stage.toCompletableFuture(); +``` + +用于将当前的 `CompletionStage` 对象转换为一个 `CompletableFuture` 对象。 + +### 异步执行任务 + +以下是在 `CompletableFuture` 对象上异步执行任务的一些方法示例: + +- `supplyAsync(Supplier supplier)`:异步执行一个有返回值的供应商(Supplier)任务。 + +```java +CompletableFuture future = CompletableFuture.supplyAsync(() -> { + // 异步任务逻辑 + return "Result"; +}); +``` + +- `runAsync(Runnable runnable)`:异步执行一个没有返回值的任务。 + +```java +CompletableFuture future = CompletableFuture.runAsync(() -> { + // 异步任务逻辑 +}); +``` + +### 链式操作 + +CompletableFuture提供了不同的方式来对异步任务进行链式操作。 + +- thenRun + +```java +CompletableFuture executedFuture = future.thenRun(() -> executeTask()); +``` + +`thenRun`方法用于在CompletableFuture完成后执行一个Runnable任务。它返回一个新的CompletableFuture对象,该对象没有返回值。 + +- thenAccept + +```java +CompletableFuture acceptedFuture = future.thenAccept(result -> processResult(result)); +``` + +`thenAccept`方法用于在CompletableFuture完成后对结果进行处理。它接收一个Consumer函数作为参数,并返回一个新的CompletableFuture对象。 + +- thenApply + +```java +CompletableFuture appliedFuture = future.thenApply(result -> transformResult(result)); +``` + +`thenApply`方法用于在CompletableFuture完成后对结果进行转换。它接收一个Function函数作为参数,并返回一个新的CompletableFuture对象。 + +- thenCompose + +```java +CompletableFuture composedFuture = future.thenCompose(result -> executeAnotherTask(result)); +``` + +用于对异步任务的结果进行处理,并返回一个新的异步任务。 + +- whenComplete + +```java +CompletableFuture future = CompletableFuture.supplyAsync(() -> 42); + +CompletableFuture whenCompleteFuture = future.whenComplete((result, exception) -> { + if (exception != null) { + System.out.println("Exception occurred: " + exception.getMessage()); + } else { + System.out.println("Result: " + result); + } +}); + +whenCompleteFuture.join(); +``` + +用于在异步任务完成后执行指定的动作。它允许你在任务完成时处理结果或处理异常。 + +- `thenCompose()` 用于对异步任务的结果进行处理,并返回一个新的异步任务。它接受一个函数式接口参数,根据原始任务的结果创建并返回一个新的 `CompletionStage` 对象。 +- `whenComplete()` 用于在异步任务完成后执行指定的动作。它接受一个消费者函数式接口参数,用于处理任务的结果或异常,但没有返回值。 + +### 异步任务组合 + +CompletableFuture还提供了一系列方法来组合和处理多个异步任务的结果。 + +- allOf + +```java +CompletableFuture allFuture = CompletableFuture.allOf(future1, future2, future3); +``` + +`allOf`方法接收一组CompletableFuture对象作为参数,并返回一个新的CompletableFuture对象,该对象在所有给定的CompletableFuture都完成时完成。这样我们可以等待所有任务都完成后再进行下一步操作。 + +- anyOf + +```java +CompletableFuture anyFuture = CompletableFuture.anyOf(future1, future2, future3); +``` + +`anyOf`方法与`allOf`类似,不同之处在于它返回的CompletableFuture对象在任何一个给定的CompletableFuture完成时就完成。这样我们可以获取最先完成的任务的结果。 + +- thenCombine + +```java +CompletableFuture combinedFuture = future1.thenCombine(future2, (result1, result2) -> combineResults(result1, result2)); +``` + +`thenCombine`方法接收两个CompletableFuture对象和一个函数作为参数,用于指定当这两个CompletableFuture都完成时如何处理它们的结果。返回的新的CompletableFuture对象将接收到计算后的结果。 + +- applyToEither + +```java +CompletableFuture resultFuture = future1.applyToEither(future2, result -> processResult(result)); +``` + +`applyToEither`方法用于获取两个CompletableFuture中任意一个完成的结果,并对该结果进行处理。它接收一个Function函数作为参数,并返回一个新的CompletableFuture对象。 + +- acceptEither + +```java +CompletableFuture future1 = CompletableFuture.supplyAsync(() -> 10); +CompletableFuture future2 = CompletableFuture.supplyAsync(() -> 20); + +future1.acceptEither(future2, result -> { + System.out.println("Result: " + result); +}); +``` + +用于在两个 `CompletableFuture` 对象中任意一个完成时执行指定的操作。该方法接收两个参数:另一个 `CompletableFuture` 对象和一个消费者函数(`Consumer`)。当其中任何一个 `CompletableFuture` 完成时,将其结果作为参数传递给消费者函数进行处理。 + +- runAfterBoth + +```java +CompletableFuture future1 = CompletableFuture.supplyAsync(() -> 42); +CompletableFuture future2 = CompletableFuture.supplyAsync(() -> "Hello"); + +CompletableFuture combinedFuture = future1.runAfterBoth(future2, () -> { + System.out.println("Both futures completed"); +}); + +combinedFuture.join(); +``` + +用于在两个异步任务都完成后执行指定的动作,需要注意的是,`runAfterBoth()` 方法是一个非阻塞方法,动作将在两个异步任务都完成后立即执行。 + +- runAfterEither + +```java +CompletableFuture future1 = CompletableFuture.supplyAsync(() -> { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + e.printStackTrace(); + } + return 42; +}); + +CompletableFuture future2 = CompletableFuture.supplyAsync(() -> { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + return "Hello"; +}); + +CompletableFuture eitherFuture = future1.runAfterEither(future2, () -> { + System.out.println("One of the futures completed"); +}); + +eitherFuture.join(); +``` + +用于在两个异步任务中任意一个完成后执行指定的动作。 + +- thenAcceptBoth + +```java +CompletableFuture future1 = CompletableFuture.supplyAsync(() -> 42); +CompletableFuture future2 = CompletableFuture.supplyAsync(() -> "Hello"); + +CompletableFuture thenAcceptBothFuture = future1.thenAcceptBoth(future2, (result1, result2) -> { + System.out.println("Action executed with thenAcceptBoth(): " + result1 + ", " + result2); +}); + +thenAcceptBothFuture.join(); +``` + +用于在两个异步任务都完成后执行指定的动作。它的作用是接收两个异步任务的结果,并将结果作为参数传递给指定的消费者函数。 + +### 异常处理 + +CompletableFuture提供了多种方式来处理异步任务的异常情况。 + +- exceptionally + +```java +CompletableFuture exceptionHandledFuture = future.exceptionally(ex -> handleException(ex)); +``` + +通过`exceptionally`方法,我们可以对CompletableFuture的异常情况进行处理。它接收一个Function函数作为参数,用于处理异常并返回一个新的CompletableFuture对象。 + +- handle + +```java +CompletableFuture handledFuture = future.handle((result, ex) -> handleResult(result, ex)); +``` + +`handle`方法可以同时处理正常结果和异常情况。它接收一个BiFunction函数作为参数,用于处理结果和异常,并返回一个新的CompletableFuture对象。 + +- completeExceptionally + +``` +future.completeExceptionally(); +``` + +异常地完成 `CompletableFuture`,将结果设置为一个异常。 + +- isCompletedExceptionally + +```java +CompletableFuture future = CompletableFuture.supplyAsync(() -> { + throw new RuntimeException("Something went wrong"); +}); + +boolean completedExceptionally = future.isCompletedExceptionally(); +System.out.println("Is completed exceptionally: " + completedExceptionally); +``` + +该方法返回一个布尔值,表示当前异步任务是否已经异常完成。 + +- obtrudeException + +```java +CompletableFuture future = new CompletableFuture<>(); + +future.obtrudeException(new RuntimeException("Something went wrong")); + +boolean completedExceptionally = future.isCompletedExceptionally(); +System.out.println("Is completed exceptionally: " + completedExceptionally); +``` + +用于强制将指定的异常作为异步任务的结果,调用 `obtrudeException(Throwable ex)` 方法后,异步任务将立即完成,并将指定的异常作为结果返回。 + +### 取值与状态 + +- join + +```java +future.join() +``` + +`join()` 方法不会抛出已检查异常,因为它是基于 `CompletableFuture` 类设计的,如果异步任务抛出异常,`join()` 方法会将该异常包装在 `CompletionException` 中并抛出。 + +- get + +```java +future.get() +``` + +`get()` 方法会抛出一个 `InterruptedException` 异常和一个 `ExecutionException` 异常,前者表示获取结果时被中断,后者表示获取结果时任务本身抛出了异常。 + +```java +future.get(1,TimeUnit.Hours) +``` + + 有异常则抛出异常,最长等待一个小时,一个小时之后,如果还没有数据,则异常。 + +- getNow + +```java +CompletableFuture future = CompletableFuture.supplyAsync(() -> { + // 异步任务逻辑 + return 42; +}); + +int result = future.getNow(0); // 获取异步操作的结果,如果尚未完成,则返回默认值0 + +System.out.println("Result: " + result); +``` + +`getNow(T value)` 是 `CompletableFuture` 类的一个方法,用于获取异步操作的结果,如果异步操作尚未完成,则返回给定的默认值,该方法会立即返回结果,不会阻塞当前线程。 + +### 超时控制与取消操作 + +CompletableFuture也支持超时控制和取消操作,以便更好地管理异步任务的执行。 + +- completeOnTimeout + +```java +CompletableFuture timeoutFuture = future.completeOnTimeout(defaultResult, timeout, timeUnit); +``` + +`completeOnTimeout`方法在指定的超时时间内等待CompletableFuture的完成,如果超时则将其设置为默认结果。它返回一个新的CompletableFuture对象。 + +- cancel + +```java +boolean isCancelled = future.cancel(true); +``` + +`cancel`方法可用于取消CompletableFuture的执行。它接收一个boolean参数,指示是否中断正在执行的任务。返回值表示是否成功取消了任务。 + +- isCancelled + +```java +CompletableFuture future = CompletableFuture.supplyAsync(() -> { + // 异步任务逻辑 + return 42; +}); + +future.cancel(true); // 取消异步任务 + +boolean isCancelled = future.isCancelled(); +System.out.println("Is cancelled: " + isCancelled); +``` + +`isCancelled()` 是 `CompletableFuture` 类的一个方法,用于判断当前异步任务是否已被取消。如果异步任务已被取消,则返回 `true`;否则返回 `false`。 + +### 依赖 + +- getNumberOfDependents + +```java +CompletableFuture future1 = CompletableFuture.supplyAsync(() -> 10); +CompletableFuture future2 = CompletableFuture.supplyAsync(() -> 20); + +CompletableFuture combinedFuture = future1.thenCombine(future2, (result1, result2) -> result1 + result2); + +int numberOfDependents = combinedFuture.getNumberOfDependents(); +System.out.println("Number of dependents: " + numberOfDependents); +``` + +`getNumberOfDependents()` 用于获取当前 `CompletableFuture` 对象所依赖的其他异步任务的数量。如果没有任何依赖任务,或者所有依赖任务已经完成,则返回的数量为0。 + +### 完成 + +- complete + +```java +future.complete("米饭"); +``` + +`complete(T value)`:该方法返回布尔值,表示是否成功地将结果设置到 `CompletableFuture` 中。如果 `CompletableFuture` 未完成,则将结果设置,并返回 `true`;如果 `CompletableFuture` 已经完成,则不进行任何操作并返回 `false`。 + +- obtrudeValue + +```java +CompletableFuture future = new CompletableFuture<>(); + +future.obtrudeValue(42); + +boolean completedNormally = future.isDone() && !future.isCompletedExceptionally(); +System.out.println("Is completed normally: " + completedNormally); +``` + +用于强制将指定的值作为异步任务的结果,调用 `obtrudeValue(T value)` 方法后,异步任务将立即完成,并将指定的值作为结果返回。 + +与 `complete()` 不同,`obtrudeValue()` 必须在任务已经完成的情况下调用,否则会引发 `IllegalStateException` 异常。并且`complete()` 方法对于已经完成的任务会忽略额外的完成操作,并返回 `false`。而`obtrudeValue()` 方法即使任务已经完成,仍然会强制使用新的结果值,并返回 `true`。 + +- isDone + +```java +CompletableFuture future = CompletableFuture.completedFuture(42); + +boolean done = future.isDone(); +System.out.println("Is done: " + done); +``` + +用于判断当前异步任务是否已经完成(无论是正常完成还是异常完成)。 + +### 并发限制 + +CompletableFuture也支持并发限制,以控制同时执行的异步任务数量。 + +```java +Executor executor = Executors.newFixedThreadPool(10); +CompletableFuture future = CompletableFuture.supplyAsync(() -> doSomething(), executor); +``` + +我们可以通过使用线程池来限制CompletableFuture的并发执行数量。通过创建一个固定大小的线程池,并将其作为参数传递给CompletableFuture,就可以控制并发执行任务的数量。 + +### 记忆窍门 + +CompletableFuture类提供了许多方法,但实际上常用的方法只有几个。为了方便记忆,以下是一些总结的规律: + +- 方法名带**Async**的都是异步方法,对应的没有Async则是同步方法,比如 `thenAccept` 与 `thenAcceptAsync` 。 +- 方法名带**run**的入参为Runnable,且无返回值。 +- 方法名带**supply**的入参为Supplier,且有返回值。 +- 方法名带**Accept**的入参为Consumer,且无返回值。 +- 方法名带**Apply**的入参为Function,且有返回值。 +- 方法名带**Either**的方法表示谁先完成就消费谁。 +- 方法名带**Both**的方法表示两个任务都完成才消费。 + +掌握以上规律后,就可以基本记住大部分方法,剩下的其他方法可以单独记忆。 + +## 总结 + +本文详细探讨了 CompletableFuture 的原理和方法,学习了如何在任务完成后执行操作、处理结果和转换结果。 + +CompletableFuture是Java中强大的异步编程工具之一,合理利用它的方法和策略可以更好地处理异步任务和操作。 + +希望本文对读者有所启发和帮助。 \ No newline at end of file diff --git "a/docs/md/java/\351\235\242\350\257\225\345\256\230\357\274\232\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\345\222\214\350\231\232\346\213\237\347\272\277\347\250\213\346\200\216\344\271\210\351\200\211\357\274\237\347\234\213\345\256\214\350\277\231\347\257\207\344\270\215\345\206\215\350\242\253\351\227\256\345\200\222.md" "b/docs/md/java/\351\235\242\350\257\225\345\256\230\357\274\232\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\345\222\214\350\231\232\346\213\237\347\272\277\347\250\213\346\200\216\344\271\210\351\200\211\357\274\237\347\234\213\345\256\214\350\277\231\347\257\207\344\270\215\345\206\215\350\242\253\351\227\256\345\200\222.md" new file mode 100644 index 0000000..b9fdea0 --- /dev/null +++ "b/docs/md/java/\351\235\242\350\257\225\345\256\230\357\274\232\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\345\222\214\350\231\232\346\213\237\347\272\277\347\250\213\346\200\216\344\271\210\351\200\211\357\274\237\347\234\213\345\256\214\350\277\231\347\257\207\344\270\215\345\206\215\350\242\253\351\227\256\345\200\222.md" @@ -0,0 +1,291 @@ +Java的高并发问题由来已久。传统线程模型下,每个Java线程映射一个操作系统内核线程,而操作系统线程是昂贵资源——默认每个线程消耗约1MB栈内存,调度还要在内核态与用户态之间来回切换。这让Java在处理高并发IO密集型应用时,总被Go、Lua等支持协程的语言压一头。为突破这个瓶颈,Java生态先后涌现出响应式编程与虚拟线程两种方案。前者要求改变编程范式,后者在底层机制上动刀,保留传统编码习惯。这两条路线的竞争,关系到Java平台的演进方向。 + +## 传统线程模型的瓶颈 + +先看传统thread-per-request模型有什么问题。以Tomcat为例,其维护的线程池默认最大线程数为200,单进程同时处理的最大并发请求数被这个数字死死卡住。当请求涉及数据库查询、缓存访问、下游服务调用等IO操作时,处理线程会在IO等待期间被阻塞,看起来线程很多,真正干活的可能没几个。 + +提升并发能力的传统方法是增加线程池大小,但会遇到三重限制: + +- **系统资源限制**:操作系统支持的内核线程数量有限,Java平台线程与内核线程1:1映射,扩展不了。实测4000个平台线程,总线程栈空间占用约8096MB。 +- **调度开销累积**:平台线程调度由内核调度器完成,线程多了,上下文切换就频繁,CPU资源消耗在调度上而不是业务处理上。 +- **IO阻塞的低效性**:线程在IO等待期间完全闲置,干不了别的事。典型企业应用里,线程大部分时间都在等——数据库查询、HTTP调用、文件读写,真正CPU干活的时间很短,大把时间耗在等待上。 + +响应式编程就是在这种背景下出来的,想通过编程范式的变革绕过硬件限制。 + +## 响应式编程:代价沉重的性能提升 + +响应式编程的核心思想是"缓冲区+回调",通过非阻塞IO让少量线程一直忙。技术实现依赖三块: + +- **非阻塞IO基础设施**:JDK 7引入的NIO为非阻塞操作打开了门,Socket读写、文件操作、锁API都有非阻塞版本。Spring WebFlux基于Project Reactor构建,用`Mono`和`Flux`类型实现发布-订阅模式,解耦数据生产者与消费者。 +- **事件循环模型**:单个线程通过事件循环处理多个请求,IO操作期间不阻塞线程,而是注册回调函数,数据就绪后由事件循环触发处理。 +- **背压机制**:通过流量控制防止生产者压垮消费者,这是响应式流规范的核心特性。 + +### 响应式代码的复杂性 + +响应式编程的性能优势明显,但代价也不小。看一个电商购物车价格计算的例子,传统代码: + +```java +public void addProductToCart(String productId, String cartId) { + Product product = repository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException("not found!")); + + Price price = product.basePrice(); + if (product.category().isEligibleForDiscount()) { + BigDecimal discount = discountService.discountForProduct(productId); + price.setValue(price.getValue().subtract(discount)); + } + + var event = new ProductAddedToCartEvent(productId, price.getValue(), price.getCurrency(), cartId); + kafkaTemplate.send(PRODUCT_ADDED_TO_CART_TOPIC, cartId, event); +} +``` + +改造成响应式风格: + +```java +void addProductToCart(String productId, String cartId) { + repository.findById(productId) + .switchIfEmpty(Mono.error(() -> new IllegalArgumentException("not found!"))) + .flatMap(this::computePrice) + .map(price -> new ProductAddedToCartEvent(productId, price.value(), price.currency(), cartId)) + .subscribe(event -> kafkaTemplate.send(PRODUCT_ADDED_TO_CART_TOPIC, cartId, event)); +} + +Mono computePrice(Product product) { + if (product.category().isEligibleForDiscount()) { + return discountService.discountForProduct(product.id()) + .map(product.basePrice()::applyDiscount); + } + return Mono.just(product.basePrice()); +} +``` + +代码量增加不是最要命的。响应式编程真正的痛点在于: + +- **可读性崩溃**:回调嵌套形成"回调地狱",链式操作符(`flatMap`、`map`、`zip`)把业务逻辑碎片化,代码审查时很难快速理解执行流程。操作全封装成回调函数,回调里面再嵌回调,看着头疼。 +- **调试黑洞**:在回调函数里打断点,调用栈追溯不到业务入口。传统阻塞式编程通过栈帧能逐层定位调用方,响应式代码的调用链路被异步边界切断,异常堆栈常常变成一堆废话,给不出有效的定位信息。 +- **思维模式冲突**:大多数程序员习惯阻塞式思维,响应式编程要求从流处理、背压控制、异步编排的角度思考,认知成本高。 +- **生态兼容性割裂**:WebFlux要求全链路非阻塞,传统阻塞式API(JPA、JDBC、RestTemplate)没法直接用,得换成R2DBC、WebClient等响应式组件。遗留项目迁移成本巨大,而且响应式生态并不完备,有些场景得自己造轮子。 + +### 响应式编程的性能边界 + +响应式编程不是万能药,性能优势主要在IO密集型场景。对于计算密集型任务,响应式编程往往适得其反——线程在CPU密集计算期间释放不了,反而搭进去响应式框架的额外开销。 + +压测数据显示,WebFlux在IO密集型场景下,用25个线程就能达到964 req/sec的吞吐量,远超传统线程池的388 req/sec(200线程)或975 req/sec(500线程)。但这要付出代码复杂度和维护成本的巨大代价。 + +## 虚拟线程的技术实现 + +Java 21引入的虚拟线程(Virtual Thread),不改变编程范式,却实现了响应式编程的性能目标。核心技术原理: + +**virtual thread = continuation + scheduler + runnable** + +### 虚拟线程的工作机制 + +虚拟线程不与特定操作系统线程绑定,而是在平台线程(载体线程)上运行Java代码,但在代码整个生命周期内不独占平台线程。多个虚拟线程可以在同一个平台线程上运行,共享平台线程资源。 + +**Continuation组件**是虚拟线程的核心,它既包装用户的真实任务,又提供虚拟线程任务暂停/继续的能力,还负责虚拟线程与平台线程之间的数据转移: + +- 任务需要阻塞挂起时(如IO操作、锁等待、sleep),调用Continuation的yield操作,虚拟线程从平台线程卸载(unmount)。 +- 任务解除阻塞继续执行时,调用Continuation的run方法,虚拟线程重新挂载(mount)到载体线程。 + +具体实现细节: + +- **Mount操作**:虚拟线程挂载到平台线程,Continuation堆栈帧数据从堆内存拷贝到平台线程栈,是从堆到栈的复制过程。 +- **Unmount操作**:虚拟线程从平台线程卸载,Continuation栈数据帧留在堆内存中,载体线程被释放到调度器等待新任务。 +- **调度器设计**:JVM用FIFO模式的ForkJoinPool作为虚拟线程调度器,当平台线程对应的虚拟线程任务列表全部阻塞时,支持工作窃取(work-stealing),平台线程可以去窃取其他平台线程的虚拟线程执行。 + +### 虚拟线程的内存优势 + +虚拟线程的低成本让它可以大规模创建: + +**平台线程资源占用**: + +- 预留1MB线程栈空间, +- 平台线程实例占据2000+字节。 + +**虚拟线程资源占用**: + +- Continuation栈占用数百字节到数百KB,作为堆栈块对象存储在Java堆中。 +- 虚拟线程实例占据200-240字节。 + +实测数据:4000个平台线程总内存占用超过8000MB,而4000个虚拟线程内存占用不到300MB。而且虚拟线程的堆栈在堆中存储,可以被GC回收,进一步降低内存压力。 + +### 虚拟线程的自动卸载机制 + +虚拟线程的核心价值在于遇到阻塞操作时自动卸载,释放载体线程。JVM对核心类库做了改造,当代码遇到IO操作时,自动切换到非阻塞版本: + +```java +Thread.startVirtualThread(() -> { + // 阻塞调用,但不会阻塞载体线程 + Product product = repository.findById(productId); + BigDecimal discount = discountService.discountForProduct(productId); + // ...业务逻辑 +}); +``` + +虚拟线程执行到`repository.findById()`时,JVM检测到IO操作,触发Continuation.yield(),虚拟线程从载体线程卸载,载体线程转而去执行其他虚拟线程。等数据库返回数据后,虚拟线程重新挂载到载体线程(可能是另一个载体线程)继续执行。 + +这种机制让开发者用传统的阻塞式编程思维,就能享受到响应式编程的性能优势。 + +## 虚拟线程的局限 + +虚拟线程不是银弹,有它的局限: + +### Pinned Thread问题 + +虚拟线程执行以下操作时,无法进行yield操作,,载体线程会被阻塞: + +- **Native方法调用**:JNI调用或Foreign Function & Memory API无法卸载虚拟线程。 +- **synchronized代码块**:在synchronized修饰的方法或代码块中,虚拟线程会pin住载体线程。官方建议用ReentrantLock替代: + +```java +// 错误:会导致载体线程阻塞 +synchronized(lock) { + // IO操作 +} + +// 正确:虚拟线程可正常卸载 +ReentrantLock lock = new ReentrantLock(); +lock.lock(); +try { + // IO操作 +} finally { + lock.unlock(); +} +``` + +### ThreadLocal陷阱 + +虚拟线程支持ThreadLocal,但因为虚拟线程数量可能达到数百万,ThreadLocal中存储的线程变量会急剧增加,导致频繁GC影响性能。官方建议: + +- 尽量少用ThreadLocal。 +- 不要在虚拟线程的ThreadLocal中放大对象。 +- 使用ScopedLocal替代ThreadLocal。 + +### 池化思维的误区 + +虚拟线程占用资源极少,不需要池化。平台线程因为创建成本高需要池化共享,但虚拟线程应该"用时创建,用完即弃": + +```java +// 错误:虚拟线程不需要池化 +ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); +for(Task task : tasks) { + pool.submit(task); +} + +// 正确:直接创建虚拟线程 +for(Task task : tasks) { + Thread.startVirtualThread(task); +} +``` + +### 适用场景限定 + +虚拟线程只适用于IO密集型应用,计算密集型场景发挥不了优势。对于CPU密集计算,虚拟线程在执行期间无法卸载,反而引入调度开销。 + +## 技术选型决策 + +基于上述分析,虚拟线程与响应式编程的选型可以遵循以下原则: + +### 优先选择虚拟线程的场景 + +- **传统Web应用或REST API**:基于Spring MVC的应用,只需启用虚拟线程配置(`spring.threads.virtual.enabled=true`),就能获得显著的性能提升。 +- **遗留项目迁移**:虚拟线程与现有阻塞式API(JPA、JDBC、RestTemplate)完全兼容,迁移成本低。 +- **团队技术栈约束**:团队没有响应式编程经验,或者希望保持代码可读性和调试便利性。 +- **中高并发IO密集型场景**:包含大量数据库查询、HTTP调用、文件操作的应用。 + +### 选择响应式编程的场景 + +- **流数据处理**:实时数据流、事件流处理,WebFlux的背压机制可以防止生产者压垮消费者。 +- **长连接应用**:WebSocket、Server-Sent Events等需要维持大量长连接的场景,WebFlux的事件循环模型更高效。 +- **端到端非阻塞架构**:系统架构要求全链路非阻塞,从网关到服务到数据库都用响应式技术栈。 +- **全新项目且团队具备响应式经验**:启动全新项目,团队熟悉响应式编程,可以构建完全非阻塞的技术栈。 + +### 不应选择响应式编程的场景 + +- **计算密集型应用**:响应式编程无法提升CPU密集型任务性能,反而引入框架开销。 +- **遗留系统改造**:把现有Spring MVC应用改成WebFlux要重写大部分代码,风险不可控。 +- **团队响应式经验不足**:学习曲线陡,容易引入难以排查的并发问题,维护成本高。 + +## Spring Boot 3.2+的虚拟线程实践 + +Spring Boot 3.2提供了虚拟线程的原生支持,集成很简单: + +### 启用虚拟线程 + +```properties +# application.properties +spring.threads.virtual.enabled=true +``` + +这个配置会自动: +- Tomcat请求处理线程使用虚拟线程。 +- 异步任务执行器使用虚拟线程。 +- ScheduledExecutor使用虚拟线程。 + +### 手动创建虚拟线程 + +```java +// 方式1:Thread API +Thread vt = Thread.startVirtualThread(() -> { + // 业务逻辑 +}); + +// 方式2:ExecutorService +try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + executor.submit(() -> { + // 业务逻辑 + return result; + }); +} + +// 方式3:StructuredTaskScope(Java 21预览特性) +try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { + Future user = scope.fork(() -> findUser()); + Future order = scope.fork(() -> fetchOrder()); + + scope.join(); + scope.throwIfFailed(); + + return new Response(user.resultNow(), order.resultNow()); +} +``` + +### 与传统代码的兼容性 + +虚拟线程最大的优势是与现有阻塞式代码完全兼容: + +```java +@RestController +public class UserController { + @Autowired + private UserService userService; // 传统阻塞式Service + + @GetMapping("/users/{id}") + public User getUser(@PathVariable Long id) { + // 在虚拟线程上执行,阻塞不会阻塞载体线程 + return userService.findUserById(id); + } +} +``` + +不需要修改Service层代码,不用引入响应式类型,不用学新API,性能提升直接见效。 + +## 虚拟线程与响应式编程的本质 + +从技术本质看,虚拟线程与响应式编程追求的是同一目标:让少量平台线程一直忙,别在IO等待期间闲着。差异在实现层次: + +- **响应式编程**:在应用层通过编程范式变革实现,要求开发者显式构建异步管道,使用非阻塞API,思维模式要完全转换。 +- **虚拟线程**:在JVM层通过运行时机制实现,开发者不用改变编程习惯,JVM自动处理阻塞与恢复,底层实现continuation机制。 + +这就是虚拟线程能替代响应式编程的原因——用更低的学习成本、更少的代码改动、更好的可维护性,实现了相同的性能目标。响应式编程是个"中间产物",存在的价值是填补Java平台缺失轻量级线程的空白。当JVM原生支持虚拟线程后,响应式编程的复杂度成本就变得不可接受了。 + +当然,响应式编程不会马上消失。WebFlux在流处理、长连接等特定场景还有优势,而且大量现有系统已经采用响应式架构。但对于新项目,尤其是传统Web应用和微服务,虚拟线程是更务实的选择。Tomcat 11.0、Jetty 12.0都已经支持虚拟线程,主流框架的集成让虚拟线程的使用门槛降到很低。 + +## Java并发编程的未来 + +虚拟线程的引入,改变了Java并发编程的格局。它不是响应式编程的简单替代,而是Java平台对轻量级并发的原生支持。 + +响应式编程没有完全失去价值。在流处理、事件驱动架构、全链路非阻塞系统等领域,WebFlux还有其独特优势。但对于绝大多数企业应用,虚拟线程提供了性能与开发效率的最佳平衡点。 + +技术演进的逻辑是降低复杂度。响应式编程以增加复杂度换取性能,虚拟线程通过底层机制革新,在不增加应用层复杂度的前提下实现性能提升。两个方案性能相当,选择成本更低的那个是自然的技术演进方向。 \ No newline at end of file diff --git a/docs/md/mysql/MMR(Multi-Range Read Optimization).md b/docs/md/mysql/MMR(Multi-Range Read Optimization).md deleted file mode 100644 index 2068323..0000000 --- a/docs/md/mysql/MMR(Multi-Range Read Optimization).md +++ /dev/null @@ -1,74 +0,0 @@ -[TOC] - -MRR 的全称是 Multi-Range Read Optimization,是优化器将随机 IO 转化为顺序 IO 以降低查询过程中 IO 开销的一种手段。 - -## 什么是MRR? - -先来了解下回表,回表是指,InnoDB在普通索引a上查到主键id的值后,再根据一个个主键id的值到主键索引上去查整行数据的过程。 -我们知道二级索引是有回表的过程的,由于二级索引上引用的主键值不一定是有序的,因此就有可能造成大量的随机 IO,如果回表前把主键值给它排一下序,那么在回表的时候就可以用顺序 IO 取代原本的随机 IO。 - -**简单说:MRR 通过把「随机磁盘读」,转化为「顺序磁盘读」,从而提高了索引查询的性能**。 - -顺序读带来了几个好处: - -1. 磁盘和磁头不再需要来回做机械运动; - -2. 可以充分利用磁盘预读; - -比如在客户端请求一页的数据时,可以把后面几页的数据也一起返回,放到数据缓冲池中,这样如果下次刚好需要下一页的数据,就不再需要到磁盘读取。这样做的理论依据是计算机科学中著名的局部性原理: - -> 当一个数据被用到时,其附近的数据也通常会马上被使用。 - -**MRR 在本质上是一种用空间换时间的算法**。MySQL 不可能给你无限的内存来进行排序,这块内存的大小就由参数 **read_rnd_buffer_size** 来控制,如果 read_rnd_buffer 满了,就会先把满了的 rowid 排好序去磁盘读取,接着清空,然后再往里面继续放 rowid,直到 read_rnd_buffer 又达到 read_rnd_buffe 配置的上限,如此循环。 - -假设,我执行这个语句: - -```sql -select * from t1 where a>=1 and a<=100; -``` - -主键索引是一棵B+树,在这棵树上,每次只能根据一个主键id查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图1所示。 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIl4jmfxUkEB7Heh3o71Lk52uSztvD1FzkZYvemgibv7ltxGcBhrJFQLyg/640?wx_fmt=png) - -如果随着a的值递增顺序查询的话,id的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。 - -**因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能**。 - -这,就是MRR优化的设计思路。此时,语句的执行流程变成了这样: - -1. 根据索引a,定位到满足条件的记录,将id值放入read_rnd_buffer中; - -2. 将read_rnd_buffer中的id进行递增排序; - -3. 排序后的id数组,依次到主键id索引中查记录,并作为结果返回。 - -这里,read_rnd_buffer的大小是由read_rnd_buffer_size参数控制的。如果步骤1中,read_rnd_buffer放满了,就会先执行完步骤2和3,然后清空read_rnd_buffer。之后继续找索引a的下个记录,并继续循环。 - -下面两幅图就是使用了MRR优化后的执行流程和explain结果。 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlnohcwLLXB2ACwzcdjs8NCEjSpD34Iwia8IJ0beVJeYb6RXjZxvVbxYg/640?wx_fmt=png) - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlAlvKCjvgVba7iand56gCticKcJp8BNp0IZOocIHvSyYZXp2JnfGmeKicQ/640?wx_fmt=png) - -从explain结果中,我们可以看到Extra字段多了**Using MRR**,表示的是用上了MRR优化。而且,由于我们在read_rnd_buffer中按照id做了排序,所以最后得到的结果集也是按照主键id递增顺序的,也就是与图1结果集中行的顺序相反。 - -**MRR能够提升性能的核心在于,这条查询语句在索引a上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势**。 - -## MRR如何使用? - -``` -//如果你不打开,是一定不会用到 MRR 的。 -set optimizer_switch='mrr=on'; -set optimizer_switch ='mrr_cost_based=off'; -set read_rnd_buffer_size = 32 * 1024 * 1024; -``` - -mrr_cost_based: on/off,则是用来告诉优化器,要不要基于使用 MRR 的成本,考虑使用 MRR 是否值得(cost-based choice),来决定具体的 sql 语句里要不要使用 MRR。 - -很明显,对于只返回一行数据的查询,是没有必要 MRR 的,而如果你把 mrr_cost_based 设为 off,那优化器就会通通使用 MRR,这在有些情况下是很 stupid 的,所以建议这个配置还是设为 on,毕竟优化器在绝大多数情况下都是正确的。 - ------- - -本篇文章就到这里,感谢阅读,如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。 -![](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) \ No newline at end of file diff --git "a/docs/md/mysql/MySQL\344\270\255\347\232\204Join \347\232\204\347\256\227\346\263\225\357\274\210NLJ\343\200\201BNL\343\200\201BKA\357\274\211.md" "b/docs/md/mysql/MySQL\344\270\255\347\232\204Join \347\232\204\347\256\227\346\263\225\357\274\210NLJ\343\200\201BNL\343\200\201BKA\357\274\211.md" deleted file mode 100644 index 1e14834..0000000 --- "a/docs/md/mysql/MySQL\344\270\255\347\232\204Join \347\232\204\347\256\227\346\263\225\357\274\210NLJ\343\200\201BNL\343\200\201BKA\357\274\211.md" +++ /dev/null @@ -1,133 +0,0 @@ -[TOC] - -## 摘要 - -Join是MySQL中最常见的查询操作之一,用于从多个表中获取数据并将它们组合在一起。Join算法通常使用两种基本方法:Index Nested-Loop Join(NLJ)和Block Nested-Loop Join(BNL)。本文将探讨这两种算法的工作原理,以及如何在MySQL中使用它们。 - -## 什么是Join - -在MySQL中,Join是一种用于组合两个或多个表中数据的查询操作。Join操作通常基于两个表中的某些共同的列进行,这些列在两个表中都存在。MySQL支持多种类型的Join操作,如Inner Join、Left Join、Right Join、Full Join等。 - -Inner Join是最常见的Join类型之一。在Inner Join操作中,只有在两个表中都存在的行才会被返回。例如,如果我们有一个“customers”表和一个“orders”表,我们可以通过在这两个表中共享“customer_id”列来组合它们的数据。 - -```sql -SELECT * -FROM customers -INNER JOIN orders -ON customers.customer_id = orders.customer_id; -``` - -上面的查询将返回所有存在于“customers”和“orders”表中的“customer_id”列相同的行。 - -## Index Nested-Loop Join - -Index Nested-Loop Join(NLJ)算法是Join算法中最基本的算法之一。在NLJ算法中,MySQL首先选择一个表(通常是小型表)作为驱动表,并迭代该表中的每一行。然后,MySQL在第二个表中搜索匹配条件的行,这个搜索过程通常使用索引来完成。一旦找到匹配的行,MySQL将这些行组合在一起,并将它们作为结果集返回。 - -工作流程如图: - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIl94cTiasZKjpEmlWicZ0zBBeNq1eNPiahuOxSGQXX6aYSQJSDn6I3ymnSQ/640?wx_fmt=png) - -例如,下面这个语句: - -```sql -select * from t1 straight_join t2 on (t1.a=t2.a); -``` - -在这个语句里,假设t1 是驱动表,t2是被驱动表。我们来看一下这条语句的explain结果。 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlEhryCJl5jj5PV23LPSQkQaoW3IRbgfKO3v0WpUUr1g1SvzWpNRP6iaw/640?wx_fmt=png) - -可以看到,在这条语句里,被驱动表t2的字段a上有索引,join过程用上了这个索引,因此这个语句的执行流程是这样的: - -1. 从表t1中读入一行数据 R; -2. 从数据行R中,取出a字段到表t2里去查找; -3. 取出表t2中满足条件的行,跟R组成一行,作为结果集的一部分; -4. 重复执行步骤1到3,直到表t1的末尾循环结束。 - -这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为**“Index Nested-Loop Join”,简称NLJ**。 - -NLJ是使用上了索引的情况,如果查询条件没有使用到索引呢? - -MySQL会选择使用另一个叫作**“Block Nested-Loop Join”的算法,简称BNL**。 - -## Block Nested-Loop Join - -Block Nested Loop Join(BNL)算法与NLJ算法不同的是,BNL算法使用一个类似于缓存的机制,将表数据分成多个块,然后逐个处理这些块,以减少内存和CPU的消耗。 - -例如,下面这个语句: - -```sql -select * from t1 straight_join t2 on (t1.a=t2.b); -``` - -字段b上是没有建立索引的。 - -这时候,被驱动表上没有可用的索引,算法的流程是这样的: - -1. 把表t1的数据读入线程内存join_buffer中,由于我们这个语句中写的是select *,因此是把整个表t1放入了内存; -2. 扫描表t2,把表t2中的每一行取出来,跟join_buffer中的数据做对比,满足join条件的,作为结果集的一部分返回。 - -这条SQL语句的explain结果如下所示: - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlibay271rhTD6FiaNEEGAAEQVC5jTic0fIVMh2vznm2ibjZlRGLAKZNMajw/640?wx_fmt=png) - -可以看到,在这个过程中,对表t1和t2都做了一次全表扫描,因此总的扫描行数是1100。由于join_buffer是以无序数组的方式组织的,因此对表t2中的每一行,都要做100次判断,总共需要在内存中做的判断次数是:100*1000=10万次。 - -虽然Block Nested-Loop Join算法是全表扫描。但是是在内存中进行的判断操作,速度上会快很多。但是性能仍然不如NLJ。 - -join_buffer的大小是由参数join_buffer_size设定的,默认值是256k。**如果放不下表t1的所有数据话,策略很简单,就是分段放。** - -1. 顺序读取数据行放入join_buffer中,直到join_buffer满了。 -2. 扫描被驱动表跟join_buffer中的数据做对比,满足join条件的,作为结果集的一部分返回。 -3. 清空join_buffer,重复上述步骤。 - -虽然分成多次放入join_buffer,但是判断等值条件的次数还是不变的,依然是10万次。 - -## MRR & BKA - -上篇文章里我们讲到了MRR(Multi-Range Read)。MySQL在5.6版本后引入了Batched Key Acess(BKA)算法了。这个BKA算法,其实就是对NLJ算法的优化,BKA算法正是基于MRR。 - -NLJ算法执行的逻辑是:从驱动表t1,一行行地取出a的值,再到被驱动表t2去做join。也就是说,对于表t2来说,每次都是匹配一个值。这时,MRR的优势就用不上了。 - -我们可以从表t1里一次性地多拿些行出来,,先放到一个临时内存,一起传给表t2。这个临时内存不是别人,就是join_buffer。 - -通过上一篇文章,我们知道join_buffer 在BNL算法里的作用,是暂存驱动表的数据。但是在NLJ算法里并没有用。那么,我们刚好就可以复用join_buffer到BKA算法中。 - -NLJ算法优化后的BKA算法的流程,如图所示: - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlW4MXORyOusGOGvbCkwgts385bNicgS9IZWOJnPic9SeGCxPF1lfqnw7A/640?wx_fmt=png) - -图中,我在join_buffer中放入的数据是P1~P100,表示的是只会取查询需要的字段。当然,如果join buffer放不下P1~P100的所有数据,就会把这100行数据分成多段执行上图的流程。 - -如果要使用BKA优化算法的话,你需要在执行SQL语句之前,先设置 - -```sql -set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on'; -``` - -其中,前两个参数的作用是要启用MRR。这么做的原因是,BKA算法的优化要依赖于MRR。 - -对于BNL,我们可以通过建立索引转为BKA。对于一些列建立索引代价太大,不好建立索引的情况,我们可以使用临时表去优化。 - -例如,对于这个语句: - -```sql -select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000; -``` - -使用临时表的大致思路是: - -1. 把表t2中满足条件的数据放在临时表tmp_t中; -2. 为了让join使用BKA算法,给临时表tmp_t的字段b加上索引; -3. 让表t1和tmp_t做join操作。 - -这样可以大大减少扫描的行数,提升性能。 - -## 总结 - -在MySQL中,不管Join使用的是NLJ还是BNL总是应该使用小表做驱动表。更准确地说,**在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。**应当尽量避免使用BNL算法,如果确认优化器会使用BNL算法,就需要做优化。优化的常见做法是,给被驱动表的join字段加上索引,把BNL算法转成BKA算法。对于不好在索引的情况,可以基于临时表的改进方案,提前过滤出小数据添加索引。 - ------- - -本篇文章就到这里,感谢阅读,如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。 -![](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) \ No newline at end of file diff --git "a/docs/md/mysql/MySQL\345\210\206\345\214\272\350\241\250\350\257\246\350\247\243.md" "b/docs/md/mysql/MySQL\345\210\206\345\214\272\350\241\250\350\257\246\350\247\243.md" new file mode 100644 index 0000000..4942cfc --- /dev/null +++ "b/docs/md/mysql/MySQL\345\210\206\345\214\272\350\241\250\350\257\246\350\247\243.md" @@ -0,0 +1,396 @@ +在我们日常处理海量数据的过程中,如何有效管理和优化数据库一直是一个既重要又具有挑战性的问题。 + +分区表技术就为此提供了一种解决方案,尤其是在使用MySQL这类关系型数据库时。该技术将大型表的数据切割成更易于管理和查询的小块,从而提高了整体数据库操作的性能。 + +本文将详细探讨MySQL分区表的概念、实现方式以及具体应用场景,帮助读者更好地理解并运用这一高效的数据库优化策略。 + +## 分区表介绍 + +MySQL 数据库中的数据是以文件的形势存在磁盘上的,默认放在 `/var/lib/mysql/` 目录下面,我们可以通过 `show variables like '%datadir%'` 命令来进行查看: + +![](https://static001.geekbang.org/infoq/6f/6f0be709cf719968bedde7d4b58849c4.png) + +我们进入到这个目录下,就可以看到我们定义的所有数据库了,一个数据库就是一个文件夹,一个库中,有其对应的表的信息,如下: + +![](https://static001.geekbang.org/infoq/5d/5d14fb10798d3dbf045cbb9d9b71c5ee.png) + +在 MySQL 中,如果存储引擎是 MyISAM,那么在 data 目录下会看到 3 类文件:`.frm`、`.myi`、`.myd`,文件含义如下: + +- `*.frm`:这个是表定义,是描述表结构的文件。 + +- `*.myd`:这个是数据信息文件,是表的数据文件。 + +- `*.myi`:这个是索引信息文件。 + + + +如果存储引擎是 `InnoDB`, 那么在 data 目录下会看到两类文件:`.frm`、`.ibd`,文件含义如下: + +- `*.frm`:表结构文件。 +- `*.ibd`:表数据和索引的文件。 + +无论是哪种存储引擎,只要一张表的数据量过大,就会导致 `*.myd`、`*.myi` 以及 `*.ibd` 文件过大,从而数据的查找就会变的很慢。 + +**为了解决这个问题,我们可以利用 MySQL 的分区功能,在物理上将这一张表对应的文件,分割成许多小块,如此,当我们查找一条数据时,就不用在某一个文件中进行整个遍历了,我们只需要知道这条数据位于哪一个数据块,然后在那一个数据块上查找就行了。** + +另一方面,如果一张表的数据量太大,可能一个磁盘放不下,这个时候,通过表分区我们就可以把数据分配到不同的磁盘里面去。 + +**通俗地讲表分区就是将一大表,根据条件分割成若干个小表。** + +如:某用户表的记录超过了 600 万条,那么就可以根据入库日期将表分区,也可以根据所在地将表分区。当然也可根据其他的条件分区。 + + + +**MySQL 从 5.1 版本开始添加了对分区的支持,分区的过程是将一个表或索引分解为多个更小、更可管理的部分。** + +对于开发者而言,分区后的表使用方式和不分区基本上还是一模一样,只不过在物理存储上,原本该表只有一个数据文件,现在变成了多个,每个分区都是独立的对象,可以独自处理,也可以作为一个更大对象的一部分进行处理。 + + + +需要注意的是,分区功能并不是在存储引擎层完成的,常见的存储引擎如 `InnoDB`、`MyISAM`、`NDB` 等都支持分区。 + +但并不是所有的存储引擎都支持,如 `CSV`**、**`FEDORATED`**、**`MERGE` **等就不支持分区,因此在使用此分区功能前,应该对选择的存储引擎对分区的支持有所了解。 + +### 表分区的优缺点和限制 + +MySQL 分区有优点也有一些缺点,罗列如下: + +优点: + +- **查询性能提升**:分区可以将大表划分为更小的部分,查询时只需扫描特定的分区,而不是整个表,从而提高查询性能。特别是在处理大量数据或高并发负载时,分区可以显著减少查询的响应时间。 +- **管理和维护的简化**:使用分区可以更轻松地管理和维护数据。可以针对特定的分区执行维护操作,如备份、恢复、优化和数据清理,而不必处理整个表。这简化了维护任务并减少了操作的复杂性。 +- **数据管理灵活性**:通过分区,可以根据业务需求轻松地添加或删除分区,而无需影响整个表。这使得数据的增长和变化更具弹性,可以根据需求进行动态调整。 +- **改善数据安全性和可用性**:可以将不同分区的数据分布在不同的存储设备上,从而提高数据的安全性和可用性。例如,可以将热数据放在高速存储设备上,而将冷数据放在廉价存储设备上,以实现更高的性能和成本效益。 + + + +缺点: + +- **复杂性增加**:分区引入了额外的复杂性,包括分区策略的选择、表结构的设计和维护、查询逻辑的调整等。正确地设置和管理分区需要一定的经验和专业知识。 +- **索引效率下降**:对于某些查询,特别是涉及跨分区的查询,可能会导致索引效率下降。由于查询需要在多个分区之间进行扫描,可能无法充分利用索引优势,从而影响查询性能。 +- **存储空间需求增加**:使用分区会导致一定程度的存储空间浪费。每个分区都需要占用一定的存储空间,包括分区元数据和一些额外的开销。因此,对于分区键的选择和分区粒度的设置需要权衡存储空间和性能之间的关系。 +- **功能限制**:在某些情况下,分区可能会限制某些 MySQL 的功能和特性的使用。例如,某些类型的索引可能无法在分区表上使用,或者某些 DDL 操作可能需要更复杂的处理。 + + + +在考虑使用分区时,需要综合考虑业务需求、查询模式、数据规模和硬件资源等因素,并权衡分区带来的优势和缺点。对于特定的应用和数据场景,分区可能是一个有效的解决方案,但并不适用于所有情况。 + +同时分区表也存在一些限制,如下: + + + +限制: + +- 在 MySQL 5.6.7 之前的版本,一个表最多有 1024 个分区,从 5.6.7 开始,一个表最多可以有 8192 个分区。 +- 分区表无法使用外键约束。 +- NULL 值会使分区过滤无效。 +- 所有分区必须使用相同的存储引擎。 + +## 分区适用场景 + +分区表在以下情况可以发挥其优势,适用于以下几种使用场景: + +- **大型表处理**:当面对非常大的表时,分区表可以提高查询性能。通过将表分割为更小的分区,查询操作只需要处理特定的分区,从而减少扫描的数据量,提高查询效率。这在处理日志数据、历史数据或其他需要大量存储和高性能查询的场景中非常有用。 +- **时间范围查询**:对于按时间排序的数据,分区表可以按照时间范围进行分区,每个分区包含特定时间段内的数据。这使得按时间范围进行查询变得更高效,例如在某个时间段内检索数据、生成报表或执行时间段的聚合操作。 +- **数据归档和数据保留**:分区表可用于数据归档和数据保留的需求。旧数据可以归档到单独的分区中,并将其存储在低成本的存储介质上。同时,可以保留较新数据在高性能的存储介质上,以便快速查询和操作。 +- **并行查询和负载均衡**:通过哈希分区或键分区,可以将数据均匀地分布在多个分区中,从而实现并行查询和负载均衡。查询可以同时在多个分区上进行,并在最终合并结果,提高查询性能和系统吞吐量。 +- **数据删除和维护**:使用分区表,可以更轻松地删除或清理不再需要的数据。通过删除整个分区,可以更快速地删除大量数据,而不会影响整个表的操作。此外,可以针对特定分区执行维护任务,如重新构建索引、备份和优化,以减少对整个表的影响。 + + + +分区表并非适用于所有情况。在选择使用分区表时,需要综合考虑数据量、查询模式、存储资源和硬件能力等因素,并评估分区对性能和管理的影响。 + +## 分区方式 + +**分区有两种方式,水平切分和垂直切分,MySQL 数据库支持的分区类型为水平分区,它不支持垂直分区。** + +此外,MySQL 数据库的分区是局部分区索引,一个分区中既存放了数据又存放了索引。而全局分区是指,数据存放在各个分区中,但是所有数据的索引放在一个对象中。**目前,MySQL 数据库还不支持全局分区**。 + +## 分区策略 + +### RANGE 分区 + +RANGE 分区是 MySQL 中的一种分区策略,根据某一列的范围值将数据分布到不同的分区。每个分区包含特定的范围。下面是 RANGE 分区的定义方式、特点以及代码示例。 + + + +定义方式: + +- **指定分区键**:选择作为分区依据的列作为分区键,通常是日期、数值等具有范围特性的列。 +- **分区函数**:通过`PARTITION BY RANGE`指定使用 RANGE 分区策略。 +- **定义分区范围**:使用`VALUES LESS THAN`子句定义每个分区的范围。 + + + +RANGE 分区的特点: + +- **范围划分**:根据指定列的范围进行分区,适用于需要按范围进行查询和管理的情况。 +- **灵活的范围定义**:可以定义任意数量的分区,并且每个分区可以具有不同的范围。 +- **高效查询**:根据查询条件的范围,MySQL 能够快速定位到特定的分区,提高查询效率。 +- **动态管理**:可以根据业务需求轻松添加或删除分区,适应数据增长或变更的需求。 + + + +以下是一个使用 RANGE 分区的代码示例: + +```sql +CREATE TABLE sales ( + id INT, + sales_date DATE, + amount DECIMAL(10, 2) +) +PARTITION BY RANGE (YEAR(sales_date)) ( + PARTITION p1 VALUES LESS THAN (2020), + PARTITION p2 VALUES LESS THAN (2021), + PARTITION p3 VALUES LESS THAN (2022), + PARTITION p4 VALUES LESS THAN MAXVALUE +); +``` + +在上述示例中,我们创建了名为`sales`的表,使用 RANGE 分区策略。根据`sales_date`列的年份范围将数据分布到不同的分区: + +- `PARTITION BY RANGE (YEAR(sales_date))`:指定使用 RANGE 分区,基于`sales_date`列的年份进行分区。 +- `PARTITION p1 VALUES LESS THAN (2020)`:定义名为`p1`的分区,包含年份小于 2020 的数据。 +- `PARTITION p2 VALUES LESS THAN (2021)`:定义名为`p2`的分区,包含年份小于 2021 的数据。 +- `PARTITION p3 VALUES LESS THAN (2022)`:定义名为`p3`的分区,包含年份小于 2022 的数据。 +- `PARTITION p4 VALUES LESS THAN MAXVALUE`:定义名为`p4`的分区,包含超出定义范围的数据。 + +RANGE 分区允许根据列值的范围将数据分散到不同的分区中,适用于按范围进行查询和管理的情况。它提供了更灵活的数据管理和查询效率的提升。 + +### LIST 分区 + +LIST 分区是根据某一列的离散值将数据分布到不同的分区。每个分区包含特定的列值列表。下面是 LIST 分区的定义方式、特点以及代码示例。 + +定义方式: + +- **指定分区键**:选择作为分区依据的列作为分区键,通常是具有离散值的列,如地区、类别等。 + +- **分区函数**:通过`PARTITION BY LIST`指定使用 LIST 分区策略。 + +- **定义分区列表**:使用`VALUES IN`子句定义每个分区包含的列值列表。 + + + +LIST 分区的特点: + +- **列值离散**:根据指定列的具体取值进行分区,适用于具有离散值的列。 +- **灵活的分区定义**:可以定义任意数量的分区,并且每个分区可以具有不同的列值列表。 +- **高效查询**:根据查询条件的列值直接定位到特定分区,提高查询效率。 +- **动态管理**:可以根据业务需求轻松添加或删除分区,适应数据增长或变更的需求。 + + + + +以下是一个使用 LIST 分区的代码示例: + +```sql +CREATE TABLE users ( + id INT, + username VARCHAR(50), + region VARCHAR(50) +) +PARTITION BY LIST (region) ( + PARTITION p_east VALUES IN ('New York', 'Boston'), + PARTITION p_west VALUES IN ('Los Angeles', 'San Francisco'), + PARTITION p_other VALUES IN (DEFAULT) +); +``` + +在上述示例中,我们创建了名为`users`的表,使用 LIST 分区策略。根据`region`列的具体取值将数据分布到不同的分区: + +- `PARTITION BY LIST (region)`:指定使用 LIST 分区,基于`region`列的值进行分区。 +- `PARTITION p_east VALUES IN ('New York', 'Boston')`:定义名为`p_east`的分区,包含值为'New York'和'Boston'的`region`列的数据。 +- `PARTITION p_west VALUES IN ('Los Angeles', 'San Francisco')`:定义名为`p_west`的分区,包含值为'Los Angeles'和'San Francisco'的`region`列的数据。 +- `PARTITION p_other VALUES IN (DEFAULT)`:定义名为`p_other`的分区,包含其他`region`列值的数据。 + +### HASH 分区 + +HASH 分区是使用哈希算法将数据均匀地分布到多个分区中。下面是 HASH 分区的定义方式、特点以及代码示例。 + +定义方式: + +- **指定分区键**:选择作为分区依据的列作为分区键。 + +- **分区函数**:通过`PARTITION BY HASH`指定使用 HASH 分区策略。 + +- **定义分区数量**:使用`PARTITIONS`关键字指定分区的数量。 + + + +HASH 分区的特点: + +- **数据均匀分布**:HASH 分区使用哈希算法将数据均匀地分布到不同的分区中,确保数据在各个分区之间平衡。 + +- **并行查询性能**:通过将数据分散到多个分区,HASH 分区可以提高并行查询的性能,多个查询可以同时在不同分区上执行。 + +- **简化管理**:HASH 分区使得数据管理更加灵活,可以轻松地添加或删除分区,以适应数据增长或变更的需求。 + + + +以下是一个使用 HASH 分区的代码示例: + +```sql +CREATE TABLE sensor_data ( + id INT, + sensor_name VARCHAR(50), + value INT +) +PARTITION BY HASH (id) PARTITIONS 4; +``` + + + +在上述示例中,我们创建了名为`sensor_data`的表,使用 HASH 分区策略。根据`id`列的哈希值将数据分布到 4 个分区中: + +- `PARTITION BY HASH (id)`:指定使用 HASH 分区,基于`id`列的哈希值进行分区。 +- `PARTITIONS 4`:指定创建 4 个分区。 + +### KEY 分区 + +KEY 分区是根据某一列的哈希值将数据分布到不同的分区。不同于 HASH 分区,KEY 分区使用的是列值的哈希值而不是哈希函数。下面是 KEY 分区的定义方式、特点以及代码示例。 + + + +定义方式: + +- **指定分区键**:选择作为分区依据的列作为分区键。 +- **分区函数**:通过`PARTITION BY KEY`指定使用 KEY 分区策略。 +- **定义分区数量**:使用`PARTITIONS`关键字指定分区的数量。 + + + +KEY 分区的特点: + +- **哈希分布**:KEY 分区使用列值的哈希值将数据分布到不同的分区中,与哈希函数不同,它使用的是列值的哈希值。 +- **高度自定义**:KEY 分区允许根据业务需求自定义分区逻辑,可以灵活地选择分区键和分区数量。 +- **并行查询性能**:通过将数据分散到多个分区,KEY 分区可以提高并行查询的性能,多个查询可以同时在不同分区上执行。 +- **简化管理**:KEY 分区使得数据管理更加灵活,可以轻松地添加或删除分区,以适应数据增长或变更的需求。 + + + +以下是一个使用 KEY 分区的代码示例: + +```sql +CREATE TABLE orders ( + order_id INT, + customer_id INT, + order_date DATE +) +PARTITION BY KEY (customer_id) PARTITIONS 5; +``` + +在上述示例中,我们创建了名为`orders`的表,使用 KEY 分区策略。根据`customer_id`列的哈希值将数据分布到 5 个分区中: + +- `PARTITION BY KEY (customer_id)`:指定使用 KEY 分区,基于`customer_id`列的哈希值进行分区。 +- `PARTITIONS 5`:指定创建 5 个分区。 + +### COLUMNS 分区 + +MySQL 在 5.5 版本引入了 COLUMNS 分区类型,其中包括 `RANGE COLUMNS` 分区和 `LIST COLUMNS` 分区。以下是对这两种 COLUMNS 分区的详细说明: + +**RANGE COLUMNS 分区**: RANGE COLUMNS 分区是根据列的范围值将数据分布到不同的分区的分区策略。它类似于 RANGE 分区,但是根据多个列的范围值进行分区,而不是只根据一个列。这使得范围的定义更加灵活,可以基于多个列的组合来进行分区。 + +下面是一个 RANGE COLUMNS 分区的代码示例: + +```sql +CREATE TABLE sales ( + id INT, + sales_date DATE, + region VARCHAR(50), + amount DECIMAL(10, 2) +) +PARTITION BY RANGE COLUMNS (region, sales_date) ( + PARTITION p1 VALUES LESS THAN ('East', '2022-01-01'), + PARTITION p2 VALUES LESS THAN ('West', '2022-01-01'), + PARTITION p3 VALUES LESS THAN ('East', MAXVALUE), + PARTITION p4 VALUES LESS THAN ('West', MAXVALUE) +); +``` + +在上述示例中,我们创建了一个名为 sales 的表,并使用 RANGE COLUMNS 分区策略。根据 `region` 和 `sales_date` 两列的范围将数据分布到不同的分区。每个分区根据这两列的范围值进行划分。 + + + +**LIST COLUMNS 分区**: LIST COLUMNS 分区是根据列的离散值将数据分布到不同的分区的分区策略。它类似于 LIST 分区,但是根据多个列的离散值进行分区,而不是只根据一个列。这使得离散值的定义更加灵活,可以基于多个列的组合来进行分区。 + +下面是一个 LIST COLUMNS 分区的代码示例: + +```sql +CREATE TABLE users ( + id INT, + username VARCHAR(50), + region VARCHAR(50), + category VARCHAR(50) +) +PARTITION BY LIST COLUMNS (region, category) ( + PARTITION p_east VALUES IN (('New York', 'A'), ('Boston', 'B')), + PARTITION p_west VALUES IN (('Los Angeles', 'C'), ('San Francisco', 'D')), + PARTITION p_other VALUES IN (DEFAULT) +); +``` + +在上述示例中,我们创建了一个名为 users 的表,并使用 LIST COLUMNS 分区策略。根据 `region` 和 `category` 两列的离散值将数据分布到不同的分区。每个分区根据这两列的离散值进行划分。 + +## 常见分区命令 + +### 是否支持分区 + +在 MySQL5.6.1 之前可以通过命令 `show variables like '%have_partitioning%'` 来查看 MySQL 是否支持分区。如果 `have_partitioning` 的值为 YES,则表示支持分区。 + +从 MySQL5.6.1 开始,`have_partitioning` 参数已经被去掉了,而是用 `SHOW PLUGINS` 来代替。若有 partition 行且 STATUS 列的值为 ACTIVE,则表示支持分区,如下所示: + +![](https://static001.geekbang.org/infoq/ff/ffc3b224f422333542f0c1648d9ae01a.png) + +### 创建分区表 + +```sql +CREATE TABLE sales ( + id INT, + sales_date DATE, + amount DECIMAL(10, 2) +) +PARTITION BY RANGE (YEAR(sales_date)) ( + PARTITION p1 VALUES LESS THAN (2020), + PARTITION p2 VALUES LESS THAN (2021), + PARTITION p3 VALUES LESS THAN (2022), + PARTITION p4 VALUES LESS THAN MAXVALUE +); +``` + +### 向分区表添加新的分区 + +```sql +ALTER TABLE sales + ADD PARTITION (PARTITION p5 VALUES LESS THAN (2023)); +``` + +### 删除指定的分区 + +```sql +ALTER TABLE sales DROP PARTITION p3; +``` + +### 重新组织分区 + +```sql +ALTER TABLE sales + REORGANIZE p1, p2, p5 INTO (PARTITION p1 VALUES LESS THAN (2020), PARTITION p2 VALUES LESS THAN (2022), PARTITION p3 VALUES LESS THAN MAXVALUE); +``` + +### 合并相邻的分区 + +```sql +ALTER TABLE sales COALESCE PARTITION p1, p2; +``` + +### 分析指定分区的统计信息 + +```sql +ALTER TABLE sales ANALYZE PARTITION p1;: +``` + + + +总的来说,MySQL分区表在数据管理和查询性能上提供了显著的优势。它可以帮助我们处理大规模数据,提高查询速度,并改善系统性能。 + +然而,合理地、有效地实施分区策略也需要对业务需求和数据特性有深刻理解。虽然分区表的使用在许多场景下都是有益的,但仍需要注意其适用性及可能存在的限制。无论如何,掌握和使用MySQL分区表无疑是每个数据库管理员和开发人员工具箱中的一个重要工具。 \ No newline at end of file diff --git "a/docs/md/mysql/MySQL\345\217\214\345\206\231\347\274\223\345\206\262\345\214\272(Doublewrite Buffer).md" "b/docs/md/mysql/MySQL\345\217\214\345\206\231\347\274\223\345\206\262\345\214\272(Doublewrite Buffer).md" deleted file mode 100644 index 6912c06..0000000 --- "a/docs/md/mysql/MySQL\345\217\214\345\206\231\347\274\223\345\206\262\345\214\272(Doublewrite Buffer).md" +++ /dev/null @@ -1,76 +0,0 @@ -[TOC] - -## 摘要 - -InnoDB是MySQL中一种常用的事务性存储引擎,它具有很多优秀的特性。其中,Doublewrite Buffer是InnoDB的一个重要特性之一,本文将介绍Doublewrite Buffer的原理和应用。 - -## 为什么需要Doublewrite Buffer - -我们常见的服务器一般都是Linux操作系统,Linux文件系统页(OS Page)的大小默认是4KB。而MySQL的页(Page)大小默认是16KB。 - -可以使用如下命令查看MySQL的Page大小: - -``` -SHOW VARIABLES LIKE 'innodb_page_size'; -``` - - 一般情况下,其余程序因为需要跟操作系统交互,它们的页(Page)都会大于等于操作系统的页大小,为整数倍。比如,Oracle的Page大小为8KB。 - -MySQL程序是跑在Linux操作系统上的,需要跟操作系统交互,所以MySQL中一页数据刷到磁盘,要写4个文件系统里的页。 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOZqcx7RQ7OHo75SQpNXEukibOIibftFdJO3NibUiaosVOLSs0Nu1Grd14iaaNPmZoriaydwCFDnic3Z0dUg/640?wx_fmt=png) - -**需要注意的是,这个操作并非原子操作,比如我操作系统写到第二个页的时候,Linux机器断电了,这时候就会出现问题了。造成”页数据损坏“。并且这种”页数据损坏“靠 redo日志是无法修复的**。 - -**重做日志中记录的是对页的物理操作,而不是页面的全量记录,而如果发生partial page write(部分页写入)问题时,出现问题的是未修改过的数据,此时重做日志(Redo Log)无能为力。写doublewrite buffer成功了,这个问题就不用担心了**。 - -Doublewrite Buffer的出现就是为了解决上面的这种情况,虽然名字带了Buffer,但实际上Doublewrite Buffer是**内存+磁盘**的结构。 - -Doublewrite Buffer是一种特殊文件flush技术,带给InnoDB存储引擎的是数据页的可靠性。它的作用是,在把页写到数据文件之前,InnoDB先把它们写到一个叫doublewrite buffer(双写缓冲区)的共享表空间内,在写doublewrite buffer完成后,InnoDB才会把页写到数据文件的适当的位置。如果在写页的过程中发生意外崩溃,InnoDB在稍后的恢复过程中在doublewrite buffer中找到完好的page副本用于恢复。 - -## Doublewrite Buffer原理 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOZqcx7RQ7OHo75SQpNXEuk0vQSvaAFscwJrFyzEyBzCO4mpr8icY58ne39Y3WTUYBW17QNrOULE6w/640?wx_fmt=png) - -**如上图所示,当有页数据要刷盘时:** - -1. 页数据先通过memcpy函数拷贝至内存中的Doublewrite Buffer中; - -2. Doublewrite Buffer的内存里的数据页,会fsync刷到Doublewrite Buffer的磁盘上,分两次写入磁盘共享表空间中(连续存储,顺序写,性能很高),每次写1MB; - -3. Doublewrite Buffer的内存里的数据页,再刷到数据磁盘存储.ibd文件上(离散写); - - - -Doublewrite Buffer内存结构由128个页(Page)构成,大小是2MB。 - -Doublewrite Buffer磁盘结构在系统表空间上是128个页(2个区,extend1和extend2),大小是2MB。 - -**如果操作系统在将页写入磁盘的过程中发生了崩溃,在恢复过程中,InnoDB存储引擎可以从共享表空间中的Double write中找到该页的一个副本,将其复制到表空间文件,再应用重做日志**。 - -MySQL会检查double writer的数据的完整性,如果不完整直接丢弃double write buffer内容,重新执行那条redo log,如果double write buffer的数据是完整的,用double writer buffer的数据更新该数据页,跳过该redo log。 - -所以在正常的情况下,MySQL写数据页时,会写两遍到磁盘上,第一遍是写到doublewrite buffer,第二遍是写到真正的数据文件中,**这就是“Doublewrite”的由来。** - -在数据库异常关闭的情况下启动时,都会做数据库恢复(redo)操作,恢复的过程中,数据库都会检查页面是不是合法(校验等等),如果发现一个页面校验结果不一致,则此时会用到双写这个功能。 - -我们可以通过如下命令来监控Doublewrite Buffer工作负载: - -``` -mysql> show global status like '%dblwr%'; -``` - -## Doublewrite Buffer相关参数 - -- **innodb_doublewrite**:Doublewrite Buffer是否启用开关,默认是开启状态,InnoDB将所有数据存储两次,首先到双写缓冲区,然后到实际数据文件。 -- **Innodb_dblwr_pages_written**:记录写入到DWB中的页数量。 -- **Innodb_dblwr_writes**:记录DWB写操作的次数。 - -## 总结 - -InnoDB Doublewrite Buffer是InnoDB的一个重要特性,用于保证MySQL数据的可靠性和一致性。它的实现原理是通过将要写入磁盘的数据先写入到Doublewrite Buffer中的内存缓存区域,然后再写入到磁盘的两个不同位置,来避免由于磁盘损坏等因素导致数据丢失或不一致的问题。Doublewrite Buffer对于保证MySQL数据的安全性和一致性具有重要意义。 - ------- - -本篇文章就到这里,感谢阅读,如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。 -![](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) diff --git "a/docs/md/mysql/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\206MVCC\350\256\262\350\247\243\357\274\214\344\270\200\347\257\207\347\234\213\346\207\202.md" "b/docs/md/mysql/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\206MVCC\350\256\262\350\247\243\357\274\214\344\270\200\347\257\207\347\234\213\346\207\202.md" new file mode 100644 index 0000000..3ff910e --- /dev/null +++ "b/docs/md/mysql/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\206MVCC\350\256\262\350\247\243\357\274\214\344\270\200\347\257\207\347\234\213\346\207\202.md" @@ -0,0 +1,396 @@ +## 摘要 + +在当今高度并发的数据库环境中,有效的并发控制是至关重要的。MVCC是MySQL中被广泛采用的并发控制机制,它通过版本管理来实现事务的隔离性,允许读写操作同时进行,提高数据库的并发性能和响应能力。 + +本文将深入解析MVCC机制的原理,帮助读者更好地理解和应用这一关键技术。 + +## MVCC 介绍 + +**MVCC,全称 Multi-Version Concurrency Control,即多版本并发控制** + +MVCC的目的主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁。 + +这里的多版本指的是数据库中同时存在多个版本的数据,并不是整个数据库的多个版本,而是某一条记录的多个版本同时存在。 + +**并发控制的挑战** + +在数据库系统中,同时执行的事务可能涉及相同的数据,因此需要一种机制来保证数据的一致性,传统的锁机制可以实现并发控制,但会导致阻塞和死锁等问题。 + +**MVCC的优点** + +MVCC机制具有以下优点: + +- 提高并发性能:读操作不会阻塞写操作,写操作也不会阻塞读操作,有效地提高数据库的并发性能。 +- 降低死锁风险:由于无需使用显式锁来进行并发控制,MVCC可以降低死锁的风险。 + +## 当前读和快照读 + +在讲解MVCC原理之前,我们先来了解一下,当前读和快照读。 + +**当前读** + +在MySQL中,当前读是一种读取数据的操作方式,它可以直接读取最新的数据版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。MySQL提供了两种实现当前读的机制: + +- 一致性读(Consistent Read): + - 默认隔离级别下(可重复读),MySQL使用一致性读来实现当前读。 + - 在事务开始时,MySQL会创建一个一致性视图(Consistent View),该视图反映了事务开始时刻数据库的快照。 + - 在事务执行期间,无论其他事务对数据进行了何种修改,事务始终使用一致性视图来读取数据。 + - 这样可以保证在同一个事务内多次查询返回的结果是一致的,从而实现了当前读。 +- 锁定读(Locking Read): + - 锁定读是一种特殊情况下的当前读方式,在某些场景下使用。 + - 当使用锁定读时,MySQL会在执行读取操作前获取共享锁或排他锁,以确保数据的一致性。 + - 共享锁(Shared Lock)允许多个事务同时读取同一数据,而排他锁(Exclusive Lock)则阻止其他事务读取或写入该数据。 + - 锁定读适用于需要严格控制并发访问的场景,但由于加锁带来的性能开销较大,建议仅在必要时使用。 + +下面列举的这些语法都是当前读: + +| 语法 | +| ----------------------------- | +| SELECT ... LOCK IN SHARE MODE | +| SELECT ... FOR UPDATE | +| UPDATE | +| DELETE | +| INSERT | + +当前读实际上是一种加锁的操作,是悲观锁的实现。 + +**快照读** + +快照读是在读取数据时读取一个一致性视图中的数据,MySQL使用 MVCC 机制来支持快照读。 + +具体而言,每个事务在开始时会创建一个一致性视图(Consistent View),该视图反映了事务开始时刻数据库的快照。这个一致性视图会记录当前事务开始时已经提交的数据版本。 + +当执行查询操作时,MySQL会根据事务的一致性视图来决定可见的数据版本。只有那些在事务开始之前已经提交的数据版本才是可见的,未提交的数据或在事务开始后修改的数据则对当前事务不可见。 + +像不加锁的 select 操作就是快照读,即不加锁的非阻塞读。 + +快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。 + +**注意:快照读的前提是隔离级别不是串行级别,在串行级别下,事务之间完全串行执行,快照读会退化为当前读** + +MVCC主要就是为了实现读-写冲突不加锁,而这个读指的就是快照读,是乐观锁的实现。 + +## MVCC 原理解析 + +### 隐式字段 + +MySQL中的行数据,除了我们肉眼能看到的字段之外,其实还包含了一些隐藏字段,它们在内部使用,默认情况下不会显示给用户。 + +| 字段 | 含义 | +| ----------- | ------------------------------------------------------------ | +| DB_ROW_ID | 隐含的自增ID(隐藏主键),用于唯一标识表中的每一行数据,如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。 | +| DB_TRX_ID | 该字段存储了当前行数据所属的事务ID。每个事务在数据库中都有一个唯一的事务ID。通过 DB_TRX_ID 字段,可以追踪行数据和事务的所属关系。 | +| DB_ROLL_PTR | 该字段存储了回滚指针(Roll Pointer),它指向用于回滚事务的Undo日志记录。 | + +### Undo Log + +上文提到了 Undo 日志,这个 Undo 日志是 MVCC 能够得以实现的核心所在。 + +Undo日志(Undo Log)是MySQL中的一种重要的事务日志,Undo日志的作用主要有两个方面: + +- **事务回滚**:当事务需要回滚时,MySQL可以通过Undo日志中的旧值将数据还原到事务开始之前的状态,保证了事务回滚的一致性。 +- **MVCC实现**:MVCC 是InnoDB存储引擎的核心特性之一。通过使用Undo日志,MySQL可以为每个事务提供独立的事务视图,使得事务读取数据时能看到一致且符合隔离级别要求的数据版本。 + +**在InnoDB存储引擎中,Undo日志分为两种:插入(insert)Undo日志 和 更新(update)Undo日志** + +- insert undo log:插入Undo日志是指在插入操作中生成的Undo日志。由于插入操作的记录只对当前事务可见,对其他事务不可见,因此在事务提交后可以直接删除,无需进行purge操作。 +- update undo log:更新Undo日志是指在更新或删除操作中生成的Undo日志。更新Undo日志可能需要提供MVCC机制,因此不能在事务提交时就立即删除。相反,它们会在提交时放入Undo日志链表中,并等待purge线程进行最终的删除。删除操作只是设置一下老记录的 DELETED_BIT,并不真正将过时的记录删除,为了节省磁盘空间,InnoDB有专门的purge线程来清理 DELETED_BIT 为true的记录。 + +注意:由于查询操作(SELECT)并不会修改任何记录,所以在查询操作执行时,并不需要记录相应的 undo log 。 + +**不同事务或者相同事务对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录** + +举个例子,比如有个事务A插入了一条新记录:insert into user(id, name) values(1, "小明') + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJHziczHWpULcqicmNJpWfloHY6xnhfTLapwC6MK3FHSYkMZhvH1iadoywQ/640) + +现在来了一个事务B对该记录的name做出了修改,改为 "小王"。 + +在事务B修改该行数据时,数据库会先对该行加排他锁,然后把该行数据拷贝到 undo log 中作为旧记录,即在 undo log 中有当前行的拷贝副本. + +拷贝完毕后,修改该行name为 "小王,并且修改隐藏字段的事务ID为当前事务B的ID, 并将回滚指针指向拷贝到 undo log 的副本记录,即表示我的上一个版本就是它,事务提交后,释放锁。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJYIazBiabgj8QMspbg5QntMuiblGQGr1zq8A5Fj9ufvs85Yvht3CF85KQ/640) + +此时又来了个事务C修改同一个记录,将name修改为 "小红"。 + +在事务C修改该行数据时,数据库也先为该行加锁,然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有 undo log 了,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面,如下图: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJ0QKAgRtyFdibGic6Db2B64xRqpUn0ORziamfYnIOMYOE8VqO0l8FGDrmg/640) + + + +关于 DB_ROLL_PTR 与 Undo日志 的配合工作,具体流程如下: + +1. 在更新或删除操作之前,MySQL会将旧值写入Undo日志中。 +2. 当事务需要回滚时,MySQL会根据事务的Undo日志记录,通过 DB_ROLL_PTR 找到对应的Undo日志。 +3. 根据Undo日志中记录的旧值,MySQL将旧值恢复到相应的数据行中,实现数据的回滚操作。 + +比方说现在想回滚到事务B,name值为 "小王" 的时候,只需通过 DB_ROLL_PTR 顺着列表找到对应的 Undo日志,将旧值恢复到数据行即可。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJBs1F1X44u8vub0jyFmk7x5MDIQKcOeTRV5rx7TSibiaBlwV9QbyiaibX8A/640) + +通过 DB_ROLL_PTR 和 Undo日志 的配合工作,MySQL能够有效地管理事务的一致性和隔离性。Undo日志的使用也使得MySQL能够支持MVCC,从而提供了高并发环境下的读取一致性和事务隔离性。 + +### 版本链 + +在MVCC中,对于每次更新操作,旧值会被保存到一条undo日志中,即使它是该记录的旧版本。随着更新次数的增加,所有的版本都会通过roll_pointer属性连接成一个链表,称之为版本链。 + +版本链的头节点代表当前记录的最新值。此外,每个版本还包含生成该版本的事务ID。 + +### Read View + +**一致性视图,全称 Read View ,是用来判断版本链中的哪个版本对当前事务是可见的** + +Read View 说白了就是事务进行快照读操作时候生成的读视图(Read View),在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(每个事务开启时,都会被分配一个ID,这个ID是递增的)。 + +这里有一点要注意一下:**Read View只针对 RC 和 RR级别** + +Read Uncommitted(RU)和 Serializable(串行化)是两个特殊的隔离级别,它们不需要使用 Read View 的主要原因是: + +- Read Uncommitted(RU)隔离级别: 在 RU 隔离级别下,事务可以读取其他事务尚未提交的数据,即脏读。这意味着不需要通过 Read View 来限制访问范围,事务可以自由地读取其他事务的未提交数据。由于没有对可见性进行严格控制,因此不需要创建或使用 Read View。 +- Serializable(串行化)隔离级别: 在 Serializable 隔离级别下,事务具有最高的隔离性,确保每次读取都能看到一致的快照。为了实现这种隔离级别,MySQL使用锁机制来保证事务之间的串行执行。由于事务按顺序执行,并且不允许并发操作,所以不需要使用 Read View 进行可见性判断。 + +Read Uncommitted 和 Serializable 隔离级别下的事务规则不涉及基于 Read View 的可见性判断。RU 允许脏读,而 Serializable 则通过锁机制保证串行执行。因此,在这两个隔离级别下,不需要创建或使用 Read View。 + +### Read View 可见性原则 + +Read View 遵循一个可见性原则,将要被修改的数据的 DB_TRX_ID 取出来,与系统当前其他活跃事务的ID去对比。 + +如果 DB_TRX_ID 跟 Read View 的属性做了某些比较,不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 Undo Log 中的 DB_TRX_ID 再比较。 + +即遍历链表的 DB_TRX_ID (从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的 DB_TRX_ID,那么这个 DB_TRX_ID 所在的记录就是当前事务能看见的最新老版本。 + +Read View 会维护以下几个字段: + +| 字段 | 含义 | +| ---------------- | ------------------------------------------------------------ | +| m_ids | `Read View` 创建时其他未提交的活跃事务 ID 列表。创建 `Read View`时,将当前未提交事务 ID 记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。`m_ids` 不包括当前事务自己和已提交的事务(正在内存中)。 | +| m_creator_trx_id | 创建该 `Read View` 的事务 ID。 | +| m_low_limit_id | 目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见。 | +| m_up_limit_id | 活跃事务列表 `m_ids` 中最小的事务 ID,如果 `m_ids` 为空,则 `m_low_limit_id`为`m_up_limit_id` 。小于这个 ID 的数据版本均可见。 | + +Read View 可见性具体判断如下: + +1. 如果被访问版本的 `DB_TRX_ID ` 属性值与 Read View 中的 `m_creator_trx_id` 值相同,表示当前事务正在访问自己所修改的记录,因此该版本可以被当前事务访问。 + +2. 如果被访问版本的 `DB_TRX_ID ` 属性值小于 Read View 中的 `m_up_limit_id `值,说明生成该版本的事务在当前事务生成 Read View 之前已经提交,因此该版本可以被当前事务访问。 + +3. 如果被访问版本的 `DB_TRX_ID ` 属性值大于或等于 Read View 中的 `m_low_limit_id ` 值,说明生成该版本的事务在当前事务生成 Read View 之后才提交,因此该版本不能被当前事务访问。 + +4. 如果被访问版本的 `DB_TRX_ID ` 属性值位于 Read View 的 `m_up_limit_id` 和 `m_low_limit_id ` 之间(包括边界),则需要进一步检查 `DB_TRX_ID ` 是否在`m_ids `列表中。如果在列表中,说明在创建ReadView时生成该版本的事务仍处于活跃状态,因此该版本不能被访问;如果不在列表中,说明在创建 Read View 时生成该版本的事务已经提交,因此该版本可以被访问。 + + +事务可见性示意图: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJLogibJCiaCWD7IdRgZiaGiaKTPsFO4LM2z1oxGNdlRRibcYA81cwicf0CJXA/640) + +## RC 和 RR 下的 Read View + +RC 和 RR 下生成 `Read View` 的时机是有所差异的: + +- **RC**:每次 SELECT 数据前都生成一个ReadView。 +- **RR**:只在第一次读取数据时生成一个ReadView,后面会复用第一次生成的。 + +正因为RC 和 RR生成 Read View 的时机不同,导致两个级别下看到的数据会不一致。 + +举例说明,假设数据初始状态如下: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJuicCSEtYL2iczewvBkHjicGzRibF9mhJ5C0aaKUae4P16F5cIzia38Ka8rw/640) + +有 A,B,C 三个事务,执行顺序如下: + +| | 事务A(事务ID: 100) | 事务B(事务ID: 200) | 事务C(事务ID: 300) | +| ---- | -------------------------------------- | -------------------------------------- | ------------------------------- | +| T1 | begin | | | +| T2 | | begin | begin | +| T3 | update user set name="小王" where id=1 | | | +| T4 | update user set name="小红" where id=1 | | select * from user where id = 1 | +| T5 | commit | update user set name="小黑" where id=1 | | +| T6 | | update user set name="小白" where id=1 | select * from user where id = 1 | +| T7 | | commit | | +| T8 | | | select * from user where id = 1 | +| T9 | | | commit | +| T10 | | | | + +### RC 下的 Read View + +**T4时刻** + +我们来看 T4 时刻的情况,此时 事务A 和 事务B 都还没提交,所以活跃的事务ID,即 `m_ids` 为:[100,200],四个字段的值分别如下: + +| 字段 | 值 | +| ---------------- | ---------- | +| m_ids | [100,200] | +| m_creator_trx_id | 300 | +| m_low_limit_id | 400 | +| m_up_limit_id | 100 | + +T4时刻的版本链如下: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJqCxKKZIJ3wDRLdm47ibKuLMEBQKqtErTCXF5kvhj2CvvuXEPYjM5VCg/640) + +依据我们之前说的可见性原则,事务C最终看到的应该是 `name = "小明"` 的数据,理由如下: + +最新记录的 `DB_TRX_ID` 为 100,既不小于 `m_up_limit_id`,也不大于 `m_low_limit_id`,也不等于 `m_creator_trx_id`。 + +落在了黄区: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJLogibJCiaCWD7IdRgZiaGiaKTPsFO4LM2z1oxGNdlRRibcYA81cwicf0CJXA/640) + +`DB_TRX_ID` 存在于 `m_ids` 列表中,故不可见,顺着版本链继续往下。 + +根据 `DB_ROLL_PTR` 找到 `undo log` 中的前一版本记录,前一条记录的 `DB_TRX_ID` 还是 100,还是不可见,继续往下。 + +继续找前一条 `DB_TRX_ID`为 1,满足 1 < `m_up_limit_id`,可见,所以事务C 查询到数据为 `name = "小明"` 。 + +**T6时刻** + +T6时候的版本链如下: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJg6yFEgEYH29FsWDuia2ILVn3glQZrhAXbNC8laQnI1hNpTiaCPzQeh3w/640) + + + +T6时刻,会再次生成新的 Read View,四个字段的值分别如下: + +| 字段 | 值 | +| ---------------- | ----- | +| m_ids | [200] | +| m_creator_trx_id | 300 | +| m_low_limit_id | 400 | +| m_up_limit_id | 200 | + +根据可见性原则,最终T6时刻事务C 查询到数据为 `name = "小红"` 。 + +**T8时刻** + +T8时刻的版本链和T6时刻是一致的,不同的是 Read View,因为T8时刻会再生成一个 Read View,四个字段的值分别如下: + +| 字段 | 值 | +| ---------------- | ---- | +| m_ids | [] | +| m_creator_trx_id | 300 | +| m_low_limit_id | 400 | +| m_up_limit_id | 400 | + +根据可见性原则,最终T8时刻事务C 查询到数据为 `name = "小白"` 。 + +总结一下,事务C在 RC 级别下各个时刻看到的数据如下: + +| 时刻 | name | +| ---- | ---- | +| T4 | 小明 | +| T6 | 小红 | +| T8 | 小白 | + +下面我们来看看,RR 级别下的表现是如何的。 + +### RR 下的 Read View + +(RR 的版本链和 RC 的版本链是一致的,区别在于 Read View) + +**T4时刻** + +T4 时刻的情况,和 R C的情况是一致的: + +| 字段 | 值 | +| ---------------- | ---------- | +| m_ids | [100,200] | +| m_creator_trx_id | 300 | +| m_low_limit_id | 400 | +| m_up_limit_id | 100 | + +根据可见性原则,最终T4时刻事务C 查询到数据为 `name = "小明"` ,和 RC 的T4时刻是一致的。 + +**T6时刻** + +RR 级别会复用 Read View,所以T6时刻也是: + +| 字段 | 值 | +| ---------------- | ---------- | +| m_ids | [100,200] | +| m_creator_trx_id | 300 | +| m_low_limit_id | 400 | +| m_up_limit_id | 100 | + +根据可见性原则,T6时刻我们发现事务C查询到的数据还是 `name = "小明"` 。 + +继续看T8时刻。 + +**T8时刻** + +T8时刻继续复用先前的 Read View。 + +根据可见性原则,T8时刻事务C查询到的数据依旧是 `name = "小明"` 。 + +### 小结 + +我们将事务C在 RC 和 RR 级别下看到的数据,放到一块来对比下: + +| 时刻 | RC | RR | +| ---- | ---- | | +| T4 | 小明 | 小明 | +| T6 | 小红 | 小明 | +| T8 | 小白 | 小明 | + +可以看出二者由于生成 Read View 的时机不同,导致在各个时刻看到的数据会存在差异。 + +回过头来看 RC 和 RR 隔离级别的定义,会有种恍然大悟的感觉: + +- 读已提交(Read Committed):事务只能读取到已经提交的数据。 +- 可重复读(Repeatable Read):事务在整个事务期间保持一致的快照视图,不受其他事务的影响。 + +**总之在 RC 隔离级别下,每个快照读都会生成并获取最新的 Read View;而在 RR 隔离级别下,则是只在第一个快照读创建Read View,之后的快照读获取的都是同一个Read View** + +## RR 级别下能否防止幻读 + +**严谨的说,RR 级别下只能防止部分幻读** + +首先,幻读通常指的是在同一个事务中,第二次查询发现了新增加的行,而第一次查询并没有返回这些新增加的行。 + +通过前面的例子,我们也看到了,在 RR 隔离级别下,由于一致性视图的存在,如果其他事务插入了新的行,在同一个事务中进行多次查询,这些新增的行将会被包含在事务的一致性视图中,确实可以避免部分幻读场景。 + +> 这里注意一下:MVCC解决的只是 RR 级别下快照读的幻读问题,而当前读的幻读问题则是通过临键锁来解决的。也就是说 RR 级别下是通过 MVCC+临键锁 来解决大部分幻读问题的。 + +为什么说是部分解决?看下面这个例子: + +| | 事务A | 事务B | +| ---- | -------------------------------------------- | ----------------------------- | +| T1 | begin | | +| T2 | | begin | +| T3 | | select * from user | +| T4 | insert into user(id, name) values(2, "小张') | | +| T5 | | select * from user for update | +| T6 | commit | | +| T7 | | commit | + +假设数据初始状态如下: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScP6Xr0ibupPNhyKvbXYAuBwJuicCSEtYL2iczewvBkHjicGzRibF9mhJ5C0aaKUae4P16F5cIzia38Ka8rw/640) + +T3时刻看到的数据只有一条 `name = "小明"`,而T5时刻,由于 select * from user for update 使用的是当前读,读取的是最新的数据版本,T5时刻查询出来的数据是两条,name 分别为 "小明" 和 "小张"。 + +理解了上面的例子之后,再看下面这个例子: + +| | 事务A | 事务B | +| ---- | -------------------------------------------- | -------------------------------------- | +| T1 | begin | | +| T2 | | begin | +| T3 | | select * from user | +| T4 | insert into user(id, name) values(2, "小张') | | +| T5 | | update user set name="小陈" where id=2 | +| T6 | | select * from user | +| T7 | commit | | +| T8 | | commit | + +UPDATE 语句也是当前读,也会发生幻读问题,最终看到的数据是name 分别为 "小明" 和 "小陈"。 + +这里发生幻读的原因,和上面的例子是一样的,本质都是在一个事务中,即使用了快照读又使用了当前读,RR 级别下无法预防此种情况,所以说 RR 级别下无法完全解决幻读问题。 + +## 总结 + +综上所述,MVCC 是一种强大的并发控制机制,在高并发环境中起着重要的作用。通过了解 MVCC 的原理和实现流程,我们可以更好地理解 MySQL 的并发控制机制,理解 MVCC 的原理对于接触 MySQL 的开发人员来说是必不可少的知识点。 + +希望本文对各位同学有所帮助,加深对 MVCC 及其在 MySQL 中的应用的理解。感谢阅读! + diff --git "a/docs/md/mysql/\345\205\255\344\270\252\346\241\210\344\276\213\346\220\236\346\207\202\351\227\264\351\232\231\351\224\201md.md" "b/docs/md/mysql/\345\205\255\344\270\252\346\241\210\344\276\213\346\220\236\346\207\202\351\227\264\351\232\231\351\224\201md.md" new file mode 100644 index 0000000..7215653 --- /dev/null +++ "b/docs/md/mysql/\345\205\255\344\270\252\346\241\210\344\276\213\346\220\236\346\207\202\351\227\264\351\232\231\351\224\201md.md" @@ -0,0 +1,245 @@ + +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + +[TOC] + +MySQL中的间隙是指索引中两个索引键之间的空间,间隙锁用于防止范围查询期间的幻读,确保查询结果的一致性和并发安全性。 + +## 概念解释 + +**记录锁(Record Lock)** + +记录锁也被称为行锁,顾名思义,它是针对数据库中的行记录进行的锁定。 + +比如: + +```sql +SELECT * FROM `user` WHERE `id`=1 FOR UPDATE; +``` + +上面的SQL会在 `id=1` 的行记录上加上记录锁,以阻止其他事务插入,更新,删除这一行。 + +**间隙锁(Gap Lock)** + +间隙锁就是对间隙加锁,用于锁定索引范围之间的间隙,以避免其他事务在这个范围内插入新的数据。间隙锁是排它锁,阻止了其他事务在间隙中插入满足条件的值,间隙锁仅在可重复读隔离级别下才有效。 + +> 关于间隙锁的详细讲解放在下文,这里只是先做个概念上的介绍。 + +**临键锁(Next-Key Lock)** + +临键锁由记录锁和间隙锁组合而成,它在索引范围内的记录上加上记录锁,并在索引范围之间的间隙上加上间隙锁。这样可以避免幻读(Phantom Read)的问题,确保事务的隔离性。 + +切记:间隙锁的区间是左开右开的,临键锁的区间是左开右闭的。 + +## 间隙锁详解 + +间隙锁是保证临键锁正常运作的基础,理解间隙锁的概念对于深入理解这三种锁非常重要。 + +**间隙锁的锁定范围是指在索引范围之间的间隙** + +举个简单例子来说明: + +假设有一个名为`products`的表,其中有一个整型列`product_id`作为主键索引。现在有两个并发事务:事务A和事务B。 + +事务A执行以下语句: + +```sql +BEGIN; +SELECT * FROM `products` WHERE `product_id` BETWEEN 100 and 200 FOR UPDATE; +``` + +事务B执行以下语句: + +```sql +BEGIN; +INSERT INTO `products` (`product_id`, `name`) VALUES (150, 'Product 150'); +``` + +在这种情况下,事务A会在`products`表中`product_id`值在 100 和 200 之间的范围上设置间隙锁。因此,在事务A运行期间,其他事务无法在这个范围内插入新的数据,在事务B尝试插入`product_id`为150的记录时,由于该记录位于事务A锁定的间隙范围内,事务B将被阻塞,直到事务A释放间隙锁为止。 + +### 间隙锁触发条件 + +在可重复读(Repeatable Read)事务隔离级别下,以下情况会产生间隙锁: + +- 使用普通索引锁定:当一个事务使用普通索引进行条件查询时,MySQL会在满足条件的索引范围之间的间隙上生成间隙锁。 +- 使用多列唯一索引:如果一个表存在多列组成的唯一索引,并且事务对这些列进行条件查询时,MySQL会在满足条件的索引范围之间的间隙上生成间隙锁。 +- 使用唯一索引锁定多行记录:当一个事务使用唯一索引来锁定多行记录时,MySQL会在这些记录之间的间隙上生成间隙锁,以确保其他事务无法在这个范围内插入新的数据。 + +需要注意的是,上述情况仅在可重复读隔离级别下才会产生间隙锁。在其他隔离级别下,如读提交(Read Committed)隔离级别,MySQL可能会使用临时的意向锁来避免并发问题,而不是生成真正的间隙锁。 + +为什么这里强调的是普通索引呢?因为对唯一索引锁定并不会触发间隙锁,请看下面这个例子: + +假设我们有一个名为`students`的表,其中有两个字段:id 和 name。id是主键,现在有两个事务同时进行操作: + +事务A执行以下语句: + +```sql +SELECT * FROM students WHERE id = 1 FOR UPDATE; +``` + +事务B执行以下语句: + +```sql +INSERT INTO students (id, name) VALUES (2, 'John'); +``` + +由于事务A使用了唯一索引锁定,它会锁定id为1的记录,不会触发间隙锁。同时,在事务B中插入id为2的记录也不会受到影响。这是因为唯一索引只会锁定匹配条件的具体记录,而不会锁定不存在的记录(如间隙)。 + +**当使用唯一索引锁定一条存在的记录时,会使用记录锁,而不是间隙锁** + +但是当搜索条件仅涉及到多列唯一索引的一部分列时,可能会产生间隙锁。以下是一个例子: + +假设`students`表,包含三个列:id、name和age。我们在(name, age)上创建了一个唯一索引。 + +现在有两个事务同时进行操作: + +事务A执行以下语句: + +```sql +SELECT * FROM students WHERE name = 'John' FOR UPDATE; +``` + +事务B执行以下语句: + +```sql +INSERT INTO students (id, name, age) VALUES (2, 'John', 25); +``` + +在这种情况下,事务A搜索的条件只涉及到了唯一索引的一部分列(name),而没有涉及到完整的索引列(name, age)。因此,MySQL会对匹配的记录加上行锁,并且还会对与该条件范围相邻的间隙加上间隙锁。 + +### 间隙锁加锁规则 + +间隙锁有以下加锁规则: + +- 规则1:加锁的基本单位是 Next-Key Lock,左开右闭区间。 +- 规则2:查找过程中访问到的对象才会加锁。 +- 规则3:唯一索引上的范围查询会上锁到不满足条件的第一个值为止。 +- 规则4:唯一索引等值查询,并且记录存在,Next-Key Lock 退化为行锁。 +- 规则5:索引上的等值查询,会将距离最近的左边界和右边界作为锁定范围,如果索引不是唯一索引还会继续向右匹配,直到遇见第一个不满足条件的值,如果最后一个值不等于查询条件,Next-Key Lock 退化为间隙锁。 + +记住上述这些规则,这些规则不太好理解,我们下面通过案例来讲解。 + +### 案例演示 + +环境:MySQL,InnoDB,RR隔离级别。 + +数据表: + +```sql +CREATE TABLE `user` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `age` int DEFAULT NULL, + `name` varchar(32) DEFAULT NULL, + PRIMARY KEY (`id`) + KEY `age` (`age`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; +``` + +数据: + +| id | age | name | +| ---- | ---- | ---- | +| 1 | 1 | 小明 | +| 5 | 5 | 小王 | +| 7 | 7 | 小张 | +| 11 | 11 | 小陈 | + +在进行测试之前,我们先来看看 user 表中存在的隐藏间隙: + +- (-∞, 1] +- (1, 5] +- (5, 7] +- (7, 11] +- (11, +∞] + +#### 案例一:唯一索引等值锁定存在的数据 + +如下是事务A和事务B执行的顺序: + +| 时刻 | 事务A | 事务B | +| ---- | ------------------------------------------- | -------------------------------------------- | +| T1 | begin | begin | +| T2 | select * from user where id = 5 for update | | +| T3 | | insert into user value(3,3,"小黑") ---不阻塞 | +| T4 | | insert into user value(6,6,"小蓝") ---不阻塞 | +| T5 | commit | commit | + +根据规则4,加的是记录锁,不会使用间隙锁,所以只会锁定 5 这一行记录。 + +#### 案例二:索引等值锁定 + +| 时刻 | 事务A | 事务B | +| ---- | ------------------------------------------------------------ | ----------------------------------------------- | +| T1 | begin | begin | +| T2 | select * from user where id = 3 for update --- 不存在的数据 | | +| T3 | | insert into user value(6,6,"小蓝") --- 不阻塞 | +| T4 | | insert into user value(2,2,"小黄") --- 阻塞 | +| T5 | commit | | + + +这是一个索引等值查询,根据规则1和规则5,加锁范围是( 1,5 ] ,又由于向右遍历时最后一个值 5 不满足查询需求,Next-Key Lock 退化为间隙锁。也就是最终锁定范围区间是 ( 1,5 )。 + +#### 案例三:唯一索引范围锁定 + +| 时刻 | 事务A | 事务B | +| ---- | ----------------------------------------------------- | --------------------------------------------- | +| T1 | begin | begin | +| T2 | select * from user where id >= 5 and id<6 for update | | +| T3 | | insert into user value(7,7,"小赵") --- 阻塞 | +| T4 | commit | | + +根据规则3,会上锁到不满足条件的第一个值为止,也就是7,所以最终加锁范围是 [ 5,7 ]。 + +其实这里可以分为两个步骤,第一次用 id=5 定位记录的时候,其实加上了间隙锁 ( 1,5 ],又因为是唯一索引等值查询,所以退化为了行锁,只锁定 5。 + +第二次用 id<6 定位记录的时候,其实加上了间隙锁( 5,7 ],所以最终合起来锁定区间是 [ 5,7 ]。 + +#### 案例四:非唯一索引范围锁定 + +| 时刻 | 事务A | 事务B | +| ---- | ------------------------------------------------------- | ----------------------------------------------- | +| T1 | begin | begin | +| T2 | select * from user where age >= 5 and age<6 for update | | +| T3 | | insert into user value(8,8,"小青") --- 不阻塞 | +| T4 | | insert into user value(2,2,"小黄") --- 阻塞 | +| T5 | commit | | + +参考上面那个例子。 + +第一次用 age =5 定位记录的时候,加上了间隙锁 ( 1,5 ],不是唯一索引,所以不会退化为行锁,根据规则5,会继续向右匹配,所以最终合起来锁定区间是 ( 1,7 ]。 + +#### 案例五:间隙锁死锁 + +| 时刻 | 事务A | 事务B | +| ---- | -------------------------------------------- | --------------------------------------------- | +| T1 | begin | begin | +| T2 | select * from user where id = 3 for update | | +| T3 | | select * from user where id = 4 for update | +| T4 | | insert into user value(2,2,"小黄") --- 阻塞 | +| T5 | insert into user value(4,4,"小紫") --- 阻塞 | | + +间隙锁之间不是互斥的,如果一个事务A获取到了( 1,5 ] 之间的间隙锁,另一个事务B仍然可以获取到( 1,5 ] 之间的间隙锁。这时就可能会发生死锁问题。 + +在事务A事务提交,间隙锁释放之前,事务B也获取到了间隙锁( 1,5 ] ,这时两个事务就处于死锁状态。 + +#### 案例六:limit对加锁的影响 + +| 时刻 | 事务A | 事务B | +| ---- | ----------------------------------- | ----------------------------------------------- | +| T1 | begin | begin | +| T2 | deletet user where age = 6 limt 1 | | +| T3 | | insert into user value(7,7,"小赵") --- 不阻塞 | +| T4 | | | +| T5 | commit | commit | + +根据规则5,锁定区间应该是 ( 5,7 ],但是因为加了 limit 1 的限制,因此在遍历到 age=6 这一行之后,循环就结束了。 + +根据规则2,查找过程中访问到的对象才会加锁,所以最终锁定区间应该是:( 5,6 ]。 + +## 总结 + +在本文中,我们讨论了间隙锁的加锁规则。间隙锁是MySQL中用于保护范围查询和防止并发问题的重要机制,了解间隙锁的加锁规则对于优化数据库性能、减少数据冲突以及提高并发性能非常重要。 + +希望本文能够帮助您深入了解和应用间隙锁,并为您的数据库开发和优化工作提供一些指导和启示。如果您有任何疑问或需要进一步讨论,欢迎随时与我们联系。 \ No newline at end of file diff --git "a/docs/md/mysql/\346\267\261\345\205\245\346\265\205\345\207\272MySQL MRR\357\274\210Multi-Range Read\357\274\211.md" "b/docs/md/mysql/\346\267\261\345\205\245\346\265\205\345\207\272MySQL MRR\357\274\210Multi-Range Read\357\274\211.md" new file mode 100644 index 0000000..0b62f16 --- /dev/null +++ "b/docs/md/mysql/\346\267\261\345\205\245\346\265\205\345\207\272MySQL MRR\357\274\210Multi-Range Read\357\274\211.md" @@ -0,0 +1,90 @@ +在探索数据库优化的广阔领域中,我们不可避免地会遇到一系列独特的概念和技术。其中之一就是MySQL的多范围读取(Multi-Range Read, MRR)。 + +这种技术为我们提供了在处理大量数据时提高查询效率的强大手段。它通过改变数据检索的顺序,并利用操作系统缓存进行预读,从而显著减少I/O操作数量,提高查询速度。本文将深入探讨MRR的内部工作原理,以及如何在日常数据库管理中有效地应用这种技术。 + +## 什么是MRR + +**MRR 是优化器将随机 IO 转化为顺序 IO 以降低查询过程中 IO 开销的一种手段。** + +了解MRR之前,我们先来了解下「**回表**」。 + +回表是MySQL在执行查询时的一个步骤,它通常发生在使用索引进行搜索之后。当MySQL在索引中找到了需要的数据,但这些数据并不完全满足查询需求时(比如,索引没有包含所有需要的列),MySQL就需要回到主表中去获取完整的行数据,这个过程就被称为"回表"。 + +举例来说,如果查询语句中有一些列没有被包含在索引中,那么即使从索引中能查到部分信息,也还需要回到原始表中获取其他列的信息,这就是所谓的"回表"操作。为了提高查询效率,我们可以尽量减少回表操作,例如通过使用「**覆盖索引(Covering Index)**」。 + +我们知道二级索引是有回表的过程的,由于二级索引上引用的主键值不一定是有序的,因此就有可能造成大量的随机 IO,如果回表前把主键值在内存中给它排一下序,那么在回表的时候就可以用顺序 IO 取代原本的随机 IO。 + +**在没有MRR的情况下,MySQL会按照索引顺序来访问行数据,而索引顺序并不一定与磁盘上的物理存储顺序一致,这就可能产生大量的随机磁盘I/O。** + +当启用MRR后,MySQL会先按照索引扫描记录,但并不立即去获取行数据,而是将每个需要访问的行位置(例如主键)保存到一个缓冲区中。 + +然后,MySQL会根据这些行位置,按照物理存储的顺序(通常也就是主键顺序)去获取行数据。这样就能避免大量的随机I/O,因为数据现在是按照它们在磁盘上的物理存储顺序被访问的。 + +比如,当我执行这个语句时: + +```sql +select * from t1 where a>=1 and a<=100; +``` + +主键索引是一棵B+树,在这棵树上,每次只能根据一个主键id查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图所示。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIl4jmfxUkEB7Heh3o71Lk52uSztvD1FzkZYvemgibv7ltxGcBhrJFQLyg/640) + +如果随着a的值递增顺序查询的话,id的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。 + +**因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能**。 + +这,就是MRR优化的设计思路。此时,语句的执行流程变成了这样: + +1. 根据索引a,定位到满足条件的记录,将id值放入`read_rnd_buffer`中。 + +2. 将read_rnd_buffer中的id进行递增排序。 + +3. 排序后的id数组,依次到主键id索引中查记录,并作为结果返回。 + +这里,`read_rnd_buffer`的大小是由`read_rnd_buffer_size`参数控制的。 + +如果步骤1中,`read_rnd_buffer`放满了,就会先执行完步骤2和3,然后清空`read_rnd_buffer`。之后继续找索引a的下个记录,并继续循环。 + +下面两幅图就是使用了MRR优化后的执行流程和explain结果。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlnohcwLLXB2ACwzcdjs8NCEjSpD34Iwia8IJ0beVJeYb6RXjZxvVbxYg/640) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlAlvKCjvgVba7iand56gCticKcJp8BNp0IZOocIHvSyYZXp2JnfGmeKicQ/640) + +从explain结果中,我们可以看到Extra字段多了「**Using MRR**」,表示的是用上了MRR优化。而且,由于我们在`read_rnd_buffer`中按照id做了排序,所以最后得到的结果集也是按照主键id递增顺序的,也就是与图1结果集中行的顺序相反。 + +**MRR能够提升性能的核心在于,这条查询语句在索引a上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势**。 + +简单来说:**MRR 的核心思想就是通过把「随机磁盘读」,转化为「顺序磁盘读」,从而提高了索引查询的性能**。 + +顺序读带来了两个好处: + +1. 磁盘和磁头不再需要来回做机械运动。 + +2. 可以充分利用磁盘预读。 + +所谓的磁盘预读,比如说在客户端请求一页的数据时,可以把后面几页的数据也一起返回,放到数据缓冲池中,这样如果下次刚好需要下一页的数据,就不再需要到磁盘读取。这样做的理论依据是计算机科学中著名的局部性原理:**当一个数据被用到时,其附近的数据也通常会马上被使用**。 + +**MRR 在本质上是一种用「空间换时间」的做法**。 + +MySQL 不可能给你无限的内存来进行排序,这块内存的大小就由参数`read_rnd_buffer_size`来控制,如果`read_rnd_buffer`满了,就会先把满了的 rowid 排好序去磁盘读取,接着清空,然后再往里面继续放 rowid,直到 `read_rnd_buffer` 又达到 `read_rnd_buffe` 配置的上限,如此循环。 + +## MRR如何使用 + +MRR相关参数如下: + +``` +//如果你不打开,是一定不会用到 MRR 的。 +set optimizer_switch='mrr=on'; +set optimizer_switch ='mrr_cost_based=off'; +set read_rnd_buffer_size = 32 * 1024 * 1024; +``` + +`mrr_cost_based: on/off`,则是用来告诉优化器,要不要基于使用 MRR 的成本,考虑使用 MRR 是否值得(cost-based choice),来决定具体的 SQL 语句里要不要使用 MRR。 + +很明显,对于只返回一行数据的查询,是没有必要 MRR 的,而如果你把 `mrr_cost_based` 设为 off,那优化器就会通通使用 MRR,这在有些情况下是很 stupid 的,所以建议这个配置还是设为 on,毕竟优化器在绝大多数情况下都是正确的。 + +通过本文我们可以了解到,MySQL的多范围读取(MRR)优化提供了一个高效的方式来处理和加速查询性能。特别是在处理大量数据、联接操作或者需要处理大量行的复杂查询时,MRR都会展现出其强大的优势。 + +然而,我们也要注意到,不是所有情况下启用MRR都会提升性能,一些具体的场景可能会产生额外的磁盘I/O开销。因此,理解其工作原理并合适地运用在恰当的场景,才是有效使用这个优化策略的关键。、 \ No newline at end of file diff --git "a/docs/md/mysql/\346\267\261\345\205\245\347\220\206\350\247\243MySQL\344\270\255\347\232\204Join\347\256\227\346\263\225.md" "b/docs/md/mysql/\346\267\261\345\205\245\347\220\206\350\247\243MySQL\344\270\255\347\232\204Join\347\256\227\346\263\225.md" new file mode 100644 index 0000000..483c137 --- /dev/null +++ "b/docs/md/mysql/\346\267\261\345\205\245\347\220\206\350\247\243MySQL\344\270\255\347\232\204Join\347\256\227\346\263\225.md" @@ -0,0 +1,148 @@ +在数据库处理中,Join操作是最基本且最重要的操作之一,它能将不同的表连接起来,实现对数据集的更深层次分析。 + +MySQL作为一款流行的关系型数据库管理系统,其在执行Join操作时使用了多种高效的算法,包括Index Nested-Loop Join(NLJ)和Block Nested-Loop Join(BNL)。这些算法各有优缺点,本文将探讨这两种算法的工作原理,以及如何在MySQL中使用它们。 + +## 什么是Join + +在MySQL中,Join是一种用于组合两个或多个表中数据的查询操作。Join操作通常基于两个表中的某些共同的列进行,这些列在两个表中都存在。MySQL支持多种类型的Join操作,如**Inner Join**、**Left Join**、**Right Join**等。 + +Inner Join是最常见的Join类型之一。在Inner Join操作中,只有在两个表中都存在的行才会被返回。 + +例如,如果我们有一个“customers”表和一个“orders”表,我们可以通过在这两个表中共享“customer_id”列来组合它们的数据。 + +```sql +SELECT * +FROM customers +INNER JOIN orders +ON customers.customer_id = orders.customer_id; +``` + +上面的查询将返回所有存在于“customers”和“orders”表中的“customer_id”列相同的行。 + +## Index Nested-Loop Join + +Index Nested-Loop Join(NLJ)算法是Join算法中最基本的算法之一。 + +在NLJ算法中,MySQL首先会选择一个表(通常是小型表)作为驱动表,并迭代该表中的每一行。然后,MySQL在第二个表中搜索匹配条件的行,这个搜索过程通常使用索引来完成。一旦找到匹配的行,MySQL将这些行组合在一起,并将它们作为结果集返回。 + +工作流程如图: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIl94cTiasZKjpEmlWicZ0zBBeNq1eNPiahuOxSGQXX6aYSQJSDn6I3ymnSQ/640) + +例如,执行下面这个语句: + +```sql +select * from t1 straight_join t2 on (t1.a=t2.a); +``` + +> 注:当使用 `straight_join` 时,MySQL会强制按照在查询中指定的从左到右的顺序执行连接。 + +在这个语句里,假设 t1 是驱动表,t2 是被驱动表。我们来看一下这条语句的explain结果。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlEhryCJl5jj5PV23LPSQkQaoW3IRbgfKO3v0WpUUr1g1SvzWpNRP6iaw/640) + +可以看到,在这条语句里,被驱动表t2的字段a上有索引,join过程用上了这个索引,因此这个语句的执行流程是这样的: + +1. 从表t1中读入一行数据 R; +2. 从数据行R中,取出a字段到表t2里去查找; +3. 取出表t2中满足条件的行,跟R组成一行,作为结果集的一部分; +4. 重复执行步骤1到3,直到表t1的末尾循环结束。 + +这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为「**Index Nested-Loop Join**」,简称**NLJ**。 + +NLJ是使用上了索引的情况,那如果查询条件没有使用到索引呢? + +MySQL会选择使用另一个叫作「**Block Nested-Loop Join**」的算法,简称**BNL**。 + +## Block Nested-Loop Join + +Block Nested Loop Join(BNL)算法与NLJ算法不同的是,BNL算法使用一个类似于缓存的机制,将表数据分成多个块,然后逐个处理这些块,以减少内存和CPU的消耗。 + +例如,执行下面这个语句: + +```sql +select * from t1 straight_join t2 on (t1.a=t2.b); +``` + +如果 t2 表的字段b上是没有建立索引的。这时候,被驱动表上没有可用的索引,算法的流程是这样的: + +1. 把表t1的数据读入线程内存`join_buffer`中,由于我们这个语句中写的是select *,因此是把整个表t1放入了内存; +2. 扫描表t2,把表t2中的每一行取出来,跟`join_buffer`中的数据做对比,满足join条件的,作为结果集的一部分返回。 + +这条SQL语句的explain结果如下所示: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlibay271rhTD6FiaNEEGAAEQVC5jTic0fIVMh2vznm2ibjZlRGLAKZNMajw/640) + +可以看到,在这个过程中,MySQL对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是1100。 + +由于`join_buffer`是以无序数组的方式组织的,因此对表t2中的每一行,都要做100次判断,总共需要在内存中做的判断次数是:**100*1000=10万次**。 + +虽然Block Nested-Loop Join算法是全表扫描。但是是在内存中进行的判断操作,速度上会快很多。但是性能仍然不如NLJ。 + +`join_buffer`的大小是由参数**join_buffer_size**设定的,默认值是256k。 + +**那如果join_buffer_size的大小不足以放下表t1的所有数据呢?** + +办法很简单,就是分段放,执行流程如下: + +1. 顺序读取数据行放入`join_buffer`中,直到`join_buffer`满了。 +2. 扫描被驱动表跟`join_buffer`中的数据做对比,满足join条件的,作为结果集的一部分返回。 +3. 清空`join_buffer`,重复上述步骤。 + +虽然分成多次放入`join_buffer`,但是判断等值条件的次数还是不变的,依然是10万次。 + +## MRR & BKA + +上篇文章里我们有提到MRR(Multi-Range Read)。MySQL在5.6版本后引入了**Batched Key Acess(BKA)**算法,这个BKA算法,其实就是对NLJ算法的优化,而BKA算法正是基于MRR。 + +NLJ算法执行的逻辑是:**从驱动表t1,一行行地取出a的值,再到被驱动表t2去做join。也就是说,对于表t2来说,每次都是匹配一个值。这时,MRR的优势就用不上了**。 + +其实我们可以从表t1里一次性地多拿些行出来,先放到一个临时内存,一起传给表t2。这个临时内存不是别人,就是`join_buffer`。 + +通过上一篇文章,我们知道`join_buffer` 在BNL算法里的作用,是暂存驱动表的数据。但是在NLJ算法里并没有用。那么,我们刚好就可以复用`join_buffer`到BKA算法中。 + +NLJ算法优化后的BKA算法的流程,如图所示: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScM0uen1PDv6hsZTdwWPECIlW4MXORyOusGOGvbCkwgts385bNicgS9IZWOJnPic9SeGCxPF1lfqnw7A/640) + +图中,在`join_buffer`中放入的数据是R1~R100,表示的是只会取查询需要的字段。当然,如果`join buffer`放不下R1~R100的所有数据,就会把这100行数据分成多段执行上图的流程。 + +如果要使用BKA优化算法的话,你需要在执行SQL语句之前,先设置 + +```sql +set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on'; +``` + +其中,前两个参数的作用是要启用MRR。这么做的原因是,BKA算法的优化要依赖于MRR。 + +对于BNL,我们可以通过建立索引转为BKA。但是,有时候你确实会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句: + +```sql +select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000; +``` + +假设t1表1000行,t2表100万行,t2.b<=2000过滤后,t2表需要参与join的只有2000行数据。 + +如果这条语句是一个低频的SQL语句,那么在表t2的字段b上创建索引就很浪费了。 + +这时候,我们可以考虑使用临时表。使用临时表的大致思路是: + +1. 把表t2中满足条件的数据放在临时表tmp_t中; +2. 为了让join使用BKA算法,给临时表tmp_t的字段b加上索引; +3. 让表t1和tmp_t做join操作。 + +此时,对应的SQL语句的写法如下: + +```sql +create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb; +insert into temp_t select * from t2 where b>=1 and b<=2000; +select * from t1 join temp_t on (t1.b=temp_t.b); +``` + +总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让join语句能够用上被驱动表上的索引,来触发BKA算法,提升查询性能。 + +## 总结 + +在MySQL中,不管Join使用的是NLJ还是BNL总是应该使用小表做驱动表。更准确地说,**在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表**。 + +另外应当尽量避免使用BNL算法,如果确认优化器会使用BNL算法,就需要做优化。优化的常见做法是,给被驱动表的join字段加上索引,把BNL算法转成BKA算法。对于不好在索引的情况,可以基于临时表的改进方案,提前过滤出小数据添加索引。 diff --git "a/docs/md/mysql/\346\267\261\345\205\245\350\247\243\346\236\220MySQL\345\217\214\345\206\231\347\274\223\345\206\262\345\214\272.md" "b/docs/md/mysql/\346\267\261\345\205\245\350\247\243\346\236\220MySQL\345\217\214\345\206\231\347\274\223\345\206\262\345\214\272.md" new file mode 100644 index 0000000..c4d2384 --- /dev/null +++ "b/docs/md/mysql/\346\267\261\345\205\245\350\247\243\346\236\220MySQL\345\217\214\345\206\231\347\274\223\345\206\262\345\214\272.md" @@ -0,0 +1,101 @@ +在数据库系统的世界中,保障数据的完整性和稳定性是至关重要的任务。为了实现这一目标,MySQL内部使用了许多精巧而高效的机制。 + +InnoDB是MySQL中一种常用的事务性存储引擎,它具有很多优秀的特性。其中,Doublewrite Buffer是InnoDB的一个重要特性之一,本文将介绍Doublewrite Buffer的原理和应用,帮助读者深入理解其如何提高MySQL的数据可靠性并防止可能的数据损坏。 + +## 为什么需要Doublewrite Buffer + +我们常见的服务器一般都是Linux操作系统,Linux文件系统页(OS Page)的大小默认是4KB。而MySQL的页(Page)大小默认是16KB。 + +可以使用如下命令查看MySQL的Page大小: + +```sql +SHOW VARIABLES LIKE 'innodb_page_size'; +``` + + 一般情况下,其余程序因为需要跟操作系统交互,所以它们的页(Page)大小都为操作系统页大小的整数倍。比如,Oracle的Page大小为8KB。 + +MySQL程序是跑在Linux操作系统上的,理所当然要跟操作系统交互,所以MySQL中一页数据刷到磁盘,要写4个文件系统里的页。 + +如图所示: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOZqcx7RQ7OHo75SQpNXEukibOIibftFdJO3NibUiaosVOLSs0Nu1Grd14iaaNPmZoriaydwCFDnic3Z0dUg/640) + +**需要注意的是,这个刷页的操作并非原子操作,比如我操作系统写到第二个页的时候,Linux机器断电了,这时候就会出现问题了。造成「页数据损坏」。并且这种页数据损坏靠 redo日志是无法修复的。** + +redo重做日志中记录的是对页的物理操作,而不是页面的全量记录,当发生「**Partial Page Write(部分页写入)**」问题时,出现问题的是未修改过的数据,此时redo日志无能为力。 + +Doublewrite Buffer的出现就是为了解决上面的这种情况,给InnoDB存储引擎提供了数据页的可靠性,虽然名字带了Buffer,但实际上Doublewrite Buffer是「**内存+磁盘**」的结构。 + +**内存结构**:Doublewrite Buffer内存结构由128个页(Page)构成,大小是2MB。 + +**磁盘结构**:Doublewrite Buffer磁盘结构在系统表空间上是128个页(2个区,extend1和extend2),大小是2MB。 + +Doublewrite Buffer的原理是,再把数据页写到数据文件之前,InnoDB先把它们写到一个叫「**doublewrite buffer(双写缓冲区)**」的共享表空间内,在写doublewrite buffer完成后,InnoDB才会把页写到数据文件适当的位置。 + +如果在写页的过程中发生意外崩溃,InnoDB会在doublewrite buffer中找到完好的page副本用于恢复。 + +## Doublewrite Buffer原理 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOZqcx7RQ7OHo75SQpNXEuk0vQSvaAFscwJrFyzEyBzCO4mpr8icY58ne39Y3WTUYBW17QNrOULE6w/640) + +如上图所示,当有数据页要刷盘时: + +1. 页数据先通过`memcpy`函数拷贝至内存中的Doublewrite Buffer中。 + +2. Doublewrite Buffer的内存里的数据页,会`fsync`刷到Doublewrite Buffer的磁盘上,分两次写入磁盘共享表空间中(连续存储,顺序写,性能很高),每次写1MB。 + +3. Doublewrite Buffer的内存里的数据页,再刷到数据磁盘存储.ibd文件上(离散写)。 + + +**如果操作系统在将页写入磁盘的过程中发生了崩溃,在恢复过程中,InnoDB存储引擎可以从共享表空间中的Double write中找到该页的一个副本,将其复制到表空间文件,再应用redo日志**。 + +所以在正常的情况下,MySQL写数据页时,会写两遍到磁盘上,第一遍是写到doublewrite buffer,第二遍是写到真正的数据文件中,这便是「Doublewrite」的由来。 + +我们可以通过如下命令来监控Doublewrite Buffer工作负载,该命令用于显示有关双写缓冲区(doublewrite buffer)的统计信息。'%dblwr%' 是一个通配符,匹配所有包含 'dblwr' 的状态变量。 + +```sql +show global status like '%dblwr%'; +``` + +这个命令可能会产生如下格式的输出: + +```sql ++------------------------+-------+ +| Variable_name | Value | ++------------------------+-------+ +| Innodb_dblwr_writes | 1000 | +| Innodb_dblwr_pages_written | 8000 | ++------------------------+-------+ +``` + +## Doublewrite Buffer和redo log + +在MySQL的InnoDB存储引擎中,Redo log和Doublewrite Buffer共同工作以确保数据的持久性和恢复能力。 + +1. 当有一个DML(如INSERT、UPDATE)操作发生时, InnoDB会首先将这个操作写入redo log(内存)。这些日志被称为未检查点(uncheckpointed)的redo日志。 +2. 然后,在修改内存中相应的数据页之前,需要将这些更改记录在磁盘上。但是直接把这些修改的页写到其真正的位置可能会因发生故障导致页部分更新,从而导致数据不一致。因此,InnoDB的做法是先将这些修改的页按顺序写入doublewrite buffer。这就是为什么叫做 "doublewrite" —— 数据实际上被写了两次,先在doublewrite buffer,然后在它们真正的位置。 +3. 一旦这些页被安全地写入doublewrite buffer,它们就可以按原始的顺序写回到文件系统中。即使这个过程在写回数据时发生故障,我们仍然可以从doublewrite buffer中恢复数据。 +4. 最后,当事务提交时,相关联的redo log会被写入磁盘。这样即使系统崩溃,redo log也可以用来重播(replay)事务并恢复数据库。 + +在系统恢复期间,InnoDB会检查doublewrite buffer,并尝试从中恢复损坏的数据页。如果doublewrite buffer中的数据是完整的,那么InnoDB就会用doublewrite buffer中的数据来更新损坏的页。否则,如果doublewrite buffer中的数据不完整,InnoDB也有可能丢弃buffer内容,重新执行那条redo log以尝试恢复数据。 + +所以,Redo log和Doublewrite Buffer的协作可以确保数据的完整性和持久性。如果在写入过程中发生故障,我们可以从doublewrite buffer中恢复数据,并通过redo log来进行事务的重播。 + +## Doublewrite Buffer相关参数 + +以下是一些与Doublewrite Buffer相关的参数及其含义: + +- `innodb_doublewrite`: 这个参数用于启用或禁用双写缓冲区。设置为1时启用,设置为0时禁用, 默认值为1。 +- `innodb_doublewrite_files`: 这个参数定义了多少个双写文件被使用。默认值为2,有效范围从2到127。 +- `innodb_doublewrite_dir`: 这个参数指定了存储双写缓冲文件的目录的路径。默认为空字符串,表示将文件存储在数据目录中。 +- `innodb_doublewrite_batch_size`: 这个参数定义了每次批处理操作写入的字节数。默认值为0,表示InnoDB会选择最佳的批量大小。 +- `innodb_doublewrite_pages`:这个参数定义了每个双写文件包含多少页面。默认值为128。 + +## 总结 + +Doublewrite Buffer是InnoDB的一个重要特性,用于保证MySQL数据的可靠性和一致性。 + +它的实现原理是通过将要写入磁盘的数据先写入到Doublewrite Buffer中的内存缓存区域,然后再写入到磁盘的两个不同位置,来避免由于磁盘损坏等因素导致数据丢失或不一致的问题。 + +总的来说,Doublewrite Buffer对于改善数据库性能和数据完整性起着至关重要的作用。尽管其引入了一些开销,但在大多数情况下,这些成本都被其提供的安全性和可靠性所抵消。 + diff --git "a/docs/md/mysql/\350\257\246\350\247\243MySQL\345\210\206\345\214\272.md" "b/docs/md/mysql/\350\257\246\350\247\243MySQL\345\210\206\345\214\272.md" deleted file mode 100644 index b33313f..0000000 --- "a/docs/md/mysql/\350\257\246\350\247\243MySQL\345\210\206\345\214\272.md" +++ /dev/null @@ -1,337 +0,0 @@ -[TOC] - -## 分区表介绍 - -MySQL 数据库中的数据是以文件的形势存在磁盘上的,默认放在 `/var/lib/mysql/` 目录下面,我们可以通过 `show variables like '%datadir%';` 命令来查看: - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpVuOk6bKOHAibN3uwX3ZjqrWTPYp315trpeSNCPQNGVq80vSr4cDXDdw/640?wx_fmt=png) - -我们进入到这个目录下,就可以看到我们定义的所有数据库了,一个数据库就是一个文件夹,一个库中,有其对应的表的信息,如下: - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpp2Z7C3yqD1Zcnib0kTtvP8ZLjPTrpkib5yzicsKIYBPMC2pibWsp11r1FA/640?wx_fmt=png) - -在 MySQL 中,如果存储引擎是 MyISAM,那么在 data 目录下会看到 3 类文件:`.frm`、`.myi`、`.myd`,如下: - -1. `*.frm`:这个是表定义,是描述表结构的文件。 -2. `*.myd`:这个是数据信息文件,是表的数据文件。 -3. `*.myi`:这个是索引信息文件。 - -如果存储引擎是 `InnoDB`, 那么在 data 目录下会看到两类文件:`.frm`、`.ibd`,如下: - -1. `*.frm`:表结构文件。 -2. `*.ibd`:表数据和索引的文件。 - -无论是哪种存储引擎,只要一张表的数据量过大,就会导致 `*.myd`、`*.myi` 以及 `*.ibd` 文件过大,数据的查找就会变的很慢。 - -为了解决这个问题,我们可以利用 MySQL 的分区功能,在物理上将这一张表对应的文件,分割成许多小块,如此,当我们查找一条数据时,就不用在某一个文件中进行整个遍历了,我们只需要知道这条数据位于哪一个数据块,然后在那一个数据块上查找就行了;另一方面,如果一张表的数据量太大,可能一个磁盘放不下,这个时候,通过表分区我们就可以把数据分配到不同的磁盘里面去。 - -**通俗地讲表分区是将一大表,根据条件分割成若干个小表**。 - -如:某用户表的记录超过了600万条,那么就可以根据入库日期将表分区,也可以根据所在地将表分区。当然也可根据其他的条件分区。 - -MySQL 从 5.1 开始添加了对分区的支持,分区的过程是将一个表或索引分解为多个更小、更可管理的部分。对于开发者而言,分区后的表使用方式和不分区基本上还是一模一样,只不过在物理存储上,原本该表只有一个数据文件,现在变成了多个,每个分区都是独立的对象,可以独自处理,也可以作为一个更大对象的一部分进行处理。 - -需要注意的是,分区功能并不是在存储引擎层完成的,常见的存储引擎如 `InnoDB`、`MyISAM`、`NDB` 等都支持分区。**但并不是所有的存储引擎都支持,如 `CSV`、`FEDORATED`、`MERGE` 等就不支持分区,因此在使用此分区功能前,应该对选择的存储引擎对分区的支持有所了解**。 - -### 表分区的优缺点和限制 - -MySQL分区有优点也有一些缺点,如下: - -优点: - -1. 查询性能提升:分区可以将大表划分为更小的部分,查询时只需扫描特定的分区,而不是整个表,从而提高查询性能。特别是在处理大量数据或高并发负载时,分区可以显著减少查询的响应时间。 -2. 管理和维护的简化:使用分区可以更轻松地管理和维护数据。可以针对特定的分区执行维护操作,如备份、恢复、优化和数据清理,而不必处理整个表。这简化了维护任务并减少了操作的复杂性。 -3. 数据管理灵活性:通过分区,可以根据业务需求轻松地添加或删除分区,而无需影响整个表。这使得数据的增长和变化更具弹性,可以根据需求进行动态调整。 -4. 改善数据安全性和可用性:可以将不同分区的数据分布在不同的存储设备上,从而提高数据的安全性和可用性。例如,可以将热数据放在高速存储设备上,而将冷数据放在廉价存储设备上,以实现更高的性能和成本效益。 - -缺点: - -1. 复杂性增加:分区引入了额外的复杂性,包括分区策略的选择、表结构的设计和维护、查询逻辑的调整等。正确地设置和管理分区需要一定的经验和专业知识。 -2. 索引效率下降:对于某些查询,特别是涉及跨分区的查询,可能会导致索引效率下降。由于查询需要在多个分区之间进行扫描,可能无法充分利用索引优势,从而影响查询性能。 -3. 存储空间需求增加:使用分区会导致一定程度的存储空间浪费。每个分区都需要占用一定的存储空间,包括分区元数据和一些额外的开销。因此,对于分区键的选择和分区粒度的设置需要权衡存储空间和性能之间的关系。 -4. 功能限制:在某些情况下,分区可能会限制某些MySQL的功能和特性的使用。例如,某些类型的索引可能无法在分区表上使用,或者某些DDL操作可能需要更复杂的处理。 - -在考虑使用分区时,需要综合考虑业务需求、查询模式、数据规模和硬件资源等因素,并权衡分区带来的优势和缺点。对于特定的应用和数据场景,分区可能是一个有效的解决方案,但并不适用于所有情况。 - -同时分区表也存在一些限制,如下: - -限制: - -- 在mysql5.6.7之前的版本,一个表最多有1024个分区;从5.6.7开始,一个表最多可以有8192个分区。 -- 分区表无法使用外键约束。 -- NULL值会使分区过滤无效。 -- 所有分区必须使用相同的存储引擎。 - -## 分区适用场景 - -分区表在以下情况下可以发挥其优势,适用于以下几种使用场景: - -1. 大型表处理:当面对非常大的表时,分区表可以提高查询性能。通过将表分割为更小的分区,查询操作只需要处理特定的分区,从而减少扫描的数据量,提高查询效率。这在处理日志数据、历史数据或其他需要大量存储和高性能查询的场景中非常有用。 -2. 时间范围查询:对于按时间排序的数据,分区表可以按照时间范围进行分区,每个分区包含特定时间段内的数据。这使得按时间范围进行查询变得更高效,例如在某个时间段内检索数据、生成报表或执行时间段的聚合操作。 -3. 数据归档和数据保留:分区表可用于数据归档和数据保留的需求。旧数据可以归档到单独的分区中,并将其存储在低成本的存储介质上。同时,可以保留较新数据在高性能的存储介质上,以便快速查询和操作。 -4. 并行查询和负载均衡:通过哈希分区或键分区,可以将数据均匀地分布在多个分区中,从而实现并行查询和负载均衡。查询可以同时在多个分区上进行,并在最终合并结果,提高查询性能和系统吞吐量。 -5. 数据删除和维护:使用分区表,可以更轻松地删除或清理不再需要的数据。通过删除整个分区,可以更快速地删除大量数据,而不会影响整个表的操作。此外,可以针对特定分区执行维护任务,如重新构建索引、备份和优化,以减少对整个表的影响。 - -分区表并非适用于所有情况。在选择使用分区表时,需要综合考虑数据量、查询模式、存储资源和硬件能力等因素,并评估分区对性能和管理的影响。 - -## 分区方式 - -分区有2种方式,水平切分和垂直切分。**MySQL 数据库支持的分区类型为水平分区,它不支持垂直分区**。 - -此外,MySQL数据库的分区是局部分区索引,一个分区中既存放了数据又存放了索引。而全局分区是指,数据存放在各个分区中,但是所有数据的索引放在一个对象中。**目前,MySQL数据库还不支持全局分区**。 - -## 分区策略 - -### RANGE分区 - -RANGE分区是MySQL中的一种分区策略,根据某一列的范围值将数据分布到不同的分区。每个分区包含特定的范围。下面是RANGE分区的定义方式、特点以及代码示例。 - -定义方式: - -1. 指定分区键:选择作为分区依据的列作为分区键,通常是日期、数值等具有范围特性的列。 -2. 分区函数:通过`PARTITION BY RANGE`指定使用RANGE分区策略。 -3. 定义分区范围:使用`VALUES LESS THAN`子句定义每个分区的范围。 - -RANGE分区的特点: - -1. 范围划分:根据指定列的范围进行分区,适用于需要按范围进行查询和管理的情况。 -2. 灵活的范围定义:可以定义任意数量的分区,并且每个分区可以具有不同的范围。 -3. 高效查询:根据查询条件的范围,MySQL能够快速定位到特定的分区,提高查询效率。 -4. 动态管理:可以根据业务需求轻松添加或删除分区,适应数据增长或变更的需求。 - -以下是一个使用RANGE分区的代码示例: - -```sql -CREATE TABLE sales ( - id INT, - sales_date DATE, - amount DECIMAL(10, 2) -) -PARTITION BY RANGE (YEAR(sales_date)) ( - PARTITION p1 VALUES LESS THAN (2020), - PARTITION p2 VALUES LESS THAN (2021), - PARTITION p3 VALUES LESS THAN (2022), - PARTITION p4 VALUES LESS THAN MAXVALUE -); -``` - -在上述示例中,我们创建了名为`sales`的表,使用RANGE分区策略。根据`sales_date`列的年份范围将数据分布到不同的分区。 - -- `PARTITION BY RANGE (YEAR(sales_date))`:指定使用RANGE分区,基于`sales_date`列的年份进行分区。 -- `PARTITION p1 VALUES LESS THAN (2020)`:定义名为`p1`的分区,包含年份小于2020的数据。 -- `PARTITION p2 VALUES LESS THAN (2021)`:定义名为`p2`的分区,包含年份小于2021的数据。 -- `PARTITION p3 VALUES LESS THAN (2022)`:定义名为`p3`的分区,包含年份小于2022的数据。 -- `PARTITION p4 VALUES LESS THAN MAXVALUE`:定义名为`p4`的分区,包含超出定义范围的数据。 - -RANGE分区允许根据列值的范围将数据分散到不同的分区中,适用于按范围进行查询和管理的情况。它提供了更灵活的数据管理和查询效率的提升。 - -### LIST分区 - -- LIST分区是根据某一列的离散值将数据分布到不同的分区。每个分区包含特定的列值列表。下面是LIST分区的定义方式、特点以及代码示例。 - - 定义方式: - - 1. 指定分区键:选择作为分区依据的列作为分区键,通常是具有离散值的列,如地区、类别等。 - 2. 分区函数:通过`PARTITION BY LIST`指定使用LIST分区策略。 - 3. 定义分区列表:使用`VALUES IN`子句定义每个分区包含的列值列表。 - - LIST分区的特点: - - 1. 列值离散:根据指定列的具体取值进行分区,适用于具有离散值的列。 - 2. 灵活的分区定义:可以定义任意数量的分区,并且每个分区可以具有不同的列值列表。 - 3. 高效查询:根据查询条件的列值直接定位到特定分区,提高查询效率。 - 4. 动态管理:可以根据业务需求轻松添加或删除分区,适应数据增长或变更的需求。 - - 以下是一个使用LIST分区的代码示例: - - ```sql - CREATE TABLE users ( - id INT, - username VARCHAR(50), - region VARCHAR(50) - ) - PARTITION BY LIST (region) ( - PARTITION p_east VALUES IN ('New York', 'Boston'), - PARTITION p_west VALUES IN ('Los Angeles', 'San Francisco'), - PARTITION p_other VALUES IN (DEFAULT) - ); - ``` - - 在上述示例中,我们创建了名为`users`的表,使用LIST分区策略。根据`region`列的具体取值将数据分布到不同的分区。 - - - `PARTITION BY LIST (region)`:指定使用LIST分区,基于`region`列的值进行分区。 - - `PARTITION p_east VALUES IN ('New York', 'Boston')`:定义名为`p_east`的分区,包含值为'New York'和'Boston'的`region`列的数据。 - - `PARTITION p_west VALUES IN ('Los Angeles', 'San Francisco')`:定义名为`p_west`的分区,包含值为'Los Angeles'和'San Francisco'的`region`列的数据。 - - `PARTITION p_other VALUES IN (DEFAULT)`:定义名为`p_other`的分区,包含其他`region`列值的数据。 - -### HASH分区 - -- HASH分区是使用哈希算法将数据均匀地分布到多个分区中。下面是HASH分区的定义方式、特点以及代码示例。 - - 定义方式: - - 1. 指定分区键:选择作为分区依据的列作为分区键。 - 2. 分区函数:通过`PARTITION BY HASH`指定使用HASH分区策略。 - 3. 定义分区数量:使用`PARTITIONS`关键字指定分区的数量。 - - HASH分区的特点: - - 1. 数据均匀分布:HASH分区使用哈希算法将数据均匀地分布到不同的分区中,确保数据在各个分区之间平衡。 - 2. 并行查询性能:通过将数据分散到多个分区,HASH分区可以提高并行查询的性能,多个查询可以同时在不同分区上执行。 - 3. 简化管理:HASH分区使得数据管理更加灵活,可以轻松地添加或删除分区,以适应数据增长或变更的需求。 - - 以下是一个使用HASH分区的代码示例: - - ```sql - CREATE TABLE sensor_data ( - id INT, - sensor_name VARCHAR(50), - value INT - ) - PARTITION BY HASH (id) - PARTITIONS 4; - ``` - - 在上述示例中,我们创建了名为`sensor_data`的表,使用HASH分区策略。根据`id`列的哈希值将数据分布到4个分区中。 - - - `PARTITION BY HASH (id)`:指定使用HASH分区,基于`id`列的哈希值进行分区。 - - `PARTITIONS 4`:指定创建4个分区。 - -### KEY分区 - -KEY分区是根据某一列的哈希值将数据分布到不同的分区。不同于HASH分区,KEY分区使用的是列值的哈希值而不是哈希函数。下面是KEY分区的定义方式、特点以及代码示例。 - -定义方式: - -1. 指定分区键:选择作为分区依据的列作为分区键。 -2. 分区函数:通过`PARTITION BY KEY`指定使用KEY分区策略。 -3. 定义分区数量:使用`PARTITIONS`关键字指定分区的数量。 - -KEY分区的特点: - -1. 哈希分布:KEY分区使用列值的哈希值将数据分布到不同的分区中,与哈希函数不同,它使用的是列值的哈希值。 -2. 高度自定义:KEY分区允许根据业务需求自定义分区逻辑,可以灵活地选择分区键和分区数量。 -3. 并行查询性能:通过将数据分散到多个分区,KEY分区可以提高并行查询的性能,多个查询可以同时在不同分区上执行。 -4. 简化管理:KEY分区使得数据管理更加灵活,可以轻松地添加或删除分区,以适应数据增长或变更的需求。 - -以下是一个使用KEY分区的代码示例: - -```sql -CREATE TABLE orders ( - order_id INT, - customer_id INT, - order_date DATE -) -PARTITION BY KEY (customer_id) -PARTITIONS 5; -``` - -在上述示例中,我们创建了名为`orders`的表,使用KEY分区策略。根据`customer_id`列的哈希值将数据分布到5个分区中。 - -- `PARTITION BY KEY (customer_id)`:指定使用KEY分区,基于`customer_id`列的哈希值进行分区。 -- `PARTITIONS 5`:指定创建5个分区。 - -### COLUMNS 分区 - -MySQL在5.5版本引入了COLUMNS分区类型,其中包括RANGE COLUMNS分区和LIST COLUMNS分区。以下是对这两种COLUMNS分区的详细说明: - -1. RANGE COLUMNS分区: RANGE COLUMNS分区是根据列的范围值将数据分布到不同的分区的分区策略。它类似于RANGE分区,但是根据多个列的范围值进行分区,而不是只根据一个列。这使得范围的定义更加灵活,可以基于多个列的组合来进行分区。 - - 下面是一个RANGE COLUMNS分区的代码示例: - - ```sql - CREATE TABLE sales ( - id INT, - sales_date DATE, - region VARCHAR(50), - amount DECIMAL(10,2) - ) - PARTITION BY RANGE COLUMNS(region, sales_date) ( - PARTITION p1 VALUES LESS THAN ('East', '2022-01-01'), - PARTITION p2 VALUES LESS THAN ('West', '2022-01-01'), - PARTITION p3 VALUES LESS THAN ('East', MAXVALUE), - PARTITION p4 VALUES LESS THAN ('West', MAXVALUE) - ); - ``` - - 在上述示例中,我们创建了一个名为sales的表,并使用RANGE COLUMNS分区策略。根据region和sales_date两列的范围将数据分布到不同的分区。每个分区根据这两列的范围值进行划分。 - -2. LIST COLUMNS分区: LIST COLUMNS分区是根据列的离散值将数据分布到不同的分区的分区策略。它类似于LIST分区,但是根据多个列的离散值进行分区,而不是只根据一个列。这使得离散值的定义更加灵活,可以基于多个列的组合来进行分区。 - - 下面是一个LIST COLUMNS分区的代码示例: - - ```sql - CREATE TABLE users ( - id INT, - username VARCHAR(50), - region VARCHAR(50), - category VARCHAR(50) - ) - PARTITION BY LIST COLUMNS(region, category) ( - PARTITION p_east VALUES IN (('New York', 'A'), ('Boston', 'B')), - PARTITION p_west VALUES IN (('Los Angeles', 'C'), ('San Francisco', 'D')), - PARTITION p_other VALUES IN (DEFAULT) - ); - ``` - - 在上述示例中,我们创建了一个名为users的表,并使用LIST COLUMNS分区策略。根据region和category两列的离散值将数据分布到不同的分区。每个分区根据这两列的离散值进行划分。 - -## 常见分区命令 - -### 是否支持分区 - -在 MySQL5.6.1 之前可以通过命令 `show variables like '%have_partitioning%'` 来查看 MySQL 是否支持分区。如果 `have_partitioning` 的值为 YES,则表示支持分区。 - -从 MySQL5.6.1 开始,`have_partitioning` 参数已经被去掉了,而是用 `SHOW PLUGINS` 来代替。若有 partition 行且 STATUS 列的值为 ACTIVE,则表示支持分区,如下所示:![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpQVbcOTn7rT6sYETeibtJGS5ZU0uu4NIuIIu5rRuTfpmD9Fp0ytVgopw/640?wx_fmt=png) - -### 创建分区表 - -```sql -CREATE TABLE sales ( - id INT, - sales_date DATE, - amount DECIMAL(10,2) -) -PARTITION BY RANGE (YEAR(sales_date)) ( - PARTITION p1 VALUES LESS THAN (2020), - PARTITION p2 VALUES LESS THAN (2021), - PARTITION p3 VALUES LESS THAN (2022), - PARTITION p4 VALUES LESS THAN MAXVALUE -); -``` - -### 向分区表添加新的分区 - -```sql -ALTER TABLE sales ADD PARTITION ( - PARTITION p5 VALUES LESS THAN (2023) -); -``` - -### 删除指定的分区 - -```sql -ALTER TABLE sales DROP PARTITION p3; -``` - -### 重新组织分区 - -```sql -ALTER TABLE sales REORGANIZE PARTITION p1, p2, p5 INTO ( - PARTITION p1 VALUES LESS THAN (2020), - PARTITION p2 VALUES LESS THAN (2022), - PARTITION p3 VALUES LESS THAN MAXVALUE -); -``` - -### 合并相邻的分区: - -```sql -ALTER TABLE sales COALESCE PARTITION p1, p2; -``` - -### 分析指定分区的统计信息: - -```sql -ALTER TABLE sales ANALYZE PARTITION p1; -``` \ No newline at end of file diff --git "a/docs/md/redis/Redis\344\270\255\347\232\204Big Key\351\227\256\351\242\230\357\274\232\346\216\222\346\237\245\344\270\216\350\247\243\345\206\263\346\200\235\350\267\257.md" "b/docs/md/redis/Redis\344\270\255\347\232\204Big Key\351\227\256\351\242\230\357\274\232\346\216\222\346\237\245\344\270\216\350\247\243\345\206\263\346\200\235\350\267\257.md" new file mode 100644 index 0000000..cf0640b --- /dev/null +++ "b/docs/md/redis/Redis\344\270\255\347\232\204Big Key\351\227\256\351\242\230\357\274\232\346\216\222\346\237\245\344\270\216\350\247\243\345\206\263\346\200\235\350\267\257.md" @@ -0,0 +1,240 @@ +在处理大型数据时,Redis 作为我们的非关系型数据库经常出现在解决方案之中。然而,在使用 Redis 的过程中,有一些问题可能会悄无声息地影响我们的系统性能,其中最具代表性的就是 Big Key 问题。 + +这个问题往往被低估,Big Key会对 Redis 的效率和整体性能产生重大影响。在本文中,我们将深入探索 Big Key 问题的源头,讨论它如何影响系统性能,并提供相应的解决策略。通过了解和解决 Big Key 问题,我们可以更有效地利用 Redis,优化我们的系统并提高性能。 + +## Big Key问题介绍 + +在Redis中,每个key都有一个对应的value,如果某个key的value过大,就会导致Redis的性能下降或者崩溃。 + +**因为Redis需要将大key全部加载到内存中,这会占用大量的内存空间,会降低Redis的响应速度,这个问题被称为Big Key问题。** + +不要小看这个问题,它可是能让你的Redis瞬间变成“乌龟”,由于Redis单线程的特性,操作Big Key的通常比较耗时,也就意味着Big Key阻塞Redis的可能性很大,这样会造成客户端阻塞或者引起故障切换,有可能导致“慢查询”或其他连锁反应。 + +一般而言,下面这两种情况可以被称为Big Key: + +- String 类型的 key 对应的value超过 10 MB。 +- list、set、hash、zset等集合类型,集合元素个数超过 5000个。 + +> 以上对Big Key的判断标准并不唯一,只是一个大体的标准。在实际业务开发中,对Big Key的判断是需要根据具体的使用场景做不同的判断。比如操作某个 key 导致请求响应时间变慢,那么这个 key 就可以判定成 Big Key。 + +**在Redis中,Big Key通常是由以下几种原因导致的:** + +- 对象序列化后的大小过大。 +- 存储大量数据的容器,如set、list等。 +- 大型数据结构,如bitmap、hyperloglog等。 + +如果不及时处理这些大key,它们会逐渐消耗Redis服务器的内存资源,最终导致Redis崩溃。 + +## Big Key问题排查 + +当出现Redis性能急剧下降的情况时,很可能是由于存在大key导致的。在排除大key问题时,可以考虑采取以下几种方法: + +### BIGKEYS命令 + +Redis自带的 `BIGKEYS` 命令可以查询当前Redis中所有key的信息,对整个数据库中的键值对大小情况进行统计分析。 + +比如说,统计每种数据类型的键值对个数以及平均大小。 + +此外,这个命令执行后,会输出每种数据类型中最大的 big key 的信息,对于 String 类型来说,会输出最大 big key 的字节长度,对于集合类型来说,会输出最大 big key 的元素个数。 + +**`BIGKEYS`命令会扫描整个数据库,这个命令本身会阻塞Redis**,找出所有的大键,并将其以一个列表的形式返回给客户端。 + +命令格式如下: + +```shell +$ redis-cli --bigkeys +``` + +返回示例如下: + +``` +# Scanning the entire keyspace to find biggest keys as well as +# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec +# per 100 SCAN commands (not usually needed). + +[00.00%] Biggest string found so far 'a' with 3 bytes +[05.14%] Biggest list found so far 'b' with 100004 items +[35.77%] Biggest string found so far 'c' with 6 bytes +[73.91%] Biggest hash found so far 'd' with 3 fields + +-------- summary ------- + +Sampled 506 keys in the keyspace! +Total key length in bytes is 3452 (avg len 6.82) + +Biggest string found 'c' has 6 bytes +Biggest list found 'b' has 100004 items +Biggest hash found 'd' has 3 fields + +504 strings with 1403 bytes (99.60% of keys, avg size 2.78) +1 lists with 100004 items (00.20% of keys, avg size 100004.00) +0 sets with 0 members (00.00% of keys, avg size 0.00) +1 hashs with 3 fields (00.20% of keys, avg size 3.00) +0 zsets with 0 members (00.00% of keys, avg size 0.00) +``` + +解读下返回结果,从这个结果中可以看出: + +- Redis中样本了506个键。 +- 这506个键总共占用了3452字节,平均每个键占用6.82字节。 +- 最大的字符串键是'c',值有6字节。 +- 最大的列表键是'b',有100004个元素。 +- 最大的哈希键是'd',有3个字段。 +- 有504个字符串键,总共1403字节,占所有键的99.60%,平均每个字符串键大小为2.78字节。 +- 有1个列表键,包含100004个元素,占所有键的0.20%,平均每个列表键大小为100004个元素。 +- 没有集合(set)键。 +- 有1个哈希键,包含3个字段,占所有键的0.20%,平均每个哈希键大小为3个字段。 +- 没有有序集合(zset)键。 + +这些信息可以帮助你理解Redis数据库的使用状态,以便进行相应的优化或调整。 + +需要注意的是,由于`BIGKEYS`命令需要扫描整个数据库,所以它可能会对Redis实例造成一定的负担。**在执行这个命令之前,请确保你的Redis实例有足够的资源来处理它,建议在从节点执行**。 + +#### Debug Object + +如果我们找到了Big Key,就需要对其进行进一步的分析。我们可以使用命令`debug object key`查看某个key的详细信息,包括该key的value大小等。这时候你就可以“窥探”Redis的内部,看看到底是哪个key太大导致的问题。 + +Debug Object 命令是一个调试命令,当 key 存在时,返回有关信息。 当 key 不存在时,返回一个错误。 + +```shell +redis 127.0.0.1:6379> DEBUG OBJECT key +Value at:0xb6838d20 refcount:1 encoding:raw serializedlength:9 lru:283790 lru_seconds_idle:150 + +redis 127.0.0.1:6379> DEBUG OBJECT key +(error) ERR no such key +``` + +第一次运行命令时,返回了 key 对应的具体信息。这些值的意思如下: + +- `Value at:0xb6838d20`:key 所在的内存地址。 +- `refcount:1`:引用计数,表示该对象被引用的次数。 +- `encoding:raw`:编码类型,这里是 raw ,表示这个字符串对象的编码类型。 +- `serializedlength:9`:序列化后的长度。 +- `lru:283790`:LRU (Least Recently Used)信息,即最近最少使用算法的相关信息,在内存淘汰策略中会用到。 +- `lru_seconds_idle:150`:该 key 已空闲多久(单位为秒),也就是自从最后一次访问已经过去多少秒。 + +第二次运行命令时,返回了 `(error) ERR no such key`,说明在 Redis 中没有找到名为 'key' 的键。 + +#### memory usage + +在Redis4.0之前,只能通过`DEBUG OBJECT`命令估算key的内存使用(字段serializedlength),但DEBUG OBJECT命令是存在误差的。 + +4.0版本及以上,更推荐使用`memory usag`命令。 + +memory usage命令使用非常简单,格式为:**memory usage key**。 + +如果当前key存在,则返回key的value实际使用内存估算值,如果key不存在,则返回nil。 + +```shell +127.0.0.1:6379> set k1 value1 +OK +127.0.0.1:6379> memory usage k1 //这里k1 value占用57字节内存 +(integer) 57 +127.0.0.1:6379> memory usage aaa // aaa键不存在,返回nil. +(nil) +``` + +对于除String类型之外的类型,memory usage命令采用抽样的方式,默认抽样5个元素,所以计算是近似值,我们也可以手动指定抽样的个数。 + +示例说明:生成一个100w个字段的hash键:hkey,每字段的value长度是从1~1024字节的随机值。 + +```shell +127.0.0.1:6379> hlen hkey // hkey有100w个字段,每个字段的value长度介于1~1024个字节 +(integer) 1000000 +127.0.0.1:6379> MEMORY usage hkey //默认SAMPLES为5,分析hkey键内存占用521588753字节 +(integer) 521588753 +127.0.0.1:6379> MEMORY usage hkey SAMPLES 1000 //指定SAMPLES为1000,分析hkey键内存占用617977753字节 +(integer) 617977753 +127.0.0.1:6379> MEMORY usage hkey SAMPLES 10000 //指定SAMPLES为10000,分析hkey键内存占用624950853字节 +(integer) 624950853 +``` + +要想获取key较精确的内存值,就指定更大抽样个数。但是抽样个数越大,占用cpu时间分片就越大。 + +### redis-rdb-tools + +redis-rdb-tools 是一个 python 的解析 rdb 文件的工具,在分析内存的时候,我们主要用它生成内存快照。可以把 rdb 快照文件生成 CSV 或 JSON 文件,也可以导入到 MySQL 生成报表来分析。 + +使用 PYPI 安装 + +```shell +pip install rdbtools +``` + +生成内存快照 + +```shell +rdb -c memory dump.rdb > memory.csv +``` + +在生成的 CSV 文件中主要有以下几列: + +- `database` key在Redis的db +- `type` key类型 +- `key` key值 +- `size_in_bytes` key的内存大小 +- `encoding` value的存储编码形式 +- `num_elements` key中的value的个数 +- `len_largest_element` key中的value的长度 + +可以在MySQL中新建表然后导入进行分析,然后可以直接通过SQL语句进行查询分析。 + +```sql +CREATE TABLE `memory` ( + `database` int(128) DEFAULT NULL, + `type` varchar(128) DEFAULT NULL, + `KEY` varchar(128), + `size_in_bytes` bigint(20) DEFAULT NULL, + `encoding` varchar(128) DEFAULT NULL, + `num_elements` bigint(20) DEFAULT NULL, + `len_largest_element` varchar(128) DEFAULT NULL, + PRIMARY KEY (`KEY`) + ); +``` + +例如,查询内存占用最高的3个 key: + +```sql +mysql> SELECT * FROM memory ORDER BY size_in_bytes DESC LIMIT 3; ++----------+------+-----+---------------+-----------+--------------+---------------------+ +| database | type | key | size_in_bytes | encoding | num_elements | len_largest_element | ++----------+------+-----+---------------+-----------+--------------+---------------------+ +| 0 | set | k1 | 624550 | hashtable | 50000 | 10 | +| 0 | set | k2 | 420191 | hashtable | 46000 | 10 | +| 0 | set | k3 | 325465 | hashtable | 38000 | 10 | ++----------+------+-----+---------------+-----------+--------------+---------------------+ +3 rows in set (0.12 sec) +``` + + +## Big Key问题解决思路 + +当发现存在Big Key问题时,我们需要及时采取措施来解决这个问题。下面列出几种可行的解决思路: + +### 分割大key + +将Big Key拆分成多个小key。这个方法比较简单,但是需要修改应用程序的代码。就像是把一个大蛋糕切成小蛋糕一样,有点费力,但是可以解决问题。 + +或者尝试将Big Key转换成Redis的其他数据结构。例如,将Big Key转换成Hash,List或者Set等数据结构。 + +### 对象压缩 + +如果大key的产生原因主要是由于对象序列化后的体积过大,我们可以考虑使用压缩算法来减小对象的大小。需要在客户端使用一些压缩算法对数据进行压缩和解压缩操作,例如LZF、Snappy等。 + +### 直接删除 + +如果你使用的是Redis 4.0+的版本,可以直接使用 `unlink`命令去异步删除大key。4.0以下的版本 可以考虑使用 `scan`命令,分批次删除。 + +无论采用哪种方法,日常使用中都需要注意以下几点: + +1. 避免使用过大的value。如果需要存储大量的数据,可以将其拆分成多个小的value。就像是吃饭一样,一口一口的吃,不要贪多嚼不烂。 +2. 避免使用不必要的数据结构。例如,如果只需要存储一个字符串,就不要使用Hash或者List等数据结构。 +3. 定期清理过期的key。如果Redis中存在大量的过期key,就会导致Redis的性能下降。就像是家里的垃圾,需要定期清理。 + +4. 对象压缩。 + +最后,结束本文时,我们要明确的是,Redis Big Key问题是所有使用Redis作为数据存储方案的开发者都需要密切关注的重要话题。大Key可能会对Redis的性能产生严重影响,或者导致意外的内存问题。 + +因此,开发者应该充分利用现有的工具和策略来检测和避免Big Key。在使用Redis时,需要注意避免使用过大的value和不必要的数据结构,以及定期清理过期的key。 + +另外,我们还应持续探索更高效、更可靠的解决方案,来优化我们的Redis实例,使其更稳定地为我们的应用提供服务。最后,不断学习和实践才是提高我们对Redis使用的理解,并准确处理Redis Big Key问题的最佳方式。 diff --git "a/docs/md/redis/Redis\344\270\255\347\232\204BigKey\351\227\256\351\242\230\357\274\232\346\216\222\346\237\245\344\270\216\350\247\243\345\206\263\346\200\235\350\267\257.md" "b/docs/md/redis/Redis\344\270\255\347\232\204BigKey\351\227\256\351\242\230\357\274\232\346\216\222\346\237\245\344\270\216\350\247\243\345\206\263\346\200\235\350\267\257.md" deleted file mode 100644 index 681a0fc..0000000 --- "a/docs/md/redis/Redis\344\270\255\347\232\204BigKey\351\227\256\351\242\230\357\274\232\346\216\222\346\237\245\344\270\216\350\247\243\345\206\263\346\200\235\350\267\257.md" +++ /dev/null @@ -1,213 +0,0 @@ -[TOC] - -## 摘要 - -Redis是一款性能强劲的内存数据库,但是在使用过程中,我们可能会遇到**Big Key**问题,这个问题就是Redis中某个key的value过大,所以**Big Key问题本质是Big Value问题**,导致Redis的性能下降或者崩溃。本文将向大家介绍如何排查和解决这个问题。 - -## Big Key问题介绍 - -在Redis中,每个key都有一个对应的value,如果某个key的value过大,就会导致Redis的性能下降或者崩溃,比玄学更玄学,**因为Redis需要将大key全部加载到内存中,这会占用大量的内存空间,会降低Redis的响应速度,这个问题被称为Big Key问题**。不要小看这个问题,它可是能让你的Redis瞬间变成“乌龟”,由于Redis单线程的特性,操作Big Key的通常比较耗时,也就意味着阻塞Redis可能性越大,这样会造成客户端阻塞或者引起故障切换,有可能导致“慢查询”。 - -一般而言,下面这两种情况被称为大 key: - -- String 类型的 key 对应的value超过 10 MB。 -- list、set、hash、zset等集合类型,集合元素个数超过 5000个。 - -> 以上对 Big Key 的判断标准并不是唯一,只是一个大体的标准。在实际业务开发中,对 Big Key的判断是需要根据具体的使用场景做不同的判断。比如操作某个 key 导致请求响应时间变慢,那么这个 key 就可以判定成 Big Key。 - -**在Redis中,大key通常是由以下几种原因导致的**: - -- 对象序列化后的大小过大 -- 存储大量数据的容器,如set、list等 -- 大型数据结构,如bitmap、hyperloglog等 - -如果不及时处理这些大key,它们会逐渐消耗Redis服务器的内存资源,最终导致Redis崩溃。 - -## Big Key问题排查 - -当出现Redis性能急剧下降的情况时,很可能是由于存在大key导致的。在排除大key问题时,可以考虑采取以下几种方法: - -### 使用BIGKEYS命令 - -Redis自带的 BIGKEYS 命令可以查询当前Redis中所有key的信息,对整个数据库中的键值对大小情况进行统计分析,比如说,统计每种数据类型的键值对个数以及平均大小。此外,这个命令执行后,会输出每种数据类型中最大的 bigkey 的信息,对于 String 类型来说,会输出最大 bigkey 的字节长度,对于集合类型来说,会输出最大 bigkey 的元素个数 - -`BIGKEYS`命令会扫描整个数据库,这个命令本身会阻塞Redis,找出所有的大键,并将其以一个列表的形式返回给客户端。 - -命令格式如下: - -```shell -$ redis-cli --bigkeys -``` - -返回示例如下: - -``` -# Scanning the entire keyspace to find biggest keys as well as -# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec -# per 100 SCAN commands (not usually needed). - -[00.00%] Biggest string found so far 'a' with 3 bytes -[05.14%] Biggest list found so far 'b' with 100004 items -[35.77%] Biggest string found so far 'c' with 6 bytes -[73.91%] Biggest hash found so far 'd' with 3 fields - --------- summary ------- - -Sampled 506 keys in the keyspace! -Total key length in bytes is 3452 (avg len 6.82) - -Biggest string found 'c' has 6 bytes -Biggest list found 'b' has 100004 items -Biggest hash found 'd' has 3 fields - -504 strings with 1403 bytes (99.60% of keys, avg size 2.78) -1 lists with 100004 items (00.20% of keys, avg size 100004.00) -0 sets with 0 members (00.00% of keys, avg size 0.00) -1 hashs with 3 fields (00.20% of keys, avg size 3.00) -0 zsets with 0 members (00.00% of keys, avg size 0.00) -``` - -需要注意的是,由于`BIGKEYS`命令需要扫描整个数据库,所以它可能会对Redis实例造成一定的负担。**在执行这个命令之前,请确保您的Redis实例有足够的资源来处理它,建议在从节点执行**。 - -#### Debug Object - -如果我们找到了Big Key,就需要对其进行进一步的分析。我们可以使用命令`debug object key`查看某个key的详细信息,包括该key的value大小等。这时候你就可以“窥探”Redis的内部,看看到底是哪个key太大了。 - -Debug Object 命令是一个调试命令,当 key 存在时,返回有关信息。 当 key 不存在时,返回一个错误。 - -``` -redis 127.0.0.1:6379> DEBUG OBJECT key -Value at:0xb6838d20 refcount:1 encoding:raw serializedlength:9 lru:283790 lru_seconds_idle:150 - -redis 127.0.0.1:6379> DEBUG OBJECT key -(error) ERR no such key -``` - -serializedlength表示key对应的value序列化之后的字节数 - -#### memory usage - -在Redis4.0之前,只能通过DEBUG OBJECT命令估算key的内存使用(字段serializedlength),但DEBUG OBJECT命令是有误差的。 - -4.0版本及以上,我们可以使用memory usag命令。 - -memory usage命令使用非常简单,直接按memory usage key名字;如果当前key存在,则返回key的value实际使用内存估算值;如果key不存在,则返回nil。 - -```shell -127.0.0.1:6379> set k1 value1 -OK -127.0.0.1:6379> memory usage k1 //这里k1 value占用57字节内存 -(integer) 57 -127.0.0.1:6379> memory usage aaa // aaa键不存在,返回nil. -(nil) -``` - -对于除String类型之外的类型,memory usage命令采用抽样的方式,默认抽样5个元素,所以计算是近似值,我们也可以指定抽样的个数。 - -示例说明:生成一个100w个字段的hash键:hkey,每字段的value长度是从1~1024字节的随机值。 - -``` -127.0.0.1:6379> hlen hkey // hkey有100w个字段,每个字段的value长度介于1~1024个字节 -(integer) 1000000 -127.0.0.1:6379> MEMORY usage hkey //默认SAMPLES为5,分析hkey键内存占用521588753字节 -(integer) 521588753 -127.0.0.1:6379> MEMORY usage hkey SAMPLES 1000 //指定SAMPLES为1000,分析hkey键内存占用617977753字节 -(integer) 617977753 -127.0.0.1:6379> MEMORY usage hkey SAMPLES 10000 //指定SAMPLES为10000,分析hkey键内存占用624950853字节 -(integer) 624950853 -``` - -要想获取key较精确的内存值,就指定更大抽样个数。但是抽样个数越大,占用cpu时间分片就越大。 - -### redis-rdb-tools - -redis-rdb-tools 是一个 python 的解析 rdb 文件的工具,在分析内存的时候,我们主要用它生成内存快照。可以把 rdb 快照文件生成 CSV 或 JSON 文件,也可以导入到 MySQL 生成报表来分析。 - -使用 PYPI 安装 - -``` -pip install rdbtools -``` - -生成内存快照 - -``` -rdb -c memory dump.rdb > memory.csv -``` - -在生成的 CSV 文件中有以下几列: - -- `database` key在Redis的db -- `type` key类型 -- `key` key值 -- `size_in_bytes` key的内存大小 -- `encoding` value的存储编码形式 -- `num_elements` key中的value的个数 -- `len_largest_element` key中的value的长度 - -可以在MySQL中新建表然后导入进行分析,然后可以直接通过SQL语句进行查询分析。 - -```sql -CREATE TABLE `memory` ( - `database` int(128) DEFAULT NULL, - `type` varchar(128) DEFAULT NULL, - `KEY` varchar(128), - `size_in_bytes` bigint(20) DEFAULT NULL, - `encoding` varchar(128) DEFAULT NULL, - `num_elements` bigint(20) DEFAULT NULL, - `len_largest_element` varchar(128) DEFAULT NULL, - PRIMARY KEY (`KEY`) - ); -``` - -例子:查询内存占用最高的3个 key - -```sql -mysql> SELECT * FROM memory ORDER BY size_in_bytes DESC LIMIT 3; -+----------+------+-----+---------------+-----------+--------------+---------------------+ -| database | type | key | size_in_bytes | encoding | num_elements | len_largest_element | -+----------+------+-----+---------------+-----------+--------------+---------------------+ -| 0 | set | k1 | 624550 | hashtable | 50000 | 10 | -| 0 | set | k2 | 420191 | hashtable | 46000 | 10 | -| 0 | set | k3 | 325465 | hashtable | 38000 | 10 | -+----------+------+-----+---------------+-----------+--------------+---------------------+ -3 rows in set (0.12 sec) -``` - - -## Big Key问题解决思路 - -当发现存在大key问题时,我们需要及时采取措施来解决这个问题。下面列出几种可行的解决思路: - -### 分割大key - -将Big Key拆分成多个小的key。这个方法比较简单,但是需要修改应用程序的代码。就像是把一个大蛋糕切成小蛋糕一样,有点费力,但是可以解决问题。 - -或者尝试将Big Key转换成Redis的数据结构。例如,将Big Key转换成Hash,List或者Set等数据结构。 - -### 对象压缩 - -如果大key的大小主要是由于对象序列化后的体积过大,我们可以考虑使用压缩算法来减小对象的大小。Redis自身支持多种压缩算法,例如LZF、Snappy等。 - -### 直接删除 - -如果你使用的是Redis 4.0+的版本,可以直接使用 unlink命令去异步删除。4.0以下的版本 可以考虑使用 scan ,分批次删除。 - - - -无论采用哪种方法,都需要注意以下几点: - -1. 避免使用过大的value。如果需要存储大量的数据,可以将其拆分成多个小的value。就像是吃饭一样,一口一口的吃,不要贪多嚼不烂。 -2. 避免使用不必要的数据结构。例如,如果只需要存储一个字符串,就不要使用Hash或者List等数据结构。 -3. 定期清理过期的key。如果Redis中存在大量的过期key,就会导致Redis的性能下降。就像是家里的垃圾,需要定期清理。 - -2. 对象压缩 - -## 总结 - -Big Key问题是Redis中常见的问题之一,但是通过合理的排查和解决思路,我们可以有效地避免这个问题。在使用Redis时,需要注意避免使用过大的value和不必要的数据结构,以及定期清理过期的key。 - ------- - -本篇文章就到这里,感谢阅读,如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。 -![](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) diff --git "a/docs/md/redis/Redis\346\200\247\350\203\275\344\274\230\345\214\226\357\274\232\347\220\206\350\247\243\344\270\216\344\275\277\347\224\250Redis Pipeline.md" "b/docs/md/redis/Redis\346\200\247\350\203\275\344\274\230\345\214\226\357\274\232\347\220\206\350\247\243\344\270\216\344\275\277\347\224\250Redis Pipeline.md" new file mode 100644 index 0000000..700719e --- /dev/null +++ "b/docs/md/redis/Redis\346\200\247\350\203\275\344\274\230\345\214\226\357\274\232\347\220\206\350\247\243\344\270\216\344\275\277\347\224\250Redis Pipeline.md" @@ -0,0 +1,82 @@ +当我们谈论Redis数据处理和存储的优化方法时,「**Redis Pipeline**」无疑是一个不能忽视的重要技术。 + +在使用Redis的过程中,频繁的网络往返操作可能会引发严重的性能问题,尤其是当大量并发操作需要快速响应的时候。这就是我们需要使用Redis Pipeline的原因。 + +Redis Pipeline是Redis提供的一种功能,主要用于优化大量命令的执行。通过将多个命令组合到一起,进而一次发送到服务器,Pipeline可以显著减少网络延迟带来的影响。 + +在本文中,我们将详细介绍Redis Pipeline,阐述它如何解决网络延迟问题,并展示如何在实践中使用它以提升你的Redis性能。 + +## Pipeline介绍 + +首先,Redis客户端执行一条命令分四个过程: + +**发送命令——〉命令排队 ——〉命令执行 ——〉返回结果** + +这整个过程称为 **Round Trip Time(简称RTT, 往返时间)** 。 + +当进行批量操作时,Redis提供了一些命令如:MGET,MSET可以有效减少RTT。 + +但大部分命令(如HGETALL,并没有MHGETALL)不支持批量操作,需要消耗N次RTT ,这个时候就需要Pipeline来解决这个问题了。 + +**1、未使用Pipeline执行N条命令** +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOvgfyIKZkWibs3GVCFiauNTxKtyichpCF40qbpx79VUPFkibpbqX9SNVia44dKGj8w7e9TYMGpPqcnIWQ/0) +**2、使用了Pipeline执行N条命令** +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOvgfyIKZkWibs3GVCFiauNTxmjxtqlJ8vDXWNllebC7eI99hmPibmdLdnricLkb3jXibeIM2ibbib0qs5Hw/0) + +**Pipeline说白了就是通过将多个命令打包到一起然后一次性发送给 Redis 服务器,以减少网络通信次数和延迟,提高操作效率。** + +在不使用 Pipeline 的情况下,客户端每执行一个 Redis 操作都需要进行一次网络请求并等待服务器响应。但是如果使用了 Pipeline,就会把多个操作合并成一个批次,只需进行一次网络请求即可,服务器在接收到批处理的命令后,会依次执行每个命令,并将结果按命令的执行顺序打包返回给客户端。 + +这样做的好处是,首先,减少了网络请求数量,从而降低了由于网络延迟带来的总体延迟;其次,因为服务器在同一时间内处理一批命令,所以也能提高服务器的处理效率。 + +需要注意的是,虽然 Pipeline 能大大提升 Redis 性能,**但由于它将多个命令打包成一个请求发送给服务器,所以这些命令无法保证原子性,即这个批次中的某个命令失败不会影响其他命令的执行**。 + +如果Redis服务器在执行一系列命令的过程中发生错误或者崩溃,可能只有部分命令得到执行。要真正实现原子性,还需要使用Redis的事务功能(`MULTI`, `EXEC`等命令)。 + +## **原生批命令(MSET, MGET) VS Pipeline** + + - 原生批命令是原子性的,Pipeline是非原子性的。 + - 原生批命令是服务端实现,而Pipeline需要服务端与客户端共同完成。 + - MSET 和 MGET 等批命令是针对特定操作的优化,而 Pipeline 则是一个一般性的解决方案,通常来说性能比Pipeline更好。 + +## Pipeline的优缺点 + + - Pipeline 每批打包的命令不能过多,因为 Pipeline 方式打包命令再发送,那么 Redis 必须在处理完所有命令前先缓存起所有命令的处理结果。这样就有一个内存的消耗,可以将大量命令拆分为多个小的Pipeline命令完成。 + - Pipeline 操作是非原子性的,如果要求原子性的,不推荐使用 Pipeline。 + +## 一些疑问 + + **Pipeline 每批执行多少条命令合适?** + +> 根据官方的解释,推荐是以 10k 每批 (注意:这个是一个参考值,请根据自身实际业务情况调整)。 + +**Pipeline 批量执行的时候,是否对Redis进行了锁定,导致其他应用无法再进行读写?** + +> Redis 采用多路I/O复用模型,非阻塞IO,所以Pipeline批量写入的时候,一定范围内不影响其他的读写操作。 +> +> 虽然Redis本身支持并发操作,但它还是一个单线程模型,命令依然是顺序执行的。处理Pipeline的时候,从接收到Pipeline开始,到所有命令执行完毕,这期间的所有命令被看作一个整体,其他客户端提交的命令会排在这个整体后面等待执行。 + +## Pipeline代码实现 + +几乎所有的Redis客户端都支持Pipeline操作,因此实现起来非常容易。以下是一个简单示例代码: + +```java + @Test + void pipeline() { + List result = redisTemplate.executePipelined((RedisCallback) connection -> { + for (int i = 0; i < 100; i++) { + redisTemplate.opsForValue().set("pipel:" + i, i); + } + return null; + }); + } +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOvgfyIKZkWibs3GVCFiauNTx3IXRXAYpZ22Z9m4JsBzdEE7JRteZQiat0yv7wFPdibzxaMbdJAgHtRrA/0) + + + +在总结今天的内容时,我们了解到Redis Pipeline不仅能够大大提高我们与Redis服务器交互的速度,而且它还可以帮助我们优化网络通信。借助Pipeline,我们能够将多个命令一次性发送给服务器,避免了频繁地进行网络往返,从而减少了延迟并提升了效率。 + +然而,使用Pipeline也需要谨慎。过多的命令可能会造成阻塞,因此在选择何时以及如何使用Pipeline时,仔细权衡是至关重要的。希望通过这篇文章,你对Redis Pipeline有了更清晰的理解,能够更有效地利用它来优化你的应用程序。 + diff --git "a/docs/md/redis/Redis\346\214\201\344\271\205\345\214\226\346\267\261\345\272\246\350\247\243\346\236\220.md" "b/docs/md/redis/Redis\346\214\201\344\271\205\345\214\226\346\267\261\345\272\246\350\247\243\346\236\220.md" new file mode 100644 index 0000000..9935264 --- /dev/null +++ "b/docs/md/redis/Redis\346\214\201\344\271\205\345\214\226\346\267\261\345\272\246\350\247\243\346\236\220.md" @@ -0,0 +1,311 @@ +在现今的数据驱动世界中,数据持久化成为了一项至关重要的任务。它不仅需要保证数据的安全,还要提供快速读写的功能。 + +对于许多现代化应用程序和服务来说,Redis被广泛使用作为一个高性能的键值存储系统。Redis以其卓越的性能和灵活性赢得了开发者们的青睐。然而,这些优点都离不开它强大的持久化机制。 + +通过本文,我们将深入探讨Redis的持久化策略,包括RDB(Redis DataBase)快照和AOF(Append Only File)日志,并解析如何根据自己的业务需求选择合适的持久化方案。 + +## Redis持久化介绍 + +你也许会问,为什么需要持久化呢?因为Redis作为一款内存数据库,在进程异常退出或服务器断电之后,所有的数据都将消失。如果没有持久化功能,无法保证数据的持久性,那么这样的数据库还有什么用呢? + +Redis持久化分为两种:「**RDB(Redis DataBase)**」和「**AOF(Append Only File)**」。 + +RDB是指将Redis内存中的数据定期写入磁盘上的一个快照文件中,而AOF则是以追加的方式记录Redis执行的每一条写命令。 + +**你也可以同时开启两种持久化方式,在这种情况下,当Redis重启的时候会优先载入AOF文件来恢复原始的数据。** + +接下来,我们将分别介绍RDB和AOF的实现原理。 + +## RDB原理 + +**RDB是Redis默认的持久化方式,它将Redis在内存中的数据定期写入到硬盘中,生成一个快照文件。快照文件是一个二进制文件,包含了Redis在某个时间点的所有数据。** + +RDB的优点是**快速**、**简单**,适用于大规模数据备份和恢复。但是,RDB也有缺点,例如数据可能会丢失,因为Redis只会在指定的时间点生成快照文件。**如果在快照文件生成之后,但在下一次快照文件生成之前服务器宕机,那么这期间的数据就会丢失**。 + +由于RDB文件是以二进制格式保存的,因此它非常紧凑,并且在Redis重启时可以迅速地加载数据。相比于AOF,RDB文件一般会更小。 + +RDB持久化有两种方式:**手动**和**自动**。 + +手动方式通过`SAVE`命令或`BGSAVE`命令进行: + +- SAVE命令会阻塞Redis服务器,直到快照文件生成完成。 +- BGSAVE命令会Fork一个子进程(注意是子进程,不是子线程)在后台生成快照文件,不会阻塞Redis服务器。 + +自动方式则是在配置文件中设置, 让它在**“ N 秒内数据集至少有 M 个改动”**这一条件被满足时, 自动保存一次数据集。 + +比如说,以下设置会让 Redis 在满足 “10秒内有至少100 个键被改动” 这一条件时, 自动保存一次数据集。 + +``` +save 10 100 +``` + +### Fork函数与写时复制 + +在 Redis 中,Fork 函数被用于创建子进程。Redis 的使用场景中通常有大量的读操作和较少的写操作,而 Fork 函数可以利用 Linux 操作系统的写时复制(Copy On Write,即 COW)机制,让父子进程共享内存,从而减少内存占用,并且避免了没有必要的数据复制。 + +我们可以使用 Linux下的 `man fork` 命令来查看下Fork函数的说明文档。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNoOxn37Dew03PaAtIibwOsqVsUNzsmVJnqQdZuzSic8uNanfh0PSFqGyxWMZxFQaqDgacNDAhYic4oA/0) + +翻译如下: + +**在Linux下,fork()是使用写时复制的页实现的,所以它唯一的代价是复制父进程的页表以及为子进程创建独特的任务结构所需的时间和内存。** + +简单来说就是 `fork()`函数会复制父进程的地址空间到子进程中,复制的是指针,而不是数据,所以速度很快。 + +在 Redis 中,当执行 RDB 持久化操作时,Redis 会调用 fork 函数创建子进程,然后由子进程负责将数据写入到磁盘中。为了避免父子进程同时对内存中的数据进行修改导致数据不一致。Redis 会启用写时复制机制。 + +**这样,当父进程修改内存中的数据时, Linux 内核会将该部分内存复制一份给子进程使用,从而保证父子进程间的数据互相独立。** + +示意图如下: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNoOxn37Dew03PaAtIibwOsqk3TkklicRK8YvQkfZnF5mn7ZNP4DrWIV0Vcr7uztbTsv9NxiasEayVEQ/0?wx_fmt=png) + +**当没有发生写的时候,子进程和父进程指向地址是一样的,发生写的时候,就会拷贝出一块新的内存区域,实现父子进程隔离。** + +通过使用 fork 函数和写时复制机制,Redis 可以高效地执行 RDB 持久化操作,并且不会对 Redis 运行过程中的性能造成太大的影响。同时,这种方式也提供了一种简单有效的机制来保护 Redis 数据的一致性和可靠性。 + +不过,需要注意的是: + +**fork的这个过程主进程是阻塞的,fork完之后不阻塞。RDB 需要经常fork子进程来保存数据集到硬盘上,当数据集比较大的时候,fork的过程是非常耗时的,可能会导致Redis在一些毫秒级内不能响应客户端的请求,数据集很大的时候,fork过程可能会持续数秒。** + +**可能会因为数据量大而导致主进程长时间被挂起,造成Redis服务不可用。因此,在设计时应尽可能减少数据量或者优化fork的调用频率。** + +#### 关于写时复制的思考 + +上述写时复制流程貌似有个问题: + +比如,有个键值对 **k1 a** 。此时Redis正在`bgsave`。这时客户端发来一个请求,主进程发生写操作**set k1 b**,由于写时复制,此时子进程里k1的值还是a。最终持久化的也是a。 + +为什么不直接持久化新值而持久化旧值?写时复制的意义是什么? + +基于上面的问题,可以给出的解释主要有两点: + +1. 其实Redis为了性能考虑,内存的持久化是一个顺序写的操作。子进程备份RDB是一个顺序写的过程,如果主进程的所有写入请求都随时记录到RDB文件中,那么理论更新的key可能在任何位置出现,就会变为随机写,性能低。 +2. 其次如果主进程一直在写入更新key的话,那么这次RDB备份一直都在写主进程写入的新值,永远不会停止。 + +### RDB相关配置 + +以下是一些RDB的相关参数配置: + +- **save**:指定 RDB 持久化操作的条件。当 Redis 的数据发生变化,并且经过指定的时间(seconds)和变化次数(changes)后,Redis 会自动执行一次 RDB 操作。例如,save 3600 10000 表示如果 Redis 的数据在一个小时内发生了至少 10000 次修改,那么 Redis 将执行一次 RDB 操作。 + +- **stop-writes-on-bgsave-error**:指定在 RDB 持久化过程中如果出现错误是否停止写入操作。如果设置为 yes,当 Redis 在执行 RDB 操作时遇到错误时,Redis 将停止接受写入操作;如果设置为 no,Redis 将继续接受写入操作。 + +- **rdbcompression**:指定是否对 RDB 文件进行压缩。如果设置为 yes,Redis 会在生成 RDB 文件时对其进行压缩,从而减少磁盘占用空间;如果设置为 no,Redis 不会对生成的 RDB 文件进行压缩。 + +- **rdbchecksum**:指定是否对 RDB 文件进行校验和计算。如果设置为 yes,在保存 RDB 文件时,Redis 会计算一个 CRC64 校验和并将其追加到 RDB 文件的末尾;在加载 RDB 文件时,Redis 会对文件进行校验和验证,以确保文件没有受到损坏或篡改。 +- **replica-serve-stale-data**:这是 Redis 4.0 中新增的一个配置项,用于指定复制节点在与主节点断开连接后是否继续向客户端提旧数据。当设置为 yes 时,在复制节点与主节点断开连接后,该节点将继续向客户端提供旧数据,直到重新连接上主节点并且同步完全新的数据为止;当设置为 no 时,复制节点会立即停止向客户端提供数据,并且等待重新连接上主节点并同步数据。需要注意的是,当 replica-serve-stale-data 设置为 yes 时,可能会存在一定的数据不一致性问题,因此建议仅在特定场景下使用。 + +- **repl-diskless-sync**:这是 Redis 2.8 中引入的一个配置项,用于指定复制节点在进行初次全量同步(即从主节点获取全部数据)时是否采用无盘同步方式。当设置为 yes 时,复制节点将通过网络直接获取主节点的数据,并且不会将数据存储到本地磁盘中;当设置为 no 时,复制节点将先将主节点的数据保存到本地磁盘中,然后再进行同步操作。**采用无盘同步方式可以避免磁盘 IO 操作对系统性能的影响,但同时也会增加网络负载和内存占用。因此,应该根据具体的场景和需求选择合适的同步方式**。 + +## AOF原理 + +**AOF持久化是按照Redis的写命令顺序将写命令追加到磁盘文件的末尾。AOF是一种基于日志的持久化方式,它保存了Redis服务器所有写入操作的日志记录,以保证数据的持久性、可靠性和完整性。** + +AOF持久化技术的核心思想是将Redis服务器执行的所有写命令追加到一个文件中。当Redis服务器重新启动时,可以通过重新执行AOF文件来恢复服务器的状态。 + +AOF有个比较好的优势是可以恢复误操作。 + +举个例子,如果你不小心执行了 `FLUSHALL` 命令,导致数据被误删了 ,但只要 AOF 文件未被重写,那么只要停止服务器,移除 AOF 文件末尾的 `FLUSHALL` 命令,并重启 Redis ,就可以将数据集恢复到 `FLUSHALL` 执行之前的状态。 + +### AOF持久化配置 + +Redis的AOF持久化配置频率可通过`appendfsync` 参数进行控制。该参数有以下三个选项: + +- **always**:每次有数据修改都立即写入磁盘,是最安全的选项。 +- **everysec**:每秒钟写入一次,性能和安全之间做了一个平衡。 +- **no**:从不主动写入,完全依靠操作系统自身的缓存机制来决定何时将数据写入磁盘。 + +默认情况下,Redis的`appendfsync`参数设置为`everysec`。如果需要提高持久化安全性,可以将其改为`always`,如果更关注性能,则可以将其改为`no`。但是需要注意的是,使用`no`可能会导致数据丢失的风险,建议在应用场景允许的情况下谨慎使用。 + +### AOF文件解读 + +一个简单的AOF文件示例如下: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNoOxn37Dew03PaAtIibwOsqBf9v4M4AFwiaZ9Ur5pdj2HicDtRzeCfX1CY3NFjib5w0es0N2h1ibbJjuA/0) + +其中: + +- *号:表示参数个数,后面紧跟着参数的长度和值。 +- $号:表示参数长度,后面紧跟着参数的值。 + +实际上AOF文件中保存的所有命令都遵循相同的格式,即以*开头表示参数个数,$开头表示参数长度,其后紧跟着参数的值。 + +### AOF文件修复 + +服务器可能在程序正在对 AOF 文件进行写入时停机,造成AOF 文件损坏。 + +发生这种情况时,可以使用 Redis 自带的 **redis-check-aof** 程序,对 AOF 文件进行修复,命令如下: + +```bash +$ redis-check-aof –fix +``` + +### AOF重写 + +**Redis的AOF重写机制指的是将AOF文件中的冗余命令删除,以减小AOF文件的大小并提高读写性能的过程。** + +Redis的AOF重写机制采用了类似于复制的方式,首先将内存中的数据快照保存到一个临时文件中,然后遍历这个临时文件,只保留最终状态的命令,生成新的AOF文件。 + +具体来说,Redis执行AOF重写可以分为以下几个步骤: + +1. 开始AOF重写过程,向客户端返回一个提示信息。 +2. 创建一个临时文件,并将当前数据库中的键值对写入到临时文件中。 +3. 在创建的临时文件中将所有的写命令都转换成Redis内部的表示格式,即使用一系列的Redis命令来表示一个操作,例如使用SET命令来表示对某个键进行赋值操作。 +4. 对临时文件进行压缩,去掉多余的空格和换行符等,减小文件体积。 +5. 将压缩后的内容写入到新的AOF文件中。 +6. 停止写入命令到旧的AOF文件,并将新的AOF文件的文件名替换为旧的AOF文件的文件名。 +7. 结束AOF重写过程,并向客户端发送完成提示信息。 + +通过AOF重写机制,Redis可以在不停止服务的情况下减小AOF文件的大小,提高读写性能,同时也可以保证数据的一致性。 + +Redis提供了手动触发AOF重写的命令 `BGREWRITEAOF` 。可以在Redis的客户端中执行该命令来启动AOF重写过程。Redis 2.2 需要自己手动执行 `BGREWRITEAOF` 命令,到了 Redis 2.4 则可以自动触发 AOF 重写。 + +具体操作步骤如下: + +1. 打开redis-cli命令行工具,连接到Redis服务。 + +2. 执行`BGREWRITEAOF`命令,启动AOF重写过程。 + + ```bash + $ redis-cli + 127.0.0.1:6379> BGREWRITEAOF + ``` + +3. Redis会返回一个后台任务的ID,表示AOF重写任务已经开始。 + + ``` + 127.0.0.1:6379> BGREWRITEAOF + Background append only file rewriting started by pid 1234 + ``` + +4. 可以使用 `INFO PERSISTENCE` 命令查看当前AOF文件的大小和重写过程的状态,等待重写完成即可。 + + ```bash + 127.0.0.1:6379> INFO PERSISTENCE + # Persistence + aof_enabled:1 + aof_rewrite_in_progress:1 + aof_rewrite_scheduled:0 + aof_last_rewrite_time_sec:0 + aof_current_rewrite_time_sec:14 + aof_last_bgrewrite_status:ok + aof_last_write_status:ok + ``` + +需要注意的是,执行`BGREWRITEAOF`命令可能会占用较多的CPU和内存资源,因此在生产环境中需要谨慎使用,并确保有足够的系统资源支持。 + +同时,即使手动触发AOF重写,Redis也会在满足一定条件时自动触发AOF重写,以保证AOF文件的大小和性能。 + +需要注意的是: + +**在版本号大于等于 2.4 的 Redis 中,BGSAVE 执行的过程中,不可以执行 BGREWRITEAOF 。反过来说,在 BGREWRITEAOF 执行的过程中,也不可以执行 BGSAVE。目的是防止两个 Redis 后台进程同时对磁盘进行大量的 I/O 操作。** + +### AOF缓冲区与AOF重写缓存区 + +在Redis中,尽管「**AOF缓冲区**」和「**AOF重写缓冲区**」的名称相似,但它们实际上是两个不同的概念。 + +AOF缓冲区是一个用于暂存需要写入AOF文件的命令的缓冲区。在Redis处理客户端发来的写命令时,如果开启了AOF持久化功能,则该命令将被先写入到AOF缓冲区。AOF缓冲区中的内容通过配置的规则持久化到磁盘上。持久化规则可以通过配置项`appendfsync`来调整。 + +AOF重写缓冲区是一个用于执行AOF文件的重写操作的缓冲区。AOF重写操作是一种将现有AOF文件重写成最小化的新AOF文件的操作。AOF重写操作的目的是减少AOF文件的大小,同时加快恢复速度。**AOF重写缓存区在AOF重写时开始启用,Redis服务器主进程在执行完写命令之后,会同时将这个写命令追加到AOF缓冲区和AOF重写缓冲区**。 + +示意图如下: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNoOxn37Dew03PaAtIibwOsq03EkG6bTf2oO5yPXzQVnndLBNibTbbtwKOojqOh8Bhng22e3N4Nurwg/0) + +### AOF缓冲区可以替代AOF重写缓冲区吗 + +**AOF缓冲区不可以替代AOF重写缓冲区。** + +原因是**AOF重写缓冲区记录的是从重写开始后的所有需要重写的命令,而AOF缓冲区可能只记录了部分的命令(如果写回的话,AOF缓存区的数据就会失效被丢失,因而只会保存一部分的命令,而AOF重写缓存区不会)**。 + +AOF缓冲区主要是Redis用来解决主进程执行命令速度与磁盘写入速度不同步所设置的,通过AOF缓冲区可以有效地避免频繁对硬盘进行读写,进而提升性能。Redis在AOF持久化的时候,会先把命令写入到AOF缓冲区,然后通过写回策略来写入硬盘AOF文件。 + +### AOF相关配置 + +在 Redis 的配置文件 redis.conf 中,可以通过以下配置项来设置 AOF 相关参数: + +- **appendonly**:该配置项用于开启或关闭 AOF,默认为关闭。若开启了 AOF,Redis 会在每次执行写命令时,将命令追加到 AOF 文件末尾。 + +- **appendfilename**:用于设置 AOF 文件名,默认为 appendonly.aof。 + +- **appendfsync**:该配置项用于设置 AOF 的同步机制。有三种可选值: + - always:表示每个写命令都要同步到磁盘,安全性最高,但是性能较差。 + - everysec:表示每秒同步一次,是默认选项,既能保证数据安全,又具有较好的性能。 + - no:表示不进行同步,而是由操作系统决定何时将缓冲区中的数据同步到磁盘上,性能最好,但是安全性较低。 + +- **auto-aof-rewrite-percentage**和**auto-aof-rewrite-min-size**:这两个配置项用于设置 AOF 重写规则。当 AOF 文件大小超过 `auto-aof-rewrite-min-size` 设置的值,并且 AOF 文件增长率达到 `auto-aof-rewrite-percentage` 所定义的百分比时,Redis 会启动 AOF 重写操作。 + + `auto-aof-rewrite-percentage`默认值为100,以及`auto-aof-rewrite-min-size默认值为64mb,也就是说默认Redis会记录上次重写时的AOF大小,**默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发**。 + +- **aof-use-rdb-preamble**:Redis 4版本新特性,混合持久化。AOF重写期间是否开启增量式同步,该配置项在AOF重写期间是否使用RDB文件内容。默认是no,如果设置为yes,在AOF文件头加入一个RDB文件的内容,可以尽可能的减小AOF文件大小,同时也方便恢复数据。 + +### 写后日志 + +我们比较熟悉的是数据库的写前日志(Write Ahead Log,WAL),也就是说,在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复。 + +不过,AOF 日志却正好相反,它是写后日志,**“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志**。 + +为什么要这样设计? + +其实为了避免额外的检查开销,**Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查**。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。 + +而写后日志这种方式,就是**先让系统执行命令,只有命令能执行成功,才会被记录到日志中**,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。 + +除此之外,AOF 写后日志还有一个好处:**它是在命令执行后才记录日志,所以并不会阻塞当前的写操作**。 + +不过,写后日志也有两个潜在的风险: + +- 首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时 Redis 是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。 +- 其次,AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。 + +## 混合持久化 + +在过去, Redis 用户通常会因为 RDB 持久化和 AOF 持久化之间不同的优缺点而陷入两难的选择当中: + +- RDB 持久化能够快速地储存和恢复数据,但是在服务器停机时可能会丢失大量数据。 +- AOF 持久化能够有效地提高数据的安全性,但是在储存和恢复数据方面却要耗费大量的时间。 + +为了让用户能够同时拥有上述两种持久化的优点, Redis 4.0 推出了一个“鱼和熊掌兼得”的持久化方案 —— **RDB-AOF 混合持久化**。 + + **这种持久化能够通过 AOF 重写操作创建出一个同时包含 RDB 数据和 AOF 数据的 AOF 文件, 其中 RDB 数据位于 AOF 文件的开头, 它们储存了服务器开始执行重写操作时的数据库状态。至于那些在重写操作执行之后执行的 Redis 命令, 则会继续以 AOF 格式追加到 AOF 文件的末尾, 也即是 RDB 数据之后。** + +也就是说当开启混合持久化之后,AOF文件中的内容:**前半部分是二进制的RDB内容,后面跟着AOF增加的数据,AOF位于两次RDB之间**。 + +格式会类似下面这样: + +``` +(二进制)RDB + AOF +(二进制)RDB +``` + +在目前版本中, RDB-AOF 混合持久化功能默认是处于关闭状态的, 为了启用该功能, 用户不仅需要开启 AOF 持久化功能, 还需要将 `aof-use-rdb-preamble` 选项的值设置为 true。 + +``` +appendonly yes +aof-use-rdb-preamble yes +``` + +## 如何选择合适的持久化方式 + +当你想选择适合你的应用程序的持久化方式时,你需要考虑以下两个因素: + +1. **数据的实时性和一致性**:如果对数据的实时性和一致性有很高的要求,则AOF可能是更好的选择。 + + 如果对数据的实时性和一致性要求不太高,并且希望能快速地加载数据并减少磁盘空间的使用,那么RDB可能更适合你的应用程序。因为RDB文件是二进制格式的,结构非常紧凑,所以在Redis重启时可以迅速地加载数据。 + +3. **Redis的性能需求**:如果对Redis的性能有很高的要求,那么关闭持久化功能也是一个选择。因为持久化功能可能会影响Redis的性能,但是一般不建议这么做。 + + + +本篇文章到这就结束了,最后我们来做个小总结: + +我们要意识到Redis的持久化机制扮演着至关重要的角色。**RDB和AOF两种主要的持久化方式各有其优势和使用场景**。 + +RDB通过提供特定时间点的数据快照,对于灾难恢复是非常有效的;而AOF则通过记录每个写入操作,提供了更好的数据持久性保证。然而,它们也有各自的局限性,这就需要根据实际需求来权衡选用哪种持久化方式。 + +最后,不可忽视的是,在选择合适的持久化策略时,我们还应考虑如何平衡内存使用、磁盘使用、性能与持久性等多个因素。只有对Redis持久化的深入理解,我们才能充分利用其强大的功能,以满足各种业务需求。 + +希望这篇文章能够给你带来收获和思考,谢谢。 diff --git "a/docs/md/redis/Redis\350\267\237MySQL\347\232\204\345\217\214\345\206\231\351\227\256\351\242\230.md" "b/docs/md/redis/Redis\350\267\237MySQL\347\232\204\345\217\214\345\206\231\351\227\256\351\242\230.md" deleted file mode 100644 index e477a69..0000000 --- "a/docs/md/redis/Redis\350\267\237MySQL\347\232\204\345\217\214\345\206\231\351\227\256\351\242\230.md" +++ /dev/null @@ -1,99 +0,0 @@ -[TOC] - -**项目中有遇到这个问题,跟MySQL中的数据不一致,研究一番发现这里面细节并不简单,特此记录一下。** - - -## 写在前面 -**严格意义上任何非原子操作都不可能保证一致性,除非用阻塞读写实现强一致性,所以缓存架构我们追求的目标是最终一致性。** -**缓存就是通过牺牲强一致性来提高性能的**。 - -这是由CAP理论决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP。 - -以下3 种缓存读写策略各有优劣,不存在最佳。 -## 三种读写缓存策略 -### Cache-Aside Pattern(旁路缓存模式) -Cache-Aside Pattern,即旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。 -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOvgfyIKZkWibs3GVCFiauNTxcXv5aBOErNC3ytI1RHur7dq3POvWgYJUe7Gmx6A3HvJibJibCduA84Xg/0?wx_fmt=png) -**读** :从缓存读取数据,读到直接返回。如果读取不到的话,从数据库加载,写入缓存后,再返回响应。 -**写**:更新的时候,先**更新数据库,然后再删除缓存**。 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOvgfyIKZkWibs3GVCFiauNTxd5ib3fIkmCkmdkDMibpzF63NRicicY93MpVy5qyF7l1Yp17eHicBjRnghibg/0?wx_fmt=png) -### Read-Through/Write-Through(读写穿透) -Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。 - -因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入DB的功能,所以使用并不多。 - -**写**:先查 cache,cache 中不存在,直接更新 DB。cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(**同步更新 cache和DB**)。 - -**读**:从 cache 中读取数据,读取到就直接返回 。读取不到的话,先从 DB 加载,写入到 cache 后返回响应。 - -### Write Behind Pattern(异步缓存写入) -Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。 - -但是,两个又有很大的不同:**Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。** - -很明显,这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新DB的话,cache服务可能就挂掉了,反而会带来更大的灾难。 - -这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制都用到了这种策略。 - -Write Behind Pattern 下 DB 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。 -## 旁路缓存模式解析 - -### Cache Aside Pattern 的一些疑问 -旁路缓存模式是我们平时中使用最多的。下面根据上面介绍的旁路缓存模式,我们可以有以下几个疑问。 - -> 为什么写操作是删除缓存,而不是更新缓存 - -**答**:线程A先发起一个写操作,第一步先更新数据库。线程B再发起一个写操作,第二步更新了数据库,由于网络等原因,线程B先更新了缓存,线程A更新缓存。 - -这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),数据**不一致**了,脏数据出现啦。如果是**删除缓存取代更新缓存**则不会出现这个脏数据问题。 - -**实际上要写操作的时候更新缓存也是可以的,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题**。 - - -> 在写数据的过程中,为什么要先更新DB在删除缓存 - -**答**:比如说请求1 是写操作,要是先删除缓存A,请求2是读操作,先读缓存A,发现缓存被删除了(被请求1删除了),然后去读数据库,但是此时请求1还没来得及把数据及时更新,那么请求2读的就是旧数据,并且请求2还会把读到的旧数据放到缓存中,造成了数据的不一致。 - -其实要先删缓存,再更新数据库也是可以,如采用**延时双删策略** -休眠1秒,再次淘汰缓存 这么做,可以将1秒内所造成的缓存脏数据,再次删除。不一定是1秒,看你业务决定的,**不过不推荐这种做法**,因为在这1秒内可能发生因素很多,它的不确定性太大。 - -> 在写数据的过程中,先更新DB,后删除cache就没有问题了么? - -**答:** 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小。 - -假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生 - -(1)缓存刚好失效 -(2)请求A查询数据库,得一个旧值 -(3)请求B将新值写入数据库 -(4)请求B删除缓存 -(5)请求A将查到的旧值写入缓存 ok,如果发生上述情况,确实是会发生脏数据。 - -**然而,发生这种情况的概率并不高** - -发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。 - -可是,仔细想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。 - -> 还有其他造成不一致的原因么? - -**答:** 如果删除缓存过程中失败了就会造成不一致问题 - -**如何解决?** -使用Canal去订阅数据库的binlog,获得需要操作的数据。另起一个程序,获得这个订阅程序传来的信息,进行删除缓存操作。 - -### Cache Aside Pattern 的缺陷 -**缺陷1:首次请求数据一定不在 cache 的问题** - -解决办法:可以将热点数据提前放入cache 中。 - -**缺陷2:写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率 。** - -- 数据库和缓存数据强一致场景 :更新DB的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。 -- 可以短暂地允许数据库和缓存数据不一致的场景 :更新DB的时候同样更新cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。 - ------- - -本篇文章就到这里,感谢阅读,如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。 -![](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) \ No newline at end of file diff --git "a/docs/md/redis/\345\270\203\351\232\206\350\277\207\346\273\244\345\231\250.md" "b/docs/md/redis/\345\270\203\351\232\206\350\277\207\346\273\244\345\231\250.md" deleted file mode 100644 index 894a19d..0000000 --- "a/docs/md/redis/\345\270\203\351\232\206\350\277\207\346\273\244\345\231\250.md" +++ /dev/null @@ -1,127 +0,0 @@ -[TOC] - -## 问题描述 -在开发过程中,经常要判断一个元素是否在一个集合中。**假设你现在要给项目添加IP黑名单功能,此时你手上有大约 1亿个恶意IP的数据集,有一个IP发起请求,你如何判断这个IP在不在你的黑名单中?** - -类似这种问题用Java自己的Collection和Map很难处理,因为它们存储元素本身,会造成内存不足,而我们只关心元素存不存在,对于元素的值我们并不关心,具体值是什么并不重要。 - -## BloomFilter(布隆过滤器) -布隆过滤器可以用来判断某个元素是否在集合内,具有运行快速,内存占用小的特点,它是一个保存了很长的二级制向量,同时结合 Hash 函数实现的。而高效插入和查询的代价就是,它是一个基于概率的数据结构,**只能告诉我们一个元素绝对不在集合内,对于存在集合内有一定的误判率**。 - -### fpp -因为布隆过滤器中总是会存在误判率,因为哈希碰撞是不可能百分百避免的。布隆过滤器对这种误判率称之为假阳性概率,即:**False Positive Probability**,简称为 fpp。 - -在实践中使用布隆过滤器时可以自己定义一个 fpp,然后就可以根据布隆过滤器的理论计算出需要多少个哈希函数和多大的位数组空间。需要注意的是这个 fpp 不能定义为 100%,因为无法百分保证不发生哈希碰撞。 - -下图表示向布隆过滤器中添加元素 **https://blog.csdn.net/bookssea** 和 **https://www.abc.com** 的过程,它使用了 func1 和 func2 两个简单的哈希函数。 -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOSiaTvNmOndSSfwBE77H7hDTo4RLFdvVS0lkibIR45L0F0Zk2uf8cqutVk3BjfDfnAmqqwRtavJVDw/0?wx_fmt=png) - - -对写入的数据做 H 次 hash 运算定位到数组中的位置,同时将数据改为 1 。当有数据查询时也是同样的方式定位到数组中。 一旦其中的有一位为 0 则认为数据肯定不存在于集合,否则数据可能存在于集合中。 - -通过其原理可以知道,可我们可以提高数组长度以及 hash 计算次数来降低误报率,但是相应的 CPU、内存的消耗也会相应的提高;这需要我们根据自己的业务需要去权衡选择。 -### 布隆过滤器的特点 -布隆过滤有以下2个特点: - - - **只要返回数据不存在,则肯定不存在。** - - **返回数据存在,但只能是大概率存在**。 - - - 在有限的数组长度中存放大量的数据,即便是再完美的 Hash 算法也会有冲突,所以有可能两个完全不同的 A、B 两个数据最后定位到的位置是一模一样的。这时拿 B 进行查询时那自然就是误报了。 - -### 布隆过滤器中的数据可不可以删除 -布隆过滤器判断一个元素存在就是判断对应位置是否为 1 来确定的,但是如果要删除掉一个元素是不能直接把 1 改成 0 的,因为这个位置可能存在其他元素,所以如果要支持删除,最简单的做法就是加一个计数器,就是说位数组的每个位如果不存在就是 0,存在几个元素就存具体的数字,而不仅仅只是存 1,那么这就有一个问题,本来存 1 就是一位就可以满足了,但是如果要存具体的数字比如说 2,那就需要 2 位了,所以带有计数器的布隆过滤器会占用更大的空间。 - -```xml - - com.baqend - bloom-filter - 1.0.7 - -``` - -新建一个带有计数器的布隆过滤器 CountingBloomFilter: - -```java -import orestes.bloomfilter.FilterBuilder; - -public class CountingBloomFilter { - public static void main(String[] args) { - orestes.bloomfilter.CountingBloomFilter cbf = new FilterBuilder(10000, - 0.01).countingBits(8).buildCountingBloomFilter(); - - cbf.add("zhangsan"); - cbf.add("lisi"); - cbf.add("wangwu"); - System.out.println("是否存在王五:" + cbf.contains("wangwu")); //true - cbf.remove("wangwu"); - System.out.println("是否存在王五:" + cbf.contains("wangwu")); //false - } -} -``` -构建布隆过滤器前面 2 个参数一个就是期望的元素数,一个就是 fpp 值,后面的 countingBits 参数就是计数器占用的大小,这里传了一个 8 位,即最多允许 255 次重复,如果不传的话这里默认是 16 位大小,即允许 65535次重复。 - -建议使用Guava自带的布隆过滤器,直接传入预期的数据量以及fpp,它会自动帮我们计算数组长度和哈希次数 -### 布隆过滤器应该设计为多大? -假设在布隆过滤器里面有 k 个哈希函数,m 个比特位(也就是位数组长度),以及 n 个已插入元素,错误率会近似于 (1-ekn/m)k,所以你只需要先确定可能插入的数据集的容量大小 n,然后再调整 k 和 m 来为你的应用配置过滤器。 -### 布隆过滤器应该使用多少个哈希函数? -对于给定的 m(比特位个数)和 n(集合元素个数),最优的 k(哈希函数个数)值为: (m/n)ln(2) -### 布隆过滤器的时间复杂度和空间复杂度? -对于一个 m(比特位个数)和 k(哈希函数个数)值确定的布隆过滤器,添加和判断操作的时间复杂度都是 O(k),这意味着每次你想要插入一个元素或者查询一个元素是否在集合中,只需要使用 k 个哈希函数对该元素求值,然后将对应的比特位标记或者检查对应的比特位即可。 - -## Guava的布隆过滤器的实现 -Guava有自带的布隆过滤器的实现 - -```java -public class BloomFilterTest { - - public static void main(String[] args) { - long star = System.currentTimeMillis(); - BloomFilter filter = BloomFilter.create( - Funnels.integerFunnel(), - //预计存放多少数据 - 10000000, - //可以接受的误报率 - 0.01); - - for (int i = 0; i < 10000000; i++) { - filter.put(i); - } - - Assert.isTrue(filter.mightContain(1),"不存在"); - Assert.isTrue(filter.mightContain(2),"不存在"); - Assert.isTrue(filter.mightContain(3),"不存在"); - Assert.isTrue(filter.mightContain(10000000),"不存在"); - long end = System.currentTimeMillis(); - System.out.println("执行时间:" + (end - star)); - - } -} -``` - -## BitMap -**BitMap不会存在误判的情况,位图也是布隆过滤器的实现,但是占用内存空间随集合内最大元素的增大而增大。而布隆过滤器,因为其可能一个bit为多个元素作标识,这就保证了它的空间利用率。这2种方式根据业务进行选择**。 - -以32位整型为例,它可以表示数字的个数为2^32. 可以申请一个位图,让每个整数对应的位图中的一个bit,这样2^32个数需要的位图的大小为512MB。具体实现的思路为:申请一个512MB的位图,并把所有的位都初始化为0;接着遍历所有的整数,对遍历到的数字,把相应的位置上的bit设置为1.最后判断待查找的数对应的位图上的值是多少,如果是0,那么表示这个数字不存在,如果是1,那么表示这个数字存在。 - -Java中有BitMap的实现类,BitSet - -```java -public class BitMapTest { - public static void main(String[] args) { - int[] array = {3, 8, 5, 7, 1}; - BitSet bitSet = new BitSet(5); - - for (int i = 0; i < array.length; i++) { - bitSet.set(array, true); - } - - bitSet.stream().forEach(e -> System.out.println(e)); - - } -} -``` ------- - -本篇文章就到这里,感谢阅读,如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。 -![](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) diff --git "a/docs/md/redis/\345\270\203\351\232\206\350\277\207\346\273\244\345\231\250\357\274\232\345\216\237\347\220\206\344\270\216\345\272\224\347\224\250.md" "b/docs/md/redis/\345\270\203\351\232\206\350\277\207\346\273\244\345\231\250\357\274\232\345\216\237\347\220\206\344\270\216\345\272\224\347\224\250.md" new file mode 100644 index 0000000..9a9ed82 --- /dev/null +++ "b/docs/md/redis/\345\270\203\351\232\206\350\277\207\346\273\244\345\231\250\357\274\232\345\216\237\347\220\206\344\270\216\345\272\224\347\224\250.md" @@ -0,0 +1,172 @@ +在日常生活和工作中,我们经常需要处理海量的数据,筛选出有用的信息。 + +这个时候,布隆过滤器(Bloom Filter)就派上了用场。 作为一种空间高效的概率型数据结构,布隆过滤器能够快速有效地检测一个元素是否属于一个集合。其应用广泛,从网络爬虫的网页去重,到数据库查询优化,乃至比特币网络的交易匹配,都离不开它的身影。 + +本文将深入解析布隆过滤器的原理以及如何在实际情况中进行使用,希望能帮助你更好地理解和运用这种强大的工具。 + +## 布隆过滤器简介 + +在开发过程中,经常要判断一个元素是否在一个集合中。**假设你现在要给项目添加IP黑名单功能,此时你手上有大约 1亿个恶意IP的数据集,有一个IP发起请求,你如何判断这个IP在不在你的黑名单中?** + +类似这种问题用Java自己的Collection和Map很难处理,因为它们存储元素本身,会造成内存不足,而我们只关心元素存不存在,对于元素的值我们并不关心,具体值是什么并不重要。 + +「**布隆过滤器**」可以用来解决类似的问题,具有运行快速,内存占用小的特点,它是一个保存了很长的二级制向量,同时结合 Hash 函数实现的。 + +而高效插入和查询的代价就是,它是一个基于概率的数据结构,**只能告诉我们一个元素绝对不在集合内,对于存在集合内的元素有一定的误判率**。 + +## fpp + +布隆过滤器中总是会存在误判率,因为哈希碰撞是不可能百分百避免的。布隆过滤器对这种误判率称之为「**假阳性概率**」,即:**False Positive Probability**,简称为 fpp。 + +在实践中使用布隆过滤器时可以自己定义一个 fpp,然后就可以根据布隆过滤器的理论计算出需要多少个哈希函数和多大的位数组空间。需要注意的是这个 fpp 不能定义为 100%,因为无法百分保证不发生哈希碰撞。 + +## 布隆过滤器原理 + +下图表示向布隆过滤器中添加元素 **www.123.com** 和 **www.456.com** 的过程,它使用了 func1 和 func2 两个简单的哈希函数。 +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOVMmTVs8zjnCmKg1cMq38QibmBrFolPsnVmBricTHCUicm7ShVxJibS4GsIEpDOiaykjaw1oMkm4CUunA/640) + +其基本原理如下: + +1. **初始化**:当我们创建一个布隆过滤器时,我们首先创建一个全由0组成的位数组(bit array)。同时,我们还需选择几个独立的哈希函数,每个函数都可以将集合中的元素映射到这个位数组的某个位置。 +2. **添加元素**:在布隆过滤器中添加一个元素时,我们会将此元素通过所有的哈希函数进行映射,得到在位数组中的几个位置,然后将这些位置标记为1。 +3. **查询元素**:如果我们要检查一个元素是否在集合中,我们同样使用这些哈希函数将元素映射到位数组中的几个位置,**如果所有的位置都被标记为1,那么我们就可以说该元素可能在集合中。如果有任何一个位置不为1,那么该元素肯定不在集合中**。 + +通过其原理可以知道,我们可以提高数组长度以及 hash 计算次数来降低误报率,但是相应的 CPU、内存的消耗也会相应地提高,会增加存储和计算的开销。因此,布隆过滤器的使用需要在误判率和性能之间进行权衡。 + +## 布隆过滤器的特点 + +布隆过滤器有以下两个特点: + + - **只要返回数据不存在,则肯定不存在。** + - **返回数据存在,不一定存在**。 + +布隆过滤器的误判率主要来源于「**哈希碰撞**」。因为位数组的大小有限,不同的元素可能会被哈希到相同的位置,导致即使某个元素并未真正被加入过滤器,也可能因为其他已经存在的元素而让所有哈希函数映射的位都变为了1,从而误判为存在。这就是布隆过滤器的“假阳性”错误。 + + 在有限的数组长度中存放大量的数据,即便是再完美的 Hash 算法也会有冲突,所以有可能两个完全不同的 A、B 两个数据最后定位到的位置是一模一样的。这时拿 B 进行查询时那自然就是误报了。 + +## 布隆过滤器使用 + +### 布隆过滤器中的数据可不可以删除 + +**布隆过滤器判断一个元素存在就是判断对应位置是否为 1 来确定的,但是如果要删除掉一个元素是不能直接把 1 改成 0 的**,因为这个位置可能存在其他元素。 + +所以如果要支持删除,最简单的做法就是加一个计数器,就是说位数组的每个位如果不存在就是 0,存在几个元素就存具体的数字,而不仅仅只是存 1,但是这样会带来其他问题,本来存 1 就是一位就可以满足了,但是如果要存具体的数字比如说 2,那就需要 2 位了,所以带有计数器的布隆过滤器会占用更大的空间。 + +以下是带有计数器的布隆过滤器的实现: + +```xml + + com.baqend + bloom-filter + 1.0.7 + +``` + +新建一个带有计数器的布隆过滤器 CountingBloomFilter: + +```java +import orestes.bloomfilter.FilterBuilder; + +public class CountingBloomFilter { + public static void main(String[] args) { + orestes.bloomfilter.CountingBloomFilter cbf = new FilterBuilder(10000, + 0.01).countingBits(8).buildCountingBloomFilter(); + + cbf.add("zhangsan"); + cbf.add("lisi"); + cbf.add("wangwu"); + System.out.println("是否存在王五:" + cbf.contains("wangwu")); //true + cbf.remove("wangwu"); + System.out.println("是否存在王五:" + cbf.contains("wangwu")); //false + } +} +``` + +构建布隆过滤器前面 两个参数一个就是期望的元素数,一第二个就是 fpp 值,后面的 countingBits 参数就是计数器占用的大小,这里传了一个 8 位,即最多允许 255 次重复,如果不传的话这里默认是 16 位大小,即允许 65535次重复。 + +### 布隆过滤器应该设计为多大 + +假设在布隆过滤器里面有 k 个哈希函数,m 个比特位(也就是位数组长度),以及 n 个已插入元素,错误率会近似于 (1-ekn/m)k,所以你只需要先确定可能插入的数据集的容量大小 n,然后再调整 k 和 m 来为你的应用配置过滤器。 + +### 布隆过滤器应该使用多少个哈希函数 + +对于给定的 m(比特位个数)和 n(集合元素个数),最优的 k(哈希函数个数)值为: (m/n)ln(2)。 + +### 布隆过滤器的时间复杂度和空间复杂度 + +对于一个 m(比特位个数)和 k(哈希函数个数)值确定的布隆过滤器,添加和判断操作的时间复杂度都是 O(k),这意味着每次你想要插入一个元素或者查询一个元素是否在集合中,只需要使用 k 个哈希函数对该元素求值,然后将对应的比特位标记或者检查对应的比特位即可。 + +## 布隆过滤器实现 + +### Guava的布隆过滤器的实现 + +Guava有自带的布隆过滤器的实现: + +```java +public class BloomFilterTest { + + public static void main(String[] args) { + long star = System.currentTimeMillis(); + BloomFilter filter = BloomFilter.create( + Funnels.integerFunnel(), + //预计存放多少数据 + 10000000, + //可以接受的误报率 + 0.01); + + for (int i = 0; i < 10000000; i++) { + filter.put(i); + } + + Assert.isTrue(filter.mightContain(1),"不存在"); + Assert.isTrue(filter.mightContain(2),"不存在"); + Assert.isTrue(filter.mightContain(3),"不存在"); + Assert.isTrue(filter.mightContain(10000000),"不存在"); + long end = System.currentTimeMillis(); + System.out.println("执行时间:" + (end - star)); + + } +} +``` + +Guava自带的布隆过滤器,只需直接传入预期的数据量以及fpp,它会自动帮我们计算数组长度和哈希次数。 + +这段代码创建了一个预期存储10000000个整数的布隆过滤器,误报率为1%。 + +然后,代码将0到9999999的所有整数添加到过滤器中。然后,对数字1、2、3和10000000进行测试。对于前三个数字,因为他们已经被添加到过滤器中,所以mightContain返回true;对于最后一个数字(10000000),由于它并未加入过滤器,mightContain方法可能返回false,但也有1%的概率返回true(误报)。 + +### BitMap(位图) + +**BitMap不会存在误判的情况,位图也是布隆过滤器的实现,但是占用内存空间随集合内最大元素的增大而增大。而布隆过滤器,因为其可能一个bit为多个元素作标识,这就保证了它的空间利用率。这两种方式根据业务进行选择。** + +以32位整型为例,它可以表示数字的个数为2^32,可以申请一个位图,让每个整数对应的位图中的一个bit,这样2^32个数需要的位图的大小为512MB。 + +具体实现的思路为:申请一个512MB的位图,并把所有的位都初始化为0,接着遍历所有的整数,对遍历到的数字,把相应的位置上的bit设置为1。 + +最后判断待查找的数对应的位图上的值是多少,如果是0,那么表示这个数字不存在,如果是1,那么表示这个数字存在。 + +Java中有BitMap的实现类:`BitSet`,Java中的`BitSet`类创建一种特殊类型的数组来保存位值。该类实现了一个可动态扩展的位向量。位集的大小会随着需要而增长。这使得它成为了实现位图的理想选择。 + +```java +public class BitMapTest { + public static void main(String[] args) { + int[] array = {3, 8, 5, 7, 1}; + BitSet bitSet = new BitSet(5); + + for (int i = 0; i < array.length; i++) { + bitSet.set(array[i], true); + } + + bitSet.stream().forEach(e -> System.out.println(e)); + + } +} +``` + +这段代码首先创建了一个`BitSet`实例,然后遍历数组,把数组中每个元素值设为位集中对应索引的位。例如,数组中的第一个元素是3,那么就把位集的第三位设为`true`。最后,使用`stream()`方法和lambda表达式打印出所有被设置为`true`的位的索引。 + +这就是本篇文章的全部内容。在总结我们对布隆过滤器的探讨时,我们可以看到其独特和强大之处。这种数据结构经常被应用于各种场景,包括缓存系统、网络路由器,甚至是大规模分布式数据库中。尽管它存在一定的误报率,但是通过精心选择哈希函数的数量和位数组的大小,我们可以降低这个概率。 + +布隆过滤器的高效性、节省空间的特性以及灵活的设计使得它成为解决各种问题的有力工具。但需要注意的是,作为工程师和开发者,我们必须理解并接受其限制和妥协,如假阳性的可能性和无法从过滤器中删除元素的事实。然而,正是这些限制,为我们提供了改进和创新的机会,推动我们寻找更多高效、灵活的数据处理方法。 + +总的来说,布隆过滤器是一个强大而高效的工具,值得我们深入理解和广泛应用。同时,它也是计算机科学中众多神奇的示例之一,展示了如何通过聪明的设计和妥协,解决现实世界中的挑战问题。 diff --git "a/docs/md/redis/\346\216\242\347\264\242Redis\344\270\216MySQL\347\232\204\345\217\214\345\206\231\351\227\256\351\242\230.md" "b/docs/md/redis/\346\216\242\347\264\242Redis\344\270\216MySQL\347\232\204\345\217\214\345\206\231\351\227\256\351\242\230.md" new file mode 100644 index 0000000..cb6516b --- /dev/null +++ "b/docs/md/redis/\346\216\242\347\264\242Redis\344\270\216MySQL\347\232\204\345\217\214\345\206\231\351\227\256\351\242\230.md" @@ -0,0 +1,176 @@ +在日常的应用开发中,我们经常会遇到需要使用多种不同类型的数据库管理系统来满足各种业务需求。其中最典型的就是Redis和MySQL的组合使用。 + +这两者拥有各自的优点,例如Redis为高性能的内存数据库提供了极快的读写速度,而MySQL则是非常强大的关系型数据库,支持事务处理,并且提供了很好的数据一致性。 + +然而,在实际应用过程中,如何保证Redis和MySQL双写时的数据一致性问题成为了开发者们面临的重要挑战。本文即将针对这个问题进行深入探讨,希望能为广大开发者们提供一些有价值的思路和解决方案。 + +## 双写一致问题 + +双写一致性问题主要是指当我们同时向Redis和MySQL写数据时,由于网络延迟、服务器故障等原因,可能导致数据在两个系统之间产生不一致。 + +例如,你可能已经更新了MySQL中的数据,但是Redis中的数据还未来得及更新,或者反过来。这样的结果就可能导致用户读到的是旧的、不正确的数据。 + +比如在现实生活中的购物网站场景:假设用户A在购买一件库存仅剩1件的商品,系统在接收到请求后,先将MySQL中的库存减少1,然后出现了网络延迟或系统故障,Redis中的库存没有减少。此时,用户B看到的是还有1件商品,也发起了购买请求,如果系统又首先更改了MySQL,那么就会出现超卖的情况,即实际库存已经没有,但因为缓存中的信息不准确,导致系统销售了更多的商品。 + +**严格意义上任何非原子操作都不可能保证一致性,除非用阻塞读写实现强一致性,所以对于缓存架构我们追求的目标是最终一致性。** + +实际上,缓存就是通过牺牲强一致性来提高性能的。这是由CAP理论决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP。 + +## 缓存读写策略 + +解决这种问题的常见策略就是“缓存读写策略”。这个策略用于处理先更新数据库还是先更新缓存等场景。 + +接下来,我们将探讨三种缓存读写策略。这些策略各有优劣,没有绝对的最佳选择。请根据具体的应用场景选择最合适的策略。 + +### Cache-Aside Pattern(旁路缓存模式) + +Cache-Aside Pattern,即旁路缓存模式,它的提出是为了尽可能地解决缓存与数据库的数据不一致问题。旁路缓存模式中服务端需要同时维护`DB`和`Cache`,并且是以`DB`的结果为准。 +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNR05Xxm1fSBOiaWuh7IRh11aQykSccTnPucpWy6IgdXkXoFd6QicUibibyouSqpoxcnvtQnzfE7mfCrg/640) +**读** :从缓存读取数据,读到直接返回。如果读取不到的话,从数据库加载,写入缓存后,再返回响应。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNR05Xxm1fSBOiaWuh7IRh11YPaaW8NFkACO3Wia5pov5QfjpVFlVShewWbK8NQzpQVvtdud4MbtDeg/640) + +**写**:更新的时候,先「**更新数据库,然后再删除缓存**」。 + +### Read/Write Through Pattern(读写穿透模式) + +Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。 + +因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入DB的功能,所以使用并不多。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNR05Xxm1fSBOiaWuh7IRh11PISibR6yCexC0HicWiaWDbwqGHxfTzibzsibYFfErUFEDBn0VE17ISTUocQ/640) + +**读**:从 cache 中读取数据,读取到就直接返回 。读取不到的话,先从 DB 加载,写入到 cache 后返回响应。 + +从流程图中可以看出,读写穿透模式和旁路缓存模式的读取流程几乎相同。不过,在旁路缓存模式中,客户端需要负责将数据写入`cache`。而在读写穿透模式中,`cache`服务自行写入缓存,对客户端来说,这个过程是透明的。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNR05Xxm1fSBOiaWuh7IRh11Lj3QiaO2OBPGDoOicUVkkq56hiaEoFCxyvUO7icLgot9X5JicjtSChAUuFQ/640) + +**写**:先查 cache,cache 中不存在,直接更新 DB。cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(**同步更新 cache和DB**)。 + +### Write Behind Pattern(异步缓存写入模式) + +Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。 + +但是,两个又有很大的不同:**Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB**。 + +很明显,这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新DB的话,cache服务可能就挂掉了,反而会带来更大的灾难。 + +这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制都用到了这种策略。 + +Write Behind Pattern 下 DB 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量等。 + +## 旁路缓存模式解析 + +### Cache Aside Pattern 的一些疑问 + +旁路缓存模式是我们平时中使用最多的,根据该模式,我们可能会有以下几个疑问。 + +> 为什么写操作是删除缓存,而不是更新缓存 + +**答**:假设线程A先发起一个写操作,第一步先更新数据库。线程B再发起一个写操作,紧接着也更新了数据库。由于网络等原因,线程B比线程A先更新了缓存,然后线程A更新缓存。 + +这时候,缓存保存的是A的数据(老数据),而数据库保存的是B的数据(新数据),数据就不一致了,脏数据出现啦。如果是「**删除缓存取代更新缓存**」则不会出现这个脏数据问题。 + +**实际上要写操作的时候更新缓存也是可以的,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。** + + +> 在写数据的过程中,为什么要先更新DB再删除缓存 + +**答**:假设请求1 是写操作,要是先删除缓存A,这时候来了请求2,请求2是读操作,先读缓存A,发现缓存被删除了(被请求1删除了),然后去读数据库,但是此时请求1还没来得及把数据及时更新,那么请求2读的就是旧数据,并且请求2还会把读到的旧数据放到缓存中,造成了数据的不一致。 + +其实要先删缓存,再更新数据库也是可以,如采用「**延时双删策略**」。 + +休眠一段时间,再次淘汰缓存。这么做,可以将这段时间内所造成的缓存脏数据,再次删除。 + +**注意sleep休眠的时间不能小于修改数据库数据的时间小,基本上1秒就够了。** + +> 在写数据的过程中,先更新DB,后删除cache就没有问题了么? + +**答:** 理论上来说还是可能会出现数据不一致性的问题,不过概率非常小。 + +假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生: + +1. 缓存刚好失效。 +2. 请求A查询数据库,得一个旧值。 +3. 请求B将新值写入数据库。 +4. 请求B删除缓存。 +5. 请求A将查到的旧值写入缓存 ok,如果发生上述情况,确实是会发生脏数据。 + +然而,发生这种情况的概率并不高 + +发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。 + +可是,仔细想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。 + +> 还有其他造成不一致的原因么? + +**答:** 如果删除缓存过程中失败了就会造成不一致问题。可以使用Canal去订阅数据库的binlog,获得需要操作的数据。另起一个程序,获得这个订阅程序传来的信息,进行删除缓存操作。 + +### Cache Aside Pattern 的缺陷 + +Cache Aside Pattern是一种常见的缓存更新策略,主要在读取数据时用于处理缓存的失效和更新。尽管它有很多优点,但也存在一些缺陷: + +**缺陷1:首次请求数据一定不在 cache 的问题** + +解决办法:可以将热点数据提前放入cache 中。 + +**缺陷2:写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率 。** + +- 数据库和缓存数据强一致场景 :更新DB的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。 +- 可以短暂地允许数据库和缓存数据不一致的场景 :更新DB的时候同样更新cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。 + +## 延时双删 + +Redis的延时双删策略主要用于解决分布式系统当中的缓存与数据库数据一致性问题。以下是其基本步骤: + +1. 先删除缓存。 +2. 再更新数据库。 +3. 最后延时再次删除缓存。 + +该策略的理念是:如果有其他线程在步骤1和步骤2之间查询到旧的数据并写入了缓存,那么步骤3可以保证这部分旧的数据被清除,从而尽可能维持数据库和缓存之间的数据一致性。 + +以下是使用Java实现的样例代码: + +```java +import redis.clients.jedis.Jedis; + +public class RedisDoubleDelStrategy { + private Jedis jedis; + private static final long DELAY_MILLIS = 1000L; // 设置为你需要的延时时间 + + public RedisDoubleDelStrategy(String host, int port) { + this.jedis = new Jedis(host, port); + } + + public void updateDBAndCache(String key, String value) { + // Step 1: 删除缓存 + jedis.del(key); + + // Step 2: 更新数据库,此处以打印输出代替 + System.out.println("Update DB with: " + value); + + // 延迟任务来完成第二次删除 + new Thread(() -> { + try { + Thread.sleep(DELAY_MILLIS); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + // Step 3: 延时后再次删除缓存 + jedis.del(key); + }).start(); + } +} +``` + +这段代码实现了延时双删策略,但请注意它仍然不能完全保证数据库和缓存之间的一致性。 + +在某些情况下(比如大量并发情况下),可能仍然会出现不一致的问题。例如,在步骤3之后,如果还有其他线程查询到了旧数据并写入了缓存,那么数据库和缓存的数据就会不一致。因此,在使用该策略时,需要根据你的系统特性和一致性需求来进行权衡。 + + + +本篇文章到这就结束了,在探讨Redis与MySQL双写问题的过程中,我们分析了各种可能的场景和解决方案。双写系统不仅考验我们对数据库原理的理解,也展示了协同工作的复杂性。最终,解决这个问题的关键是理解你的用例并根据实际需求选择适当的策略和工具。 + +而在实际应用中,再完美的方案也可能会遇到挑战和困难。因此,持续监控,频繁测试和及时调整策略都至关重要。希望本文能为你在处理Redis与MySQL双写问题上提供一些思路和灵感,同时,我们也期待在未来看到更多精妙的解决方案诞生。 diff --git "a/docs/md/redis/\351\235\236\347\234\213\344\270\215\345\217\257\347\232\204Redis\346\214\201\344\271\205\345\214\226.md" "b/docs/md/redis/\351\235\236\347\234\213\344\270\215\345\217\257\347\232\204Redis\346\214\201\344\271\205\345\214\226.md" deleted file mode 100644 index 07310ca..0000000 --- "a/docs/md/redis/\351\235\236\347\234\213\344\270\215\345\217\257\347\232\204Redis\346\214\201\344\271\205\345\214\226.md" +++ /dev/null @@ -1,269 +0,0 @@ -[TOC] - -## 写在前面 - -Redis的持久化,这部分的知识点不仅求职面试的时候是重点,工作中也是经常打交道。说起持久化都会想到RDB和AOF,但是里面有些细节是可以展开去聊的。比如:为什么 fork速度这么快?AOF是如何提高写入性能的?等问题。对这些疑问本文都会有所解答。 - -## 摘要 - -Redis是许多公司都在使用的一款高性能、非关系型数据库。其中最为重要的一个特性就是它支持持久化。本文将深入介绍Redis持久化原理,包括RDB和AOF两种方式的实现。 - -## Redis持久化介绍 - -Redis持久化分为两种:**RDB**(Redis DataBase)和**AOF**(Append Only File)。RDB是指将Redis内存中的数据定期写入磁盘上的一个快照文件中;而AOF则是以追加的方式记录Redis执行的每一条写命令。**你也可以同时开启两种持久化方式,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据**。 - -你也许会问,为什么需要持久化呢?因为Redis作为一款内存数据库,在进程异常退出或服务器断电之后,所有的数据都将消失。如果没有持久化功能,无法保证数据的持久性,那么这样的数据库还有什么用呢? - -因此,Redis提供了持久化功能,以确保数据的可靠性和持久性。接下来,我们将分别介绍RDB和AOF的实现原理。 - -## RDB原理 - -**RDB是Redis默认的持久化方式,它将Redis在内存中的数据定期写入到硬盘中,生成一个快照文件**。快照文件是一个二进制文件,包含了Redis在某个时间点的所有数据。 - -RDB的优点是快速、简单,适用于大规模数据备份和恢复。但是,RDB也有缺点,例如数据可能会丢失,因为Redis只会在指定的时间点生成快照文件。**如果在快照文件生成之后,但在下一次快照文件生成之前服务器宕机,那么这期间的数据就会丢失**。 - -由于RDB文件是以二进制格式保存的,因此它非常紧凑,并且在Redis重启时可以迅速地加载数据。相比于AOF,RDB文件一般会更小。 - -RDB持久化有两种方式: - -- 手动 -- 自动 - -手动方式通过**SAVE**命令或**BGSAVE**命令进行。SAVE命令会阻塞Redis服务器,直到快照文件生成完成。BGSAVE命令会fork一个子进程(注意是子进程,不是子线程)在后台生成快照文件,不会阻塞Redis服务器。 - -自动方式则是在配置文件中设置, 让它在“ N 秒内数据集至少有 M 个改动”这一条件被满足时, 自动保存一次数据集。 - -比如说,以下设置会让 Redis 在满足 “10秒内有至少100 个键被改动” 这一条件时, 自动保存一次数据集。 - -``` -save 10 100 -``` - -### Fork函数与写时复制 - -在 Redis 中,fork 函数被用于创建子进程。Redis 的使用场景中通常有大量的读操作和较少的写操作,而 fork 函数可以利用 Linux 操作系统的写时复制(Copy On Write,即 COW)机制,让父子进程共享内存,从而减少内存占用,并且避免了没有必要的数据复制。 - -我们可以使用 **man fork** 来查看下说明文档。 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNoOxn37Dew03PaAtIibwOsqVsUNzsmVJnqQdZuzSic8uNanfh0PSFqGyxWMZxFQaqDgacNDAhYic4oA/0?wx_fmt=png) - -翻译:**在Linux下,fork()是使用写时复制的页面实现的,所以它唯一的代价是复制父进程的页表以及为子进程创建独特的任务结构所需的时间和内存**。 - -简单来说就是 fork()函数复制的是指针,不会复制数据,所以速度很快。 - -**需要注意的是,fork的这个过程主进程是阻塞的,fork完之后不阻塞**。 - -在 Redis 中,当执行 RDB 持久化操作时,Redis 会调用 fork 函数创建子进程,然后由子进程负责将数据写入到磁盘中。为了避免父子进程同时对内存中的数据进行修改导致数据不一致。Redis 会启用写时复制机制。**这样,当父进程修改内存中的数据时, Linux 内核会将该部分内存复制一份给子进程使用,从而保证父子进程间的数据互相独立**。 - -简单画了个示意图: - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNoOxn37Dew03PaAtIibwOsqk3TkklicRK8YvQkfZnF5mn7ZNP4DrWIV0Vcr7uztbTsv9NxiasEayVEQ/0?wx_fmt=png) - -**当没有发生写的时候,子进程和父进程指向地址是一样的,发生写的时候,就会拷贝出一块新的内存区域,实现父子进程隔离**。 - -通过使用 fork 函数和写时复制机制,Redis 可以高效地执行 RDB 持久化操作,并且不会对 Redis 运行过程中的性能造成太大的影响。同时,这种方式也提供了一种简单有效的机制来保护 Redis 数据的一致性和可靠性。 - -缺点:**RDB 需要经常fork子进程来保存数据集到硬盘上,当数据集比较大的时候,fork的过程是非常耗时的,可能会导致Redis在一些毫秒级内不能响应客户端的请求,数据集很大的时候,fork过程可能会持续数秒**。 - -#### 关于写时复制的思考 - -上述流程貌似有个问题。比如,有个键值对 **k1 a** 。此时正在bgsave,客户端发来一个请求,主进程发生写操作**set k1 b**,由于写时复制,此时子进程里k1值还是值a。最终持久化的也是a。 - -为什么不直接持久化新值而持久化旧值?写时复制的意义是什么? - -主要有2点原因: - -1. 其实Redis为了性能考虑,将内存的数据持久化是一个顺序写的操作,RDB备份是有一个过程的,这个过程是由子进程完成。子进程备份RDB是一个顺序写的过程,如果主进程的所有写入请求都随时记录到RDB文件中,那么理论更新key可能在任何位置,这是个随机写的过程,性能低。 -2. 其次如果主进程一直在写入的话,那么这次RDB备份一直都在写主进程写入的新值,永远不会停止。 - -### RDB相关配置 - -1. **save **:指定 RDB 持久化操作的条件。当 Redis 的数据发生变化,并且经过指定的时间(seconds)和变化次数(changes)后,Redis 会自动执行一次 RDB 操作。例如,save 3600 10000 表示如果 Redis 的数据在一个小时内发生了至少 10000 次修改,那么 Redis 将执行一次 RDB 操作。 -2. **stop-writes-on-bgsave-error **:指定在 RDB 持久化过程中如果出现错误是否停止写入操作。如果设置为 yes,当 Redis 在执行 RDB 操作时遇到错误时,Redis 将停止接受写入操作;如果设置为 no,Redis 将继续接受写入操作。 -3. **rdbcompression **:指定是否对 RDB 文件进行压缩。如果设置为 yes,Redis 会在生成 RDB 文件时对其进行压缩,从而减少磁盘占用空间;如果设置为 no,Redis 不会对生成的 RDB 文件进行压缩。 -4. **rdbchecksum **:指定是否对 RDB 文件进行校验和计算。如果设置为 yes,在保存 RDB 文件时,Redis 会计算一个 CRC64 校验和并将其追加到 RDB 文件的末尾;在加载 RDB 文件时,Redis 会对文件进行校验和验证,以确保文件没有受到损坏或篡改。 -5. **replica-serve-stale-data **:这是 Redis 4.0 中新增的一个配置项,用于指定复制节点在与主节点断开连接后是否继续向客户端提供旧数据。当设置为 yes 时,在复制节点与主节点断开连接后,该节点将继续向客户端提供旧数据,直到重新连接上主节点并且同步完全新的数据为止;当设置为 no 时,复制节点会立即停止向客户端提供数据,并且等待重新连接上主节点并同步数据。需要注意的是,当 replica-serve-stale-data 设置为 yes 时,可能会存在一定的数据不一致性问题,因此建议仅在特定场景下使用。 -6. **repl-diskless-sync **:这是 Redis 2.8 中引入的一个配置项,用于指定复制节点在进行初次全量同步(即从主节点获取全部数据)时是否采用无盘同步方式。当设置为 yes 时,复制节点将通过网络直接获取主节点的数据,并且不会将数据存储到本地磁盘中;当设置为 no 时,复制节点将先将主节点的数据保存到本地磁盘中,然后再进行同步操作。**采用无盘同步方式可以避免磁盘 IO 操作对系统性能的影响,但同时也会增加网络负载和内存占用。因此,应该根据具体的场景和需求选择合适的同步方式**。 - -## AOF原理 - -AOF持久化是按照Redis的写命令顺序将写命令追加到磁盘文件的末尾。AOF是一种基于日志的持久化方式,它保存了Redis服务器所有写入操作的日志记录,以保证数据的持久性、可靠性和完整性。AOF持久化技术的核心思想是将Redis服务器执行的所有写命令追加到一个文件中。当Redis服务器重新启动时,可以通过重新执行AOF文件来恢复服务器的状态。 - -AOF有个比较好的优势是可以恢复误操作。举个例子,如果你不小心执行了 **FLUSHALL** 命令,但只要 AOF 文件未被重写,那么只要停止服务器,移除 AOF 文件末尾的 FLUSHALL 命令,并重启 Redis ,就可以将数据集恢复到 FLUSHALL 执行之前的状态,重写了就没办法了。 - -### AOF持久化配置 - -Redis的AOF持久化配置频率可通过`appendfsync` 参数进行控制。该参数有以下三个选项: - -1. `always`: 每次有数据修改都立即写入磁盘,是最安全的选项。 -2. `everysec`: 每秒钟写入一次,性能和安全之间做了一个平衡。 -3. `no`: 从不主动写入,完全依靠操作系统自身的缓存机制来决定何时将数据写入磁盘。 - -默认情况下,Redis的`appendfsync`参数设置为`everysec`。如果需要提高持久化安全性,可以将其改为`always`;如果更关注性能,则可以将其改为`no`。但是需要注意的是,使用`no`可能会导致数据丢失的风险,建议在应用场景允许的情况下谨慎使用。 - -### AOF文件解读 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNoOxn37Dew03PaAtIibwOsqBf9v4M4AFwiaZ9Ur5pdj2HicDtRzeCfX1CY3NFjib5w0es0N2h1ibbJjuA/0?wx_fmt=png) - -这是一个简单的AOF文件示例。 - -- *号:表示参数个数,后面紧跟着参数的长度和值。 -- $号:表示参数长度,后面紧跟着参数的值。 - -实际上AOF文件中保存的所有命令都遵循相同的格式,即以*开头表示参数个数,$开头表示参数长度,其后紧跟着参数的值。 - -### AOF文件修复 - -服务器可能在程序正在对 AOF 文件进行写入时停机,造成AOF 文件出错。 - -可以使用 Redis 自带的 **redis-check-aof** 程序,对原来的 AOF 文件进行修复: - -``` -$ redis-check-aof –fix -``` - -### AOF重写 - -Redis的AOF重写机制指的是将AOF文件中的冗余命令删除,以减小AOF文件的大小并提高读写性能的过程。 - -Redis的AOF重写机制采用了类似于复制的方式,首先将内存中的数据快照保存到一个临时文件中,然后遍历这个临时文件,只保留最终状态的命令,生成新的AOF文件。 - -具体来说,Redis执行AOF重写可以分为以下几个步骤: - -1. 开始AOF重写过程,向客户端返回一个提示信息。 -2. 创建一个临时文件,并将当前数据库中的键值对写入到临时文件中。 -3. 在创建的临时文件中将所有的写命令都转换成Redis内部的表示格式,即使用一系列的Redis命令来表示一个操作,例如使用SET命令来表示对某个键进行赋值操作。 -4. 对临时文件进行压缩,去掉多余的空格和换行符等,减小文件体积。 -5. 将压缩后的内容写入到新的AOF文件中。 -6. 停止写入命令到旧的AOF文件,并将新的AOF文件的文件名替换为旧的AOF文件的文件名。 -7. 结束AOF重写过程,并向客户端发送完成提示信息。 - -通过AOF重写机制,Redis可以在不停止服务的情况下减小AOF文件的大小,提高读写性能,同时也可以保证数据的一致性。 - -Redis提供了手动触发AOF重写的命令**BGREWRITEAOF**。可以在Redis的客户端中执行该命令来启动AOF重写过程。Redis 2.2 需要自己手动执行 BGREWRITEAOF 命令; Redis 2.4 则可以自动触发 AOF 重写。 - -具体操作步骤如下: - -1. 打开redis-cli命令行工具,连接到Redis服务。 - -2. 执行BGREWRITEAOF命令,启动AOF重写过程。 - - ``` - $ redis-cli - 127.0.0.1:6379> BGREWRITEAOF - ``` - -3. Redis会返回一个后台任务的ID,表示AOF重写任务已经开始。 - - ``` - 127.0.0.1:6379> BGREWRITEAOF - Background append only file rewriting started by pid 1234 - ``` - -4. 可以使用**INFO PERSISTENCE**命令查看当前AOF文件的大小和重写过程的状态,等待重写完成即可。 - - ``` - 127.0.0.1:6379> INFO PERSISTENCE - # Persistence - aof_enabled:1 - aof_rewrite_in_progress:1 - aof_rewrite_scheduled:0 - aof_last_rewrite_time_sec:0 - aof_current_rewrite_time_sec:14 - aof_last_bgrewrite_status:ok - aof_last_write_status:ok - ``` - -需要注意的是,执行BGREWRITEAOF命令可能会占用较多的CPU和内存资源,因此在生产环境中需要谨慎使用,并确保有足够的系统资源支持。同时,即使手动触发AOF重写,Redis也会在满足一定条件时自动触发AOF重写,以保证AOF文件的大小和性能。 - -**在版本号大于等于 2.4 的 Redis 中,BGSAVE 执行的过程中,不可以执行 BGREWRITEAOF 。反过来说,在 BGREWRITEAOF 执行的过程中,也不可以执行 BGSAVE。这可以防止两个 Redis 后台进程同时对磁盘进行大量的 I/O 操作**。 - -### AOF缓冲区与AOF重写缓存区 - -在Redis中,AOF缓冲区和AOF重写缓冲区是两个不同的概念。 - -AOF缓冲区是一个用于暂存需要写入AOF文件的命令的缓冲区。在Redis处理客户端发来的写命令时,如果开启了AOF持久化功能,则该命令将被先写入到AOF缓冲区。AOF缓冲区中的内容通过配置的规则持久化到磁盘上。持久化规则可以通过配置项`appendfsync`来调整。 - -AOF重写缓冲区是一个用于执行AOF文件的重写操作的缓冲区。AOF重写操作是一种将现有AOF文件重写成最小化的新AOF文件的操作。AOF重写操作的目的是减少AOF文件的大小,同时加快恢复速度。**AOF重写缓存区在AOF重写时开始启用,Redis服务器主进程在执行完写命令之后,会同时将这个写命令追加到AOF缓冲区和AOF重写缓冲区**。 - -需要注意的是,AOF缓冲区和AOF重写缓冲区是不同的概念,但它们都使用了Redis的内存缓冲机制,并且都会影响Redis服务器的性能。因此,在实际使用中,应该合理配置AOF缓冲区大小和AOF重写规则,以确保Redis服务器的正常运行和高性能。 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNoOxn37Dew03PaAtIibwOsq03EkG6bTf2oO5yPXzQVnndLBNibTbbtwKOojqOh8Bhng22e3N4Nurwg/0?wx_fmt=png) - -### AOF缓冲区可以替代AOF重写缓冲区吗? - -AOF缓冲区不可以替代AOF重写缓冲区的原因是**AOF重写缓冲区记录的是从重写开始后的所有需要重写的命令,而AOF缓冲区可能只记录了部分的命令(如果写回的话,AOF缓存区的数据就会失效被丢失,因而只会保存一部分的命令,而AOF重写缓存区不会)**。 - -AOF缓冲区就主要是Redis用来解决主进程执行命令速度与磁盘写入速度不同步所设置的,通过AOF缓冲区可以有效地避免频繁对硬盘进行读写,进而提升性能。Redis在AOF持久化的时候,会先把命令写入到AOF缓冲区,然后通过回写策略来写入硬盘AOF文件。 - -### AOF相关配置 - -在 Redis 的配置文件 redis.conf 中,可以通过以下配置项来设置 AOF 相关参数: - -1. **appendonly**:该配置项用于开启或关闭 AOF,默认为关闭。若开启了 AOF,Redis 会在每次执行写命令时,将命令追加到 AOF 文件末尾。 -2. **appendfilename**:用于设置 AOF 文件名,默认为 appendonly.aof。 -3. **appendfsync**:该配置项用于设置 AOF 的同步机制。有三种可选值: - - always:表示每个写命令都要同步到磁盘,安全性最高,但是性能较差。 - - everysec:表示每秒同步一次,是默认选项,既能保证数据安全,又具有较好的性能。 - - no:表示不进行同步,而是由操作系统决定何时将缓冲区中的数据同步到磁盘上,性能最好,但是安全性较低。 -4. **auto-aof-rewrite-percentage**和**auto-aof-rewrite-min-size**:这两个配置项用于设置 AOF 重写规则。当 AOF 文件大小超过 auto-aof-rewrite-min-size 设置的值,并且 AOF 文件增长率达到 auto-aof-rewrite-percentage 所定义的百分比时,Redis 会启动 AOF 重写操作。auto-aof-rewrite-percentage:默认值为100,以及auto-aof-rewrite-min-size:64mb 配置,也就是说默认Redis会记录上次重写时的AOF大小,**默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发。** -5. **aof-use-rdb-preamble**:Redis 4版本新特性,混合持久化。AOF重写期间是否开启增量式同步,该配置项在AOF重写期间是否使用RDB文件内容。默认是no,如果设置为yes,在AOF文件头加入一个RDB文件的内容,可以尽可能的减小AOF文件大小,同时也方便恢复数据。 - -### 写后日志 - -我们比较熟悉的是数据库的写前日志(Write Ahead Log,WAL),也就是说,在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复。不过,AOF 日志正好相反,它是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志。 - -why? - -为了避免额外的检查开销,**Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查**。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。 - -而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。 - -除此之外,AOF 还有一个好处:**它是在命令执行后才记录日志,所以不会阻塞当前的写操作**。 - -不过,AOF 也有两个潜在的风险。 - -首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时 Redis 是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。 - -其次,AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。 - -## 混合持久化 - -在过去, Redis 用户通常会因为 RDB 持久化和 AOF 持久化之间不同的优缺点而陷入两难的选择当中: - -- RDB 持久化能够快速地储存和恢复数据,但是在服务器停机时可能会丢失大量数据。 -- AOF 持久化能够有效地提高数据的安全性,但是在储存和恢复数据方面却要耗费大量的时间。 - -为了让用户能够同时拥有上述两种持久化的优点, Redis 4.0 推出了一个“鱼和熊掌兼得”的持久化方案 —— RDB-AOF 混合持久化: **这种持久化能够通过 AOF 重写操作创建出一个同时包含 RDB 数据和 AOF 数据的 AOF 文件, 其中 RDB 数据位于 AOF 文件的开头, 它们储存了服务器开始执行重写操作时的数据库状态。至于那些在重写操作执行之后执行的 Redis 命令, 则会继续以 AOF 格式追加到 AOF 文件的末尾, 也即是 RDB 数据之后**。 - -说就是说开启混合持久化之后,AOF文件中的内容:前半部分是二进制的RDB内容,后面跟着AOF增加的数据,AOF位于2次RDB之间。格式类似下面这样: - -``` -(二进制)RDB - AOF -(二进制)RDB -``` - -在目前版本中, RDB-AOF 混合持久化功能默认是处于关闭状态的, 为了启用该功能, 用户不仅需要开启 AOF 持久化功能, 还需要将 `aof-use-rdb-preamble` 选项的值设置为 true。 - -``` -appendonly yes -aof-use-rdb-preamble yes -``` - -## 如何选择合适的持久化方式 - -当你想选择适合你的应用程序的持久化方式时,你需要考虑以下几个因素: - -1. 数据的实时性和一致性:如果您对数据的实时性和一致性有很高的要求,则AOF可能是更好的选择。 -2. 如果您对数据的实时性和一致性要求不太高,并且希望能快速地加载数据并减少磁盘空间的使用,那么RDB就可能更适合您的应用程序。因为RDB文件是二进制格式的,所以它很紧凑,并且在Redis重启时可以迅速地加载数据。 -3. Redis的性能需求:如果您对Redis的性能有很高的要求,那么关闭持久化功能也是一个选择。因为持久化功能可能会影响Redis的性能,但是一般不建议这么做。 - -## 总结 - -通过本文的介绍,我们了解了Redis持久化的两种方式,RDB和AOF,它们都有自己独特的优势,我们应该根据项目大小、数据量和业务需求制定不同的持久化策略。 - ------- - -本篇文章就到这里,感谢阅读,如果本篇博客有任何错误和建议,欢迎给我留言指正。文章持续更新,可以关注公众号第一时间阅读。 -![](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) \ No newline at end of file diff --git "a/docs/md/spring/\345\246\202\344\275\225\344\274\230\351\233\205\345\234\260Spring\344\272\213\345\212\241\347\274\226\347\250\213.md" "b/docs/md/spring/\345\246\202\344\275\225\344\274\230\351\233\205\345\234\260Spring\344\272\213\345\212\241\347\274\226\347\250\213.md" new file mode 100644 index 0000000..58004a3 --- /dev/null +++ "b/docs/md/spring/\345\246\202\344\275\225\344\274\230\351\233\205\345\234\260Spring\344\272\213\345\212\241\347\274\226\347\250\213.md" @@ -0,0 +1,315 @@ +在开发中,有时候我们需要对 Spring 事务的生命周期进行监控,比如在事务提交、回滚或挂起时触发特定的逻辑处理。那么如何实现这种定制化操作呢? + +Spring 作为一个高度灵活和可扩展的框架,早就提供了一个强大的扩展点,即事务同步器 TransactionSynchronization 。通过 TransactionSynchronization ,我们可以轻松地控制事务生命周期中的关键阶段,实现自定义的业务逻辑与事务管理的结合。 + +```java +package org.springframework.transaction.support; + +import java.io.Flushable; + +public interface TransactionSynchronization extends Flushable { + /** 事务提交状态 */ + int STATUS_COMMITTED = 0; + /** 事务回滚状态 */ + int STATUS_ROLLED_BACK = 1; + /**系统异常状态 */ + int STATUS_UNKNOWN = 2; + //挂起该事务同步器 + default void suspend() { + } + //恢复事务同步器 + default void resume() { + } + //flush底层的session到数据库 + default void flush() { + } + // 事务提交之前 + default void beforeCommit(boolean readOnly) { + } + // 操作完成之前(包含commit/rollback) + default void beforeCompletion() { + } + // 事务提交之后 + default void afterCommit() { + } + // 操作完成之后(包含commit/rollback) + default void afterCompletion(int status) { + } +} +``` + +TransactionSynchronization 是一个接口,它里面定义了一系列与事务各生命周期阶段相关的方法。比如,我们可以这样使用: + +```java +public class UserService { + + @Transactional(rollbackFor = Exception.class) + public void saveUser(User user) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + System.out.println("saveUser事务已提交..."); + } + }); + userDao.saveUser(user); + } +} +``` + +在 Spring 事务刚开始的时候,我们向 TransactionSynchronizationManager 事务同步管理器注册了一个事务同步器,事务提交前/后,会遍历执行事务同步器中对应的事务同步方法(一个 Spring 事务可以注册多个事务同步器)。 + +需要注意的是注册事务同步器必须得在一个 Spring 事务中才能注册,否则会抛出 **Transaction synchronization is not active** 这个错误。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNWlTdUWx3D2HR1r9d1zIrK9UbI49tE8gY779mYRj7icJyXiatddU1HjXrb85Zedo1ibVcKNQrQxEiaBA/640) + +`isSynchronizationActive()` 方法用来判断当前是否存在事务(判断线程共享变量,是否存在 TransactionSynchronization) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNWlTdUWx3D2HR1r9d1zIrKHlqfichM1zaxpmWkmyia25SZD24n4cLnAiaTvTC6n4EKyr91aUQLNqLgQ/640) + +Spring 在创建事务的时候,会初始化一个空集合放到 synchronizations 属性中,所以只要当前存在事务,`isSynchronizationActive()` 就为 true。 + +## TransactionSynchronizationManager 解析 + +Spring 对于事务的管理都是基于 `TransactionSynchronizationManager` 这个类,先看下 TransactionSynchronizationManager 的一些属性: + +```java + private static final ThreadLocal> resources = new NamedThreadLocal("Transactional resources"); + private static final ThreadLocal> synchronizations = new NamedThreadLocal("Transaction synchronizations"); + private static final ThreadLocal currentTransactionName = new NamedThreadLocal("Current transaction name"); + private static final ThreadLocal currentTransactionReadOnly = new NamedThreadLocal("Current transaction read-only status"); + private static final ThreadLocal currentTransactionIsolationLevel = new NamedThreadLocal("Current transaction isolation level"); + private static final ThreadLocal actualTransactionActive = new NamedThreadLocal("Actual transaction active"); +``` + +- resources:保存连接资源,因为一个方法里面可能包含多个事务,所以就用 Map 来保存资源, key为 DataSource,value 为connectionHolder。线程可以通过该属性获取到同一个 Connection 对象。 +- synchronizations:事务同步器,是 Spring 交由程序员进行扩展的代码,每个线程可以注册N个事务同步器。 +- currentTransactionName:事务的名称。 +- currentTransactionReadOnly:事务是否是只读。 +- currentTransactionIsolationLevel:事务的隔离级别。 +- actualTransactionActive:用于保存当前事务是否还是 Active 状态(事务是否开启)。 + +Spring 创建事务时,DataSourceTransactionManager.doBegin 方法中,将新创建的 connection 包装成 connectionHolder ,通过 TransactionSynchronizationManager#bindResource 方法存入 resources 中。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNWlTdUWx3D2HR1r9d1zIrKvbHKZDuU5UhotKmVIU0ONUPZTr11jJFODIgeofM7ZLc64IhBYibtPoA/640) + +然后标注到一个事务当中的其它数据库操作就可以通过 TransactionSynchronizationManager#getResource 方法获取到这个连接。 + +```java + @Nullable + public static Object getResource(Object key) { + Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key); + Object value = doGetResource(actualKey); + if (value != null && logger.isTraceEnabled()) { + logger.trace("Retrieved value [" + value + "] for key [" + actualKey + "] bound to thread [" + Thread.currentThread().getName() + "]"); + } + + return value; + } + + @Nullable + private static Object doGetResource(Object actualKey) { + Map map = (Map)resources.get(); + if (map == null) { + return null; + } else { + Object value = map.get(actualKey); + if (value instanceof ResourceHolder && ((ResourceHolder)value).isVoid()) { + map.remove(actualKey); + if (map.isEmpty()) { + resources.remove(); + } + + value = null; + } + + return value; + } + } +``` + +从上面我们也能看到,Spring 对于多个数据库操作的事务实现是基于 ThreadLocal 的,所以 Spring 事务操作是无法使用多线程的。 + +## 应用场景 + +`TransactionSynchronization` 可以用于一些需要在事务结束后执行清理操作或其他相关任务的场景。 + +应用场景举例: + +- **资源释放**:在事务提交或回滚后释放资源,如关闭数据库连接、释放文件资源等。 +- **日志记录**:在事务结束后记录相关日志信息,例如记录事务的执行结果或异常情况。 +- **缓存更新**:在事务完成后更新缓存数据,保持缓存和数据库数据的一致性。 +- **消息通知**:在事务结束后发送消息通知相关系统或用户,如发送邮件或短信通知。 + +举例: 假设一个电商系统中存在订单支付的业务场景,当用户支付订单时,需要在事务提交后发送订单支付成功的消息通知给用户。 + +由于事务是和数据库连接相绑定的,如果把发送消息和数据库操作放在一个事务里面。当发送消息时间过长时会占用数据库连接,所以就要把数据库操作与发送消息到 MQ 解耦开来。 + +这时就可以通过 `TransactionSynchronization` 来实现在事务提交后发送消息通知的功能。具体示例代码如下: + +```java +@Component +public class OrderPaymentNotification implements TransactionSynchronization { + + private String orderNo; + + public OrderPaymentNotification(String orderNo) { + this.orderNo = orderNo; + } + + @Override + public void beforeCommit(boolean readOnly) { + // 在事务提交前不执行任何操作 + } + + @Override + public void beforeCompletion() { + // 在事务即将完成时不执行任何操作 + } + + @Override + public void afterCommit() { + // 在事务提交后发送订单支付成功的消息通知 + sendMessage("订单支付成功", orderNo); + } + + @Override + public void afterCompletion(int status) { + // 在事务完成后不执行任何操作 + } + + private void sendMessage(String message, String orderNo) { + // 发送消息通知的具体实现逻辑 + System.out.println(message + ": " + orderNo); + } +} +``` + +```java + @Transactional + public void finishOrder(String orderNo) { + // 修改订单成功 + updateOrderSuccess(orderNo); + // 发送消息到 MQ + TransactionSynchronizationManager.registerSynchronization(new OrderPaymentNotification(orderNo)); + } +``` +这样当事务成功提交之后,就会把消息发送给 MQ,并且不会占用数据库连接资源。 + +## @TransactionalEventListener + +在 Spring Framework 4.2版本后还可以使用 @TransactionalEventListener 注解处理数据库事务提交成功后的执行操作。 + +```java +@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@EventListener +public @interface TransactionalEventListener { + TransactionPhase phase() default TransactionPhase.AFTER_COMMIT; + + // 表明若没有事务的时候,对应的event是否需要执行,默认值为false表示,没事务就不执行了。 + boolean fallbackExecution() default false; + + @AliasFor( + annotation = EventListener.class, + attribute = "classes" + ) + Class[] value() default {}; + + @AliasFor( + annotation = EventListener.class, + attribute = "classes" + ) + Class[] classes() default {}; + + String condition() default ""; +} + + + +public enum TransactionPhase { + // 在事务commit之前执行 + BEFORE_COMMIT, + // 在事务commit之后执行 + AFTER_COMMIT, + // 在事务rollback之后执行 + AFTER_ROLLBACK, + // 在事务完成后执行(包括commit/rollback) + AFTER_COMPLETION; + + private TransactionPhase() { + } +} + +``` + +从命名上可以直接看出,它就是个 `EventListener`,效果跟 TransactionSynchronization 一样,但比 TransactionSynchronization 更加优雅。它的使用方式如下: + +```Java +@Data +public class Order { + + private Long orderId; + private String orderNumber; + private BigDecimal totalAmount; +} + +@Service +public class OrderService { + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private ApplicationEventPublisher eventPublisher; + + @Transactional + public void createOrder(Order order) { + // 保存订单逻辑 + System.out.println("Creating order: " + order.getOrderNumber()); + + orderRepository.save(order); + + // 发布订单创建事件 + OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent(order); + eventPublisher.publishEvent(orderCreatedEvent); + } +} + +@Getter +@Setter +public class OrderCreatedEvent { + + private Order order; + + public OrderCreatedEvent(Order order) { + this.order = order; + } +} + +@Component +@Slf4j +public class OrderEventListener { + + @Autowired + private EmailService emailService; + + /* + * @Async加了就是异步监听,没加就是同步(启动类要开启@EnableAsync注解) + * 可以使用@Order定义监听者顺序,默认是按代码书写顺序 + * 可以使用SpEL表达式来设置监听器生效的条件 + * 监听器可以看做普通方法,如果监听器抛出异常,在publishEvent里处理即可 + */ + + @Async + @Order(1) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, classes = OrderCreatedEvent.class) + public void onOrderCreatedEvent(OrderCreatedEvent event) { + // 处理订单创建事件,例如发送邮件通知 + log.info("Received OrderCreatedEvent for order: " + event.getOrder().getOrderNumber()); + emailService.sendOrderConfirmationEmail(event.getOrder()); + } +} +``` + +都看到这里了,如果觉得有帮助,还请您给我个小小的鼓励,动动手指,帮忙点个赞或收藏!谢谢喽,如果觉得文章有误,欢迎在评论区留言指正,我会第一时间讨论并改正!!! diff --git "a/docs/md/\345\205\266\344\273\226/Maven\345\256\236\346\210\230.md" "b/docs/md/\345\205\266\344\273\226/Maven\345\256\236\346\210\230.md" new file mode 100644 index 0000000..272fdb2 --- /dev/null +++ "b/docs/md/\345\205\266\344\273\226/Maven\345\256\236\346\210\230.md" @@ -0,0 +1,672 @@ +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfaaUxnw8IDuicNESzxXE0vOCwHptdhCz9B6QQtOWfGdeuNPelQbYqxxg/640?wx_fmt=png&from=appmsg) + +# Maven实战 + +## 一、Maven介绍 + +### 1.1 现存问题 + +jar包问题 + +* jar包需要在本地保存,而且在使用的时候需要将jar复制到项目中,再build才可以生效。 +* jar包的体量不小,一个项目中可能需要上百的jar的支持,这样一个项目就太大了。 +* 如果jar包的版本需要升级,需要重新去搜集新版本的jar包,重新去build,时间成本太高了。 +* 做一些功能时,可能需要因为几个,甚至十几个jar包,才能完成一个功能,都需要自己维护,甚至记住。 + +项目结构的问题 + +* 之前开发工具很多,有Eclipse,MyEclipse,IDEA,VSCode等等……不同的开发工具的项目的结构会有一些不同,多人协同开发时,就会造成冲突,甚至还需要统一开发工具。 + +整体项目的生命流程 + +* 整个项目从立项开发,到最后的发布上线到生产环境,没有一套统一的流程来控制。 + +### 1.2 Maven + +- Maven可以帮助我们更好地去管理jar包,只需要指定好jar的一些基本的标识,就可以让jar包支持我们的项目。而且Maven可以帮助咱们导入一个jar包后,自动将和它绑定好的其他jar包引入。 +- Maven可以提供一个统一的项目结构。 +- Maven也对整体项目的生命周期有响应的管理,从开始的编译、测试、打包、部署等操作,都提供了相应的支持。 +- Maven还提供了分模块开发的功能。 + +Maven是apache组织的一个顶级开源项目。 http://maven.apache.org + +## 二、Maven安装&环境变量配置 + +### 2.1 Maven的安装 + +首先下载Maven,直接去官网即可 + +在点击Download之后,需要注意看一下对JDK版本的支持。 + +Maven需要JDK的环境变量支持,一定要看一下自己又没有设置上JAVA_HOME + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfQFcQNzsTx4Ga7xianbEIPEJxnaH8ibj2HDMNwWMcn0cMe5H6NAsGRxnw/640?wx_fmt=png&from=appmsg) + +需要根据自己的环境变量,下载对应的压缩包。 + +Linux、Mac选择.tar.gz的压缩包 + +Windows选择zip的压缩包 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfUIITxI3eZkQTPlAYXSB2gKsua68braYNnyXwcQVkUva7Lr1Jv9heMg/640?wx_fmt=png&from=appmsg) + +下载好之后,得到一个压缩包。 + +解压的目录最好没有任何的中文和空格等特殊字符。推荐放到磁盘的根目录下即可。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfOWicdcoIEQibfats3LyAvORzE9D35Pb4qaNmHYrJQFlR1tRFUR2sMshg/640?wx_fmt=png&from=appmsg) + +> bin:含有mvn运行的脚本。 +> +> boot:含有类加载器框架,Maven使用这个框架来加载自己的类库。 +> +> conf:含有非常核心的settings.xml文件。 +> +> lib:含有Maven运行时需要的一些类库。 + +### 2.2 Maven的环境变量的配置 + +首先配置Maven的环境变量前,必须先查看一下JDK环境变量配置。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tftwTSkK9RXff4d6ibDHJVuOicRgWup6E85sOYtUzTOuFkpeicZCEEKq1icA/640?wx_fmt=png&from=appmsg) + +其次,查看一下前面说过的JAVA_HOME。 + +上述两点都ok的话,直接开始配置环境变量 + +* 配置MAVEN_HOME![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfIapibng6RhD67b8WVboH8vQeQQbvHMtgVx5QwhVhC0ib8vBHTC4NjNJA/640?wx_fmt=png&from=appmsg) +* 配置到path![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfl15jvKDGxlsJIVLrBdJWYZc5jW5RgJ5TQQib3aK7kvtOrFCMEnh2LNA/640?wx_fmt=png&from=appmsg) + +**配置完毕后,记得重新打开一下cmd窗口。别直接在之前的cmd窗口测试**。 + +在cmd窗口执行mvn -v + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tffGpHQds8Jk1ONP8k5pPKPtVqD4TcNGWceC4YF7k1XLW3KlLJl5z1yA/640?wx_fmt=png&from=appmsg) + +> Ps:常见错误,没有配置正确的JAVA_HOME + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfto4PQfHW3F3eZPzqqZvwjk5JAt56H8ODN1qOiauoO3Ngcic27yxNibg6w/640?wx_fmt=png&from=appmsg) + +## 三、仓库&settings.xml配置(重要) + +### 3.1 仓库 + +Maven可以帮助咱们管理jar文件,但是,jar包是需要从网上下载下来的。 + +仓库很多,有官方的中央仓库,还有国内公司的仓库,还有公司内部会搭建的私服 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfB4U2Vbqn7CkqalibtOOKC28TBANS4SjqU8hxDfLOefFuiaFAU0lBicOow/640?wx_fmt=png&from=appmsg) + +咱们后面需要配置好国内公司的一些仓库。 + +### 3.2 settings.xml配置(重要) + +在MAVEN_HOME目录下,有一个conf目录。在conf目录下就有需要修改的settings.xml文件。 + +需要修改三点内容 + +#### 3.2.1 本地仓库地址 + +默认情况下,本地仓库在C盘。 + +> Default: ${user.home}/.m2/repository + +根据配置文件中的注释,默认是仍在用户目录下的.m2目录下的repository目录中。 + +这个本地仓库会随着项目越来越多,这个仓库也会越来越大。可能会占用10多个G,甚至更多。 + +所以推荐放在系统盘之外。(如果就C盘,那就用默认的吧…………) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfTO2L2yqCOmCnJ97t2HWPJfhR0V7CfaF6vTu2icXSyOcs68C83rgrqbQ/640?wx_fmt=png&from=appmsg) + +#### 3.2.2 配置阿里云/华为云……仓库 + +配置阿里云仓库 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfx5G3xZIOpZFDwTCbiaLIH9k5cJ5WguUceuZKyd1QEKOmu26tBOQ99dg/640?wx_fmt=png&from=appmsg) + +```xml + + + + aliyun + * + 阿里云公共仓库 + https://maven.aliyun.com/repository/public + + +``` + +华为云的仓库地址:`https://repo.huaweicloud.com/repository/maven/` + +#### 3.2.3 JDK编译版本配置 + +Maven默认采用JDK1.5的编译方式去编译项目。 +为了让Maven支持现在JDK的编译版本,可以指定一下现在采用JDK1.8 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfLKU1iabIV7BJfNXAFysiap4LfEibrHQIDnp7jA0CxMecSicOQ6UH4eza5g/640?wx_fmt=png&from=appmsg) + +```xml + + + + jdk1.8 + + true + 1.8 + + + 1.8 + 1.8 + 1.8 + + + + + + jdk1.8 + +``` + +## 四、IDEA配置Maven + +**先看老版本的,再看新版本的!!!** + +### 4.1 2019.1.3 IDEA配置Maven + +打开IDEA的初始窗口 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfMUsm8qlLPkRHIUk3V6H3oDXnXRO6giaRP2LI7Lj8HTSxFZZVykv9o5Q/640?wx_fmt=png&from=appmsg) + +右下角的Configure的位置打开settings,点开后,在左上角可以看到是Settings for New Projects + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfWJWokGI1o3ibPBKyMNlpaVVcCbvM5oQ4yzTukDgUtGz1MBZ00D9Aoqg/640?wx_fmt=png&from=appmsg) + +因为IDEA版本的原因,对Maven的版本也是有要求的。 + +比如现在的2019.1.3的IDEA版本,无法支撑3.6.1以上的Maven版本 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfD8wqEoiapGrYObGicPTibwy1JqgwkRiaN3yGycYE9oUhd6UBhkb7CBJ3QA/640?wx_fmt=png&from=appmsg) + +一定要记得,点击Apply,然后ok,确认生效。 + +### 4.2 2024.1 IDEA配置Maven + +首先一定要记住,选择Settings for new projects + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf0nZ8wBAOQXjTPdnCk1dEyQQp8noSutzD0BrdvxgPAlFKgjvqW7PFEA/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfXeNQ0sOiaHliaB2BQPsaPVCONZTno4sPgZicsiaSHms3mY3k4tvwpT23yA/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfw6tDNVk4FAVLGN2xTuqLKicQ00yWq4wTXbniciaPr13vfKNRNLhG0aOIA/640?wx_fmt=png&from=appmsg) + +## 五、IDEA构建Maven项目 + +**先看老版本的,再看新版本的!!!** + +### 5.1 2019.1.3 IDEA构建Maven项目 + +点击Create New Project + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf3KbIc02XzR0RBpxKeraYMeGfzDsYvIbLVYDwrg5uX7I5CI8DnjEhPA/640?wx_fmt=png&from=appmsg) + +next后,指定当前项目的三围,包名,项目名,版本号 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf98IQwUdg2eBpicQ8eE0LUkwIupWwqx53YjwAlLC707qaj4FsmE9tdNw/640?wx_fmt=png&from=appmsg) + +指定好项目名和存放地址。这里对存放地址修改一下就ok。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfXEtGUPwKT9icIOicU9HYOxUTpt5tk51hDgflrjicGaHdoMQ7T50IH09xA/640?wx_fmt=png&from=appmsg) + +指定好之后,点击Finish即可。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf7IFvQ5ptxrVLGykDWjYUGruhuaeM3UElPVljCJ6mvdfm6qR6bGtDicQ/640?wx_fmt=png&from=appmsg) + +进来后,可以看到右下角的进度条,在下载一些Maven必要的插件 + +在下载插件时,可能需要一定的时间,等插件下载好,为了确认咱们阿里云私服的配置是否生效,随便复制下面内容到当前位置。 **一定一定一定记得点击右下角的import Changes** + +```xml + + + org.springframework.boot + spring-boot-starter-web + 2.1.6.RELEASE + + +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfRwqpxbWwESricHicic12RZXHmy4Hia7ibF2TmmYiaJveNlPgAptcl70y5Ktw/640?wx_fmt=png&from=appmsg) + +快速地点击右下角的进度条,查看下载的链接地址,确认一下是否是阿里云的地址 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfrMF7OXYeOhDTsFrufy9BS15GoqeDMjELyyE2Vef5CCwKeMqSlqTibtA/640?wx_fmt=png&from=appmsg) + +再次查看右侧的Maven栏,确认profiles中的JDK1.8编译版本已经生效 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfiaz54Llbic4GyqkXtzJHiaYS8OZiaYnjXl96yVSh1rXnVheVSqCCFwV9AQ/640?wx_fmt=png&from=appmsg) + +最后查看完毕后,要对Maven项目的目录结构有个了解 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfU7SRWUK6rtCxWGMzD7fn2alKo6qbDRdOOVoic8uib7n2H3ToNygPXqqg/640?wx_fmt=png&from=appmsg) + +### 5.2 2014.1 IDEA构建Maven项目 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfSvM2T5G52mabcS1kNuhkVTGduhVuVPts7QXMFeibsm5nRr5ia6FqibRicg/640?wx_fmt=png&from=appmsg) + +### 5.3 IDEA构建Maven的Web项目 + +这个新老版本是一致的!!! + +这里是先构建Maven的基础项目,然后将基础项目修改为Web项目。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf5BbAOXRVfXGGsZiafgRiaPIUQ67VlgicaGr6ysuQOL7bYJibJuyNFvypIw/640?wx_fmt=png&from=appmsg) + +正常,构建的基础maven项目,打包的方式是jar文件。需要将当前web项目的打包方式修改为war的形式。 + +需要修改pom.xml文件指定打包方式。 + +默认情况下,这个packaging是jar的打包形式。需要指定好war的形式,一定一定一定记得import Changes + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tficicDsIPcrfznuf3LNP4IcI47wfAur5HHg1XsPkual82XUhQk74wmvtg/640?wx_fmt=png&from=appmsg) + +然后选中项目,点击左上角的file,选择Project Structure + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfUm0Ngx8UMPjic3eJWsN1DUlBlrL4laW5ViaaicTmpHdpcqicXJxicW1JT2w/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfh1D90QD3Via1Y7HSz4yuYDlkLVdZBw4FAs1vAG4GQ5EEpqibPM6PiavjQ/640?wx_fmt=png&from=appmsg) + +选择左侧导航栏中的facets选项,如果你的Facets界面没有这个Web,说明之前的war没配置好!! + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfLznicInlHbZZ42I5jb3xKbeGKVT3XzV7LYHxhowdtTZTk3ryMoAyRxQ/640?wx_fmt=png&from=appmsg) + +然后点击右上角的+,追加一个web.xml文件,记得一定要放到webapp资源目录下 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfzCQyRsgTkrchqC3zRtqvoa0AGrBKC83N0UtDBkb7kMIFLSEDiaNRJXg/640?wx_fmt=png&from=appmsg) + +点击ok,就会自动生成webapp目录,以及目录下的web.xml文件 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfpLMQ8TicvFCDx3vZdFM2YZMSDls2E1znUhPno9oKQtlUlaWibgGLug1A/640?wx_fmt=png&from=appmsg) + +## 六、导入依赖jar(重要) + +创建好Maven项目之后,需要导入具体的jar包时,要通过 **坐标** 导入 + +* 每个jar都需要三个内容形成一个唯一的坐标,需要groupId + artifactId + version导入一个具体的jar。 +* 在maven项目中,只需要导入配置的坐标,Maven便会自动地去网上下载jar文件,并且添加到项目中。 + +当需要使用某个jar时,知道大概的名字,但是不会背下来具体的坐标信息,可以去一个地方搜索 + +[https://mvnrepository.com/](https://mvnrepository.com/) + +可以去这个地址搜索具体的jar包坐标 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tficM7CwAK47BfdqMfiaLg9vr2PemPUJKgEgd4z1JjV7ibjLlebqubkVgFw/640?wx_fmt=png&from=appmsg) + +进入具体的依赖内部后,选择对应的版本 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfMQjBz5yWIjZARfHlOh4nicsNKicmB1vgfLFwLicNH4f8xp7Sr5zArkgrA/640?wx_fmt=png&from=appmsg) + +找到需要导入的dependency + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tficxAWicBF3mhMPoPupZdyabHw4TrtoqZjlqU0oicj1keQVuL52N4BibN0w/640?wx_fmt=png&from=appmsg) + +复制好之后,扔到项目的pom.xml文件中 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfqMPqZpyg3lCq4WlE7QjwubYtz750X0oibf1zz0sBKBNfTYDFQicWP0QQ/640?wx_fmt=png&from=appmsg) + +如果本地仓库出现了.lastUpdated后缀的文件,可能有两个情况 + +* 这个坐标的jar文件不存在 +* 因为网络原因下载失败了 + +**这种.lastUpdated后缀的文件,会导致后续依赖下载失败,记得如果出现了依赖失败,检查坐标都没问题,并且也是走阿里云或者华为云去下载的,依然失败。记得去本地仓库看一下,是不是有.lastUpdated后缀的文件导致无法下载成功**! + +## 七、依赖的作用域 + +所谓的依赖作用域就是当前这个jar文件在什么情况下,项目会使用到。 + +这个所谓的情况,可以分成三点来聊: + +* 编译阶段 +* 测试阶段 +* 运行阶段 + +Maven中给予依赖五种作用域: + +* compile(默认作用域):编译,测试,运行都会提供当前依赖的功能 + ``` + + commons-io + commons-io + 2.11.0 + + ``` +* provided:编译,测试会提供当前依赖的功能。 一般Servlet,JSP会涉及。 + ``` + + javax.servlet + javax.servlet-api + 3.1.0 + provided + + ``` +* runtime:测试,运行会提供当前依赖的功能。一般MySQL会涉及。 + ``` + + mysql + mysql-connector-java + 8.0.28 + runtime + + ``` +* test:测试会提供当前依赖的功能。 + ``` + + junit + junit + 4.13.2 + test + + ``` +* system:不是在什么情况下用,这个比较特殊,是将一些本地仓库没有的jar文件,引入到当前项目。 + ``` + + com.oracle.database.jdbc + ojdbc10 + 19.21.0.0 + system + D:/ojdbc10-19.21.0.0.jar + + ``` + +**system,不推荐用,哪怕一些依赖,本地仓库无法下载,也别用system去引入。这种引入方式会导致后期打包还是更换了环境之后,无法使用。(后面咱们会根据maven的命令,可以将本地的jar包安装到本地仓库)** + +```shell +mvn install:install-file -Dfile=D:/ojdbc10-19.21.0.0.jar -DgroupId=laozheng -DartifactId=laozheng-oracle -Dversion=yeyeye -Dpackaging=jar +``` + +搞定后,本地仓库可以看到install的jar文件和路径 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfOOyibtIXcQr6aDJzZq82aAXjOxEdCt4JnOSP9ibdoXyZqZnRgbiaXb3jQ/640?wx_fmt=png&from=appmsg) + +然后就可以在项目中引用了。 + +```xml + + laozheng + laozheng-oracle + yeyeye + +``` + +## 八、依赖冲突 + +首先,咱们要先了解一下Maven依赖的传递特性。 + +当咱们导入一个jar包后,如果这个jar为了完成一些功能,还需要其他的jar的功能。 + +比如有A,有B,其中A依赖了B。 + +咱们只需要导入A包,B会自动被依赖过来。优点大大的: + +* 不需要刻意的去记导入A之后,还需要导入什么其他的依赖。 +* 关于某个版本的A需要哪个版本的B也不需要关注。 + +上面是优点,但是也存在着一些问题。 + +当前项目 -> A -> B(1.0.0) + +当前项目 -> C -> B(2.0.0) + +此时,当前项目会出现相同的依赖,有两个,但是版本不一样,此时就会产生依赖冲突问题。 + +一般依赖冲突会在启动或者测试项目时,直接给你甩异常。而且这个依赖不太好处理。需要解决这种依赖冲突。 + +### 8.1 就近原则 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfAHwAsN3zB1FQiaOOsG2NWVhokiblxMa4lAgRst4RKhPlM8ImYFIqgZTg/640?wx_fmt=png&from=appmsg) + +明显,当前项目通过D依赖C的路径最近,基于就近原则,会使用2.0.0的版本![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfgDhQbY8YVicDKMthNSY90LDzpEics5lQLOtiaoFSH63Ck9FTwFnFmTg9Q/640?wx_fmt=png&from=appmsg) + +### 8.2 优先声明原则 + +当出现依赖传递导致相同jar包版本不一致时,此时会根据优先声明原则来决定使用谁。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfQB2U6TVaSwOFf4Z9t1yBPVjF73oQxky43oDXXW2yvAsHuIRibcLupzg/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf2aiaTnjicKUWmniaYooFClwVPqCQ6K4pDh9XB6SeibeKWOuu8EFVm1Mnicg/640?wx_fmt=png&from=appmsg) + +如果是你主动导入的依赖,此时会根据你最后引用的版本决定![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfibgynfqcOeQjqonNwib6BUqD6lv10lS7dTvIouNNHc57oD6bOkuzueqA/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfdrsXGJjWGhsqZ8PZskP6vhtEBFBZB51O27un1YlheypSMTKgLPHl8w/640?wx_fmt=png&from=appmsg) + +### 8.3 手动排除依赖 + +可以手动的形式,在引入A依赖时,将B依赖中A依赖排除掉 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfIkU0N2CJ0FvtTVHiaoKkpqzIEteDZuicHpazb6yVxzq2eibZDiblIoML0A/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfk4PBgj8OkfvAtWAlgmtkuH3ibQPoSiby6CcOwqg9HoXMcJ5ibT9iaDVKOA/640?wx_fmt=png&from=appmsg) + +```xml + + + org.springframework + spring-context + 5.3.12 + + + org.springframework + spring-beans + + + org.springframework + spring-core + + + + + org.springframework + spring-aop + 5.2.10.RELEASE + + + +``` + +### 8.4 声明依赖版本 + +可以通过dependencyManagement标签,提前声明依赖的版本。 + +dependencyManagement标签只会声明版本,不会将依赖导入,导入依赖依然需要借助dependencies + +配置完下面的内容后,再导入spring-beans、spring-core无论什么方式,都使用dependencyManagement中声明的版本。 + +```xml + + + + + org.springframework + spring-beans + 5.1.8.RELEASE + + + org.springframework + spring-core + 5.3.9 + + + +``` + +如果前面已经声明好了依赖的版本。 + +但是你在pom.xml文件中,直接引入了一个具体的版本的依赖,和dependencyManagement不一致,那么会使用你指定好的版本。这种依赖传递的版本会严格遵循dependencyManagement。 + +其次,如果基于dependencyManagement声明好了版本,在dependencies中导入依赖时,是可以不写版本号的,可以直接基于dependencyManagement中的版本导入。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfvHu5eC1D5MpTG2hnzloOnv9GGpl1RloK9U6FibzMqq9RoUZcPK2CC2g/640?wx_fmt=png&from=appmsg) + +## 九、Maven指令 + +Maven为整个项目生命周期的各个阶段,提供了各种各样的指令。 + +先了解常用的几个: + +```java +mvn clean:清空target目录。 +mvn compile:编译整个项目,生成到target +mvn test:专门针对test目录下的内容做测试 +mvn package:会将当前项目打包,jar,war。 +mvn install:将当前项目进行编译,测试,打包,并且将jar包安装到本地仓库。 +// mvn deploy:私服的位置再讲 +``` + +* compile:这里是将main目录下的内容编译,生成一个target目录,将编译后的内容全部放到target目录下,java和resources都可以称为classpath,因为编译后的内容都是放在classes目录下的。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfJSpp1PxbT73I5wR208KzY95ZVbO55T1iaAwyLgdpcaa5z9Kz2XmYn1g/640?wx_fmt=png&from=appmsg) +* clean:就是将编译后的内容全部清除掉。 +* test:测试会优先进行编译,并且会针对test目录下以Test结尾的类中追加了@Test注解的方法运行测试,如果报错,控制台会有显示。直接Build失败。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfhfrLlicMGbBxGwuOaCxMfVRU10fjsMmuf1c5A3IlY4hKGS3cBrHbHFw/640?wx_fmt=png&from=appmsg) +* package:将项目进行打包,但是打包会经历compile以及test,并且成功后,才会将项目打包成具体的jar或者是war。打包后的具体文件,会存放在target目录下。项目打包无法跳过编译过程的,但是可以跳过测试的过程,需要自行敲命令![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfZkLaZKNhRXT98KGMqGuJ8DNTsHS5wQq79NVk4EGFCIsSwXJv470lxg/640?wx_fmt=png&from=appmsg) + + ``` + mvn package -DskipTests + ``` + + ![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfL2EIgV737KPeGU072bZR9xO8GrefYxf7Gwwic7WHlbgE0mGDxQjg2hw/640?wx_fmt=png&from=appmsg) +* install:将当前项目做好编译,测试,打包,并且将项目安装到本地仓库。如果安装到本地仓库的是一个jar包,其他项目就可以将这个jar依赖过来使用。!![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf972dwV8t4RDNfIgUZnYuoqHibndQoOpn2icP6r9C4XNF3rmLClFmI71Q/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfmACmKxfv9X2hONWiaWmYD5DzCaHxGfHiapOWpiagbpSbZWOe0kgLpozcg/640?wx_fmt=png&from=appmsg) + +## 十、聚合工程 + +在项目打包的方式中,前面聊过jar,还有war的形式。 + +除此之外,还有一个打包的形式,叫做pom。pom就是所谓的聚合工程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfRnHAYSvGEHZYBUTcPW6e7V1bRRNxHC7tOAgFqqPF1yMo2hsqkdPNibA/640?wx_fmt=png&from=appmsg) + +构建最外层的电商聚合工程,聚合工程不需要写任何的业务代码,它的目的就是管理其他的子工程 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tflxmTadFHbicfkk51foUU6yurOzd8YXu8ys34n30MBVUtvSawWPFibzmA/640?wx_fmt=png&from=appmsg) + +构建好聚合工程后,可以再构建子工程,流程如下。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfic4jDiaRZ4BFcJ9ibBnMN0aTNXnCao7Fw9gvkdgjjWTArGNft6prrlLIQ/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfFZ0wETqJEn3cC9kzJ0tc01wcvwZhvmT2vvmYYKwgj9kzGaOLmn9dpw/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfVTPR6dKwmmib3Dz07nt3BqBlDxjprOsDO1aPV0AsQ6oRlcDUXYwg3eQ/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfdQyW8via4tiaQfI7Svyp1G6czft5vH2QGdPrBwOD4ia8uCYgFCibyBwYOA/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfdYIlI2bZzvicmaR4QSPZIHk1Zc3qv6wnfICL60W0EZLO4D8FATRQCPA/640?wx_fmt=png&from=appmsg) + +好处是可以在聚合工程内去管理依赖的版本。同时可以基于聚合工程做统一的多个项目的打包或者其他操作。而且拆分模块去写项目。 + +## 十一、Maven私服 + +### 11.1 Maven私服的概念 + +> * 私服是搭建在局域网的一种特殊的远程仓库,目的是代理远程仓库,让下载依赖的效率更高。 +> * 有了私服之后,使用Maven需要下载依赖时,直接请求私服下载依赖,将私服中的依赖下载到本地仓库中。如果私服中没有具体依赖,私服会去外部的远程仓库下载。 +> * 私服可以解决在业务做开发时,有一些内部的依赖,是中央仓库没有提供的,是公司开发人员自行封装的一些依赖。可以将公司自研的一些框架和依赖上传到私服中,让公司内部人员可以通过私服将这种依赖下载到本地仓库。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfTyFGbWcz9gw0JQ6zZTOUeREhrsHH3nBNDaAsaDTKcjTvedFGu1MjGA/640?wx_fmt=png&from=appmsg) + +> 搭建私服的方式非常多,Apache Archiva,Sonatype Nexus。 一般都会采用后者。 + +### 11.2 搭建Nexus私服 + +去官网下载最新的安装包。 + +http://www.sonatype.com + +但是在官网想找到Download挺麻烦的,下载的话,直接进入到下面这个地址 + +https://help.sonatype.com/en/download.html + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfuoUampibiaLav3TEhfFql6cpDic7mXYIKvicPicvT5CfGbia0uib2sSYiaHLdw/640?wx_fmt=png&from=appmsg) + +下载完毕是一个zip的压缩包,最好解压到非系统盘的位置,路径不要带 **中文和空格** !!!! + +解压后,有两个目录 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfRzw5LXg8UNIPmlkDyZX70pC2cNaDhrt8v9kLVN3SXw5F4cHTjYrxoQ/640?wx_fmt=png&from=appmsg) + +进入到nexus-3.67.1-01目录下,再进入bin目录下。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfDIMdtPiaqIkBM4MpZsU4j4s7nlG999mc05fXprvSevXRNFsQGPmkSicQ/640?wx_fmt=png&from=appmsg) + +启动时,需要基于doc窗口去运行Nexus私服,但是一定要以 **超级管理员** 的身份打开cmd。![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfhibAGud10LuzjcYoC9IwBFhAADBxRJLQJ9wXLot7sazHyAkIPSm9ibIQ/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf6qBP1s729hVllDR2Lks7dE7TdmZntnD60VTXibXnhJ4NPsYGfBkVx3A/640?wx_fmt=png&from=appmsg) + +在bin目录下执行指定,访问外网慢的话,可能需要至少9~10分钟左右甚至更多。 + +```shell +nexus.exe /run +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tf9WFwk5eDMukxlFDFCSv1Fe52aPfdo7AujmgOFZXV6jHIJHewZggLCw/640?wx_fmt=png&from=appmsg) + +启动成功后,直接访问http://localhost:8081/ + +进入首页后,需要加载一小会,可以访问到首页,第一个要做的事情是登录 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfzzFh4H7ug7dHcuTMKAfeibfmjOZpujZA0L8juUgecdIb8NS75R0MVkQ/640?wx_fmt=png&from=appmsg) + +登录即可,默认用户名是admin,密码在下面图中的文件里 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfrW3vSH1uQu00skVWwOrSSROr9Jw5BUWFlH9BDMNichta7Yfial9MMOsw/640?wx_fmt=png&from=appmsg) + +登录成功后,第二步需要重新设置密码 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfQ3wiaephSxia4ic6wsz1tqx84wiazAKud6c0iafHNcIYK7jUIMQBl6HCQcA/640?wx_fmt=png&from=appmsg) + +设置私服下载依赖的权限信息 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfNID5IXYE8jZSxskyUTFgZmKkFgSAMCeFEJOsL2wCPILrvIO3la5FzA/640?wx_fmt=png&from=appmsg) + +关注前四个即可 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfj7WJYicj5WQQtGGjxKRsJO21g51FtZVXO67cut3lNOyb1agB8d82ecA/640?wx_fmt=png&from=appmsg) + +### 11.3 Nexus私服配置&下载依赖 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfaGkmzTvXrF51RT8hWvU6kpeuAziazwqhnRWF28XZpX4cniajnyTKbfsw/640?wx_fmt=png&from=appmsg) + +将私服仓库的代理,设置为国内的仓库镜像源 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfYUicOqvGgwSVoCZxeYjCibDMQhsCfUjg1o9hu6ibaJqiaJnw48VOmPaGicw/640?wx_fmt=png&from=appmsg)![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfWsIewf6mmCzsDOYr7QDLzf7jvfNCtARCNmMnOzVn6Ee16OlVHT1x5Q/640?wx_fmt=png&from=appmsg) + +配置完,拉到最下面,记得Save保存一下。 + +接下来配置好私服的地址,让项目基于私服下载依赖 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfSnJJS0IHXBMybeCeSFtXxm9WEbXBdeQ7RETvHlQFmR1kxq5tCvwic3g/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfTAv5edYxXhl9g08jw32pxfic5zbRLeJKp4DDTupBZpAoLb30SfZgDuA/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfSjnnuaYyNibGNSfJBLVEk40dwdaDmBdnlZWLJ90kOYJ8swhEqdLdeOw/640?wx_fmt=png&from=appmsg) + +因为初始化Nexus时,选择的是下载依赖不需要认证信息。 + +如果选择的是需要,要如何配置。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfIP3tT4mFict8wLYaU8fI3GiboR1Gd5lQFGICKH2olgxT8aHzunmunjMA/640?wx_fmt=png&from=appmsg) + +### 11.4 上传依赖到私服 + +首先在Maven私服的位置,找到release和snapshot的仓库地址 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfK4M5hq8zEbdoh0ef1UrfSHu6fbVqrUko7TWFQTuAwbLZ8jvrrtTicFQ/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfk0CF8uke0ES8pwzCPZ2SKiccmgCicKRgG3TFnZ2L6vpSw6CGicYNz03Jg/640?wx_fmt=png&from=appmsg) + +然后在pom.xml文件中配置相应的信息 + +```xml + + + zjw + http://localhost:8081/repository/maven-releases/ + + + zjw + http://localhost:8081/repository/maven-snapshots/ + + +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfphaibx8VciaweZF70hcuMqZibFx9vwUrtuzLy2gefcr2xheHTVOp4h9qQ/640?wx_fmt=png&from=appmsg) + +准备好之后,直接在项目右侧,点击deploy上传当前项目的jar到私服 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfSsGJueGnEbCBwI81sRBrJcXzibc0ulRhEgvokQ5heRpwjuDKczKbkoQ/640?wx_fmt=png&from=appmsg) + +上传成功后,可以在私服中找到 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNMyAQwia9YnRcHk7s9wu3tfW4vfmJcSPYnEVWYr4KGZt9FoD3ry4Gfv4jyEXXia1HFoCkavwPwBFYg/640?wx_fmt=png&from=appmsg) + +其他的项目在配置没问题的情况下,就可以使用私服中的各种依赖了。 diff --git "a/docs/md/\345\205\266\344\273\226/\344\270\215\347\224\250Mockito\345\206\231\345\215\225\345\205\203\346\265\213\350\257\225\357\274\237\344\275\240\345\217\257\350\203\275\345\234\250\346\265\252\350\264\271\344\270\200\345\215\212\346\227\266\351\227\264(1).md" "b/docs/md/\345\205\266\344\273\226/\344\270\215\347\224\250Mockito\345\206\231\345\215\225\345\205\203\346\265\213\350\257\225\357\274\237\344\275\240\345\217\257\350\203\275\345\234\250\346\265\252\350\264\271\344\270\200\345\215\212\346\227\266\351\227\264(1).md" new file mode 100644 index 0000000..597c1ea --- /dev/null +++ "b/docs/md/\345\205\266\344\273\226/\344\270\215\347\224\250Mockito\345\206\231\345\215\225\345\205\203\346\265\213\350\257\225\357\274\237\344\275\240\345\217\257\350\203\275\345\234\250\346\265\252\350\264\271\344\270\200\345\215\212\346\227\266\351\227\264(1).md" @@ -0,0 +1,532 @@ +你是不是也经常在写单元测试时,被数据库连接、第三方接口这些折腾得头疼?明明只是想验证自己的业务逻辑,却不得不花半天时间处理各种外部依赖——这种体验就像是想喝杯咖啡却发现要自己种咖啡豆。 + +好在Mockito这个神器能让你的测试飞起来!它帮你模拟复杂依赖,让测试回归到代码逻辑本身。无论是验证某个方法是否被正确调用,还是模拟异常来测试程序的健壮性,Mockito 都能让测试变得专注而高效。 + +# 简介 + +Mockito是一个用于Java单元测试的mock框架,用于创建**模拟对象**(mock object)来替代真实对象,帮助开发者隔离外部依赖,从而专注于单元测试的逻辑,Mockito通常配合单元测试框架(如JUnit)使用。 + +- 官方网站:https://site.mockito.org/ +- 官方文档:https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html + +# 依赖 + +```xml + + + org.mockito + mockito-core + 4.11.0 + test + +``` + +如果使用Spring Boot Test 则不需要引入,Spring Boot Test 默认集成了 Mockito。 + +# 常见用法 + +Mockito的核心功能包括: + +- **创建mock对象**:使用`mock()`创建mock对象。 +- **打桩**:使用`when()`和`thenReturn()`等方法指定mock对象的特定方法被调用时的行为(如返回值或抛出异常)。 +- **验证行为**:使用`verify()`检查mock对象的特定方法是否被调用,参数和调用次数是否符合预期。 + +下面通过示例展开介绍Mockito的用法。 + +## 验证行为 + +Mockito 的 `verify()` 用于验证**模拟对象的方法是否按预期被调用**,包括调用次数、参数匹配等。它支持精确验证(如 `times(2)`)、最少/最多次数(`atLeast()`/`atMost()`)、未调用(`never()`)及顺序验证(结合 `InOrder`)等,确保代码执行逻辑正确。 + +```java +public class MockTest { + + @Test + public void testBasicVerification() { + List mockList = mock(List.class); + + // 模拟调用 + mockList.add("apple"); + mockList.add("banana"); + mockList.add("apple"); + mockList.add("orange"); + + // 1. 验证方法被调用【恰好一次】(默认行为) + verify(mockList).add("banana"); + + // 2. 验证方法被调用【指定次数】 + verify(mockList, times(2)).add("apple"); // 精确2次 + + // 3. 验证方法【从未调用】 + verify(mockList, never()).clear(); + + // 4. 验证【调用顺序】 + InOrder inOrder = inOrder(mockList); + inOrder.verify(mockList).add("apple"); + inOrder.verify(mockList).add("banana"); + inOrder.verify(mockList).add("apple"); + + verifyNoMoreInteractions(mockList); + } + +} +``` + +`org.mockito.Mockito`类的`mock()`方法用于创建指定类或接口的mock对象。一旦创建,mock对象就会记住所有的方法调用。之后可以选择性地验证感兴趣的方法调用。 + +- **验证单次调用**:`verify(mockList).add("banana");`→ 检查 `add("banana")` 被调用 ​​1 次​​。 + +- **验证精确次数**:`verify(mockList, times(2)).add("apple");`→ 检查 `add("apple")` 被调用 ​2 次​​。 + +- **验证禁止调用**:`verify(mockList, never()).clear();`→ 确保 `clear()` ​从未调用​​。 + +- **验证调用顺序**: + + ```java + InOrder inOrder = inOrder(mockList); + inOrder.verify(mockList).add("apple"); + inOrder.verify(mockList).add("banana"); + inOrder.verify(mockList).add("apple"); + ``` + + 严格按顺序验证调用链。 + +- **未验证的调用**:`verifyNoMoreInteractions()` 用来检查mock对象没有未验证的调用。由于`mockList.add("orange")`被调用过,但没有验证,因此最后的测试将会失败。 + +## 打桩 + +**打桩**是为模拟对象(Mock)的方法调用预设返回值或行为,使得测试代码可以**隔离外部依赖**,并控制方法的输出或异常,一旦被打桩,方法将返回指定的值,无论调用多少次。通过打桩,可以模拟数据库、网络请求等复杂或不可控的操作。 + +```java + @Test + public void testStubbing() { + // 1. 创建模拟对象 + List mockList = mock(List.class); + + // 2. 基础打桩:返回固定值 + when(mockList.get(0)).thenReturn("apple"); + assertEquals("apple", mockList.get(0)); + + // 3. 抛出异常 + when(mockList.get(1)).thenThrow(new RuntimeException("索引错误")); + assertThrows(RuntimeException.class, () -> mockList.get(1)); + + // 4. 多次调用不同返回值 + when(mockList.size()) + .thenReturn(1) + .thenReturn(2); + assertEquals(1, mockList.size()); + assertEquals(2, mockList.size()); + + // 5. 参数匹配器(如 anyInt()) + when(mockList.get(anyInt())).thenReturn("default"); + assertEquals("default", mockList.get(999)); + + // 6. Void 方法打桩(如抛出异常) + doThrow(new IllegalStateException("清空失败")).when(mockList).clear(); + assertThrows(IllegalStateException.class, mockList::clear); + } +``` + +**语法优先级**: + +- `when(...).thenX()` 适用于有返回值的方法。 +- `doX().when(mock).method()` 适用于 void 方法。 + +**参数匹配器**:使用 `any()`、`eq()` 等灵活匹配参数,但需注意​参数一致性​(不能混用具体值和匹配器)。 + +**覆盖规则**:最后一次打桩会覆盖之前的定义(例如多次对 `mock.get(0)` 打桩,以最后一次为准)。 + +**默认情况下,对于所有返回值的方法,mock对象将返回适当的默认值**。例如,对于`int`或`Integer`返回0,对于`boolean`或`Boolean`返回`false`,对于集合类型返回空集合,对于其他对象类型(例如字符串)返回`null`。 + +## 连续打桩和回调打桩 + +**连续打桩(Chained Stubbing)**:为同一个方法的连续多次调用定义不同的返回值或行为,常用于模拟多次调用时的动态响应。 + +```java + @Test + public void testChainedStubbing() { + List mockList = mock(List.class); + + // 定义连续打桩:第一次调用返回 "A",第二次返回 "B",第三次抛出异常 + when(mockList.get(0)) + .thenReturn("A") + .thenReturn("B") + .thenThrow(new RuntimeException("No more elements")); + + // 验证 + assertEquals("A", mockList.get(0)); // 第一次返回 "A" + assertEquals("B", mockList.get(0)); // 第二次返回 "B" + assertThrows(RuntimeException.class, () -> mockList.get(0)); // 第三次抛出异常 + } +``` + +超出定义的调用次数后,最后一次行为会持续生效(例如第三次后继续调用会一直抛异常)。 + +**回调打桩(Callback Stubbing)**:`thenAnswer()` 可以实现动态返回值逻辑,根据方法参数或外部条件生成响应。 + +```java + @Test + public void testChainedStubbing() { + List mockList = mock(List.class); + + // 根据参数动态返回:参数是偶数时返回 "even",奇数返回 "odd" + when(mockList.get(anyInt())).thenAnswer(invocation -> { + int index = invocation.getArgument(0); // 获取第一个参数 + return (index % 2 == 0) ? "even" : "odd"; + }); + + // 验证 + assertEquals("even", mockList.get(0)); // 0是偶数 + assertEquals("odd", mockList.get(1)); // 1是奇数 + } +``` + +- **灵活控制**:可在 `thenAnswer()` 中编写任意 Java 代码,甚至访问外部变量。 +- **参数获取**:通过 `invocation.getArgument(n)` 获取第 `n` 个参数(从 0 开始)。 + +## 参数匹配器 + +Mockito默认使用`equals()`方法验证参数值。当需要额外的灵活性时,可以使用参数匹配器。 + +参数匹配器是 Mockito 提供的一种灵活的参数验证机制,允许开发者通过匹配器来匹配方法参数,而无需指定具体值。 + +参数匹配器广泛用于 `when()` 打桩和 `verify()` 验证中。 + +```java + @Test + public void testMatchers() { + List mockList = mock(List.class); + + // 1. 通用匹配器:anyInt(), anyString() + when(mockList.get(anyInt())).thenReturn("default"); + assertEquals("default", mockList.get(999)); + + // 2. 条件匹配器:startsWith(), endsWith() + when(mockList.add(startsWith("app"))).thenReturn(true); + assertTrue(mockList.add("apple")); + assertFalse(mockList.add("banana")); + + // 3. 混合使用具体值和匹配器(必须用 eq() 包裹具体值) + when(mockList.set(eq(0), anyString())).thenReturn("old_value"); + assertEquals("old_value", mockList.set(0, "new_value")); + } +``` + +**通用匹配器** + +- **作用**:匹配任意参数或特定类型参数。 + +- **常见方法**: +- `any()`:匹配任意对象(包括 `null`)。 + +- `anyInt()`, `anyString()`, `anyList()`:匹配特定类型参数。 + +- `isNull()`, `isNotNull()`:匹配 `null` 或非 `null` 参数。 + +**条件匹配器** + +- **作用**:根据逻辑条件匹配参数。 + +- **常见方法**: + + - `eq(value)`:严格匹配具体值(等同于直接写值)。 + + - `startsWith("prefix")`:匹配以指定前缀开头的字符串。 + + - `endsWith("suffix")`, `contains("substr")`:匹配字符串后缀或子串。 + + - `argThat(condition)`:自定义条件(如集合大小、对象属性)。 + +**混合使用规则** + +- **强制要求**:若方法参数中至少有一个匹配器,则所有参数必须用匹配器。 + + 错误示例: + + ```java + // 错误:混合具体值和匹配器 + when(mock.method("value", anyInt())).thenReturn(true); + ``` + + 修复方法:将具体值用 `eq()`包裹: + + ```java + when(mock.method(eq("value"), anyInt())).thenReturn(true); + ``` + + **自定义匹配器** + +通过 `argThat()` 实现复杂条件: + +```java +// 自定义匹配器:验证集合大小大于2 +when(mockList.addAll(argThat(list -> list.size() > 2))).thenReturn(true); +assertTrue(mockList.addAll(List.of("A", "B", "C"))); +``` + +更多的内置参数匹配器参考: + +- https://javadoc.io/static/org.mockito/mockito-core/4.11.0/org/mockito/ArgumentMatchers.html + +- https://javadoc.io/static/org.mockito/mockito-core/4.11.0/org/mockito/hamcrest/MockitoHamcrest.html + +## 间谍(spy) + +`spy()` 可以创建部分真实对象的代理(保留原有行为,可选择性地对某些方法打桩),适合需要混合真实逻辑与模拟行为的场景。 + +对比 `mock()`: + +| 特性 | `mock()` | `spy()` | +| :----------: | :----------------------------------: | :------------------------: | +| **默认行为** | 所有方法返回默认值(如 `null`、`0`) | 调用真实方法,除非显式打桩 | +| **适用场景** | 完全隔离被测对象依赖 | 需保留部分真实逻辑的测试 | + +```java + @Test + public void testSpyBasic() { + // 1. 创建一个 ArrayList 的 spy 对象 + List spyList = spy(new ArrayList<>()); + + // 2. 调用真实方法 + spyList.add("apple"); + spyList.add("banana"); + + // 3. 验证真实行为 + assertEquals(2, spyList.size()); // 实际调用了 add 和 size 方法 + + // 4. 对某个方法打桩 + when(spyList.size()).thenReturn(100); + assertEquals(100, spyList.size()); // 打桩生效 + + // 5. 验证方法调用次数 + verify(spyList, times(2)).add(anyString()); // 验证 add 被调用两次 + } +``` + +当对 `spy` 对象的方法打桩时,若直接使用 `when(...)` 会触发真实方法调用,可能导致异常。 + +错误示例: + +```java +List spyList = spy(new ArrayList<>()); +// 会被真实执行,但此时列表为空,导致 IndexOutOfBoundsException +when(spyList.get(0)).thenReturn("mock-value"); +``` + +正确方式:使用 `doReturn().when()` 语法避免真实调用 + +```java + List spyList = spy(new ArrayList<>()); + // 正确:不会触发 get(0) 的真实调用 + doReturn("mock-value").when(spyList).get(0); + assertEquals("mock-value", spyList.get(0)); +``` + +**最佳实践**: + +1. **优先使用 `mock()`**:除非需要保留部分真实行为,否则优先用 `mock()` 隔离依赖。 +2. **谨慎打桩**:使用 `doReturn().when()` 替代 `when().thenReturn()`,避免意外触发真实方法。 +3. **避免复杂间谍**:不要对复杂对象(如 Spring Bean)滥用 `spy()`,可能导致测试不可控。 + +## 参数捕获(ArgumentCaptor) + +ArgumentCaptor 用于在测试中捕获方法调用时传递的参数,便于后续对参数值进行详细验证(如对象属性、集合内容等)。 + +完整示例: + +```java + @Test + public void testCaptureArgument() { + // 1. 创建 Mock 对象 + UserService mockService = mock(UserService.class); + + // 2. 调用被测试方法 + User user = new User("Alice", 30); + mockService.processUser(user); + + // 3. 创建 ArgumentCaptor + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + + // 4. 验证方法调用并捕获参数 + verify(mockService).processUser(userCaptor.capture()); + + // 5. 获取捕获的参数并验证 + User capturedUser = userCaptor.getValue(); + assertEquals("Alice", capturedUser.getName()); + assertEquals(30, capturedUser.getAge()); + } + + @Data + static class User { + private String name; + private int age; + + public User(String name, int age) { + this.name = name; + this.age = age; + } + } + + static class UserService { + public void processUser(User user) { + // 实际业务逻辑(在测试中被 Mock) + } + } +``` + +## 静态方法Mock + +`Mockito.mockStatic(Class)` 可以创建静态类的 Mock 作用域,并在其中定义行为。 + +```java + @Test + public void testMockStaticMethod() { + // 1. 创建静态类(如 LocalDate)的 Mock 作用域 + try (MockedStatic mockedLocalDate = mockStatic(LocalDate.class)) { + + // 2. 定义静态方法 now() 的行为 + LocalDate fixedDate = LocalDate.of(2023, 10, 1); + mockedLocalDate.when(LocalDate::now).thenReturn(fixedDate); + + // 3. 验证静态方法调用 + assertEquals(fixedDate, LocalDate.now()); // 返回固定日期 + mockedLocalDate.verify(LocalDate::now); // 验证 now() 被调用 + } + + // 4. 作用域结束后,静态方法恢复原始行为 + assertNotEquals("2023-10-01", LocalDate.now().toString()); + } +``` + +**作用域限制**: + +- 静态 Mock 仅在 `try-with-resources` 或 `MockedStatic.close()` 前有效。 +- 必须关闭:确保使用 `try-with-resources` 或手动 `close()`,避免影响其他测试。 + +# 注解 + +## @Mock + +@Mock用于快速创建 Mock 对象,替代 `Mockito.mock(Class)` 方法。 + +**方式 1:通过 `MockitoJUnitRunner` 自动初始化** + +```java +// 自动初始化 @Mock 注解 +@RunWith(MockitoJUnitRunner.class) +public class MockTest { + + @Mock // 自动创建 List 的 Mock 对象 + private List mockList; + + @Test + public void testMockAnnotation() { + mockList.add("test"); + verify(mockList).add("test"); + } + + +} +``` + +**JUnit 5 适配**:需使用`@ExtendWith(MockitoExtension.class)`。 + +**方式 2:手动调用 `MockitoAnnotations.openMocks()`** + +```java +public class MockTest { + @Mock + private List mockList; + + @Before + public void init() { + MockitoAnnotations.openMocks(this); // 手动初始化 @Mock 注解 + } + + @Test + public void testMockAnnotation() { + mockList.add("test"); + verify(mockList).add("test"); + } +} +``` + +## @MockBean + +在Spring Boot 集成测试中,@MockBean用于向 ApplicationContext 注入一个Mock 对象,替换原有 Bean。适用于需要隔离外部依赖(如数据库、第三方服务)的集成测试。 + +示例场景:测试 `UserService` 时,Mock 其依赖的 `UserRepository`,避免真实数据库操作。 + +```java +@SpringBootTest // 启动 Spring 上下文 +public class UserServiceTest { + + @Autowired + private UserService userService; // 被测服务 + + @MockBean // 自动替换 Spring 容器中的 UserRepository Bean + private UserRepository userRepository; + + @Test + public void testGetUserById() { + // 1. 定义 Mock 行为 + when(userRepository.findById(1L)).thenReturn(new User("Alice")); + + // 2. 调用被测方法 + User user = userService.getUserById(1L); + + // 3. 验证结果和交互 + assertEquals("Alice", user.getName()); + verify(userRepository).findById(1L); // 确保方法被调用 + } +} +``` + +- **替换规则**:若 Spring 上下文中已存在同名 Bean,`@MockBean` 会覆盖它;若不存在,则新增 Mock Bean。 +- **多 Bean 类型冲突**:若同一类型有多个 Bean,需结合 `@Qualifier` 指定名称。 + +## @InjectMock + +- **核心功能**:自动将 `@Mock` 或 `@Spy` 创建的依赖对象注入到被测试类中,简化依赖管理。 + +- **适用场景**:单元测试中,快速构建被测试类(如 Service 层),并自动注入其依赖的 Mock 对象(如 Repository)。 + +示例场景:测试 `UserService`,其依赖 `UserRepository`(需要 Mock)。 + +```java +@ExtendWith(MockitoExtension.class) +public class MockTest { + + + @Mock // 创建 UserRepository 的 Mock 对象 + private UserRepository userRepository; + + @InjectMocks // 自动将 userRepository 注入 UserService + private UserService userService; + + @Test + public void testGetUserById() { + // 1. 定义 Mock 行为 + when(userRepository.findById(1L)).thenReturn(new User("Alice")); + + // 2. 调用被测试方法 + User user = userService.getUserById(1L); + + // 3. 验证结果和交互 + assertEquals("Alice", user.getName()); + verify(userRepository).findById(1L); // 确保方法被调用 + } +} +``` + +`@InjectMocks` 按以下顺序尝试注入依赖: + +1. **构造函数注入**(优先选择参数最多的构造函数)。 +2. **Setter 方法注入**(按方法名匹配,如 `setUserRepository()`)。 +3. **字段注入**(直接注入到 `private` 字段,需匹配名称和类型)。 + +# 结尾 + +Mockito 的魅力在于它用简单的语法解决了测试中的复杂问题。通过模拟对象、打桩预设行为、验证调用细节,开发者可以轻松隔离外部依赖,像搭积木一样构造测试场景。无论是新手还是经验丰富的工程师,Mockito 的直观设计都能让人快速上手。 + +下次当你面对一个难以测试的方法时,不妨试试 Mockito——让它帮你把“不确定”变成“可控”,把“复杂依赖”变成“精准验证”。毕竟,好的测试不是为了证明代码完美,而是为了让它足够可靠,而 Mockito 正是这条路上值得信赖的工具。 diff --git "a/docs/md/\345\205\266\344\273\226/\345\256\236\346\210\230Arthas\357\274\232\345\270\270\350\247\201\345\221\275\344\273\244\344\270\216\346\234\200\344\275\263\345\256\236\350\267\265.md" "b/docs/md/\345\205\266\344\273\226/\345\256\236\346\210\230Arthas\357\274\232\345\270\270\350\247\201\345\221\275\344\273\244\344\270\216\346\234\200\344\275\263\345\256\236\350\267\265.md" new file mode 100644 index 0000000..87f55be --- /dev/null +++ "b/docs/md/\345\205\266\344\273\226/\345\256\236\346\210\230Arthas\357\274\232\345\270\270\350\247\201\345\221\275\344\273\244\344\270\216\346\234\200\344\275\263\345\256\236\350\267\265.md" @@ -0,0 +1,759 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + +[toc] +当涉及到 Java 应用程序的诊断和调优时,Arthas 是一款备受推崇的开源工具,无论是线上问题的定位,还是实时性能监控和分析,Arthas 都能为您提供强大的支持。 + +本文将介绍 Arthas 的常用命令和使用技巧,帮助您更好地利用该工具进行故障排查和性能优化。 + +## 前言 + +在开始本文之前,先推荐两个东西: + +一个是 Arthas 官网:https://arthas.aliyun.com/doc/,官方文档对 Arthas 的每个命令都做出了介绍和解释,并且还有在线教程,方便大家学习和熟悉命令。 + +![](https://img-blog.csdnimg.cn/img_convert/699b8877cd01afb0c3f5ca3c8a92900b.png) + +另外还有一个向大家推荐的是一款名为 **Arthas Idea** 的 IDEA 插件。 + +这是一款能快速生成 Arthas命令的插件,可快速生成可用于该类或该方法的 Arthas 命令,大大提高排查问题的效率。 + +![](https://img-blog.csdnimg.cn/img_convert/8267b4aea9cb65985c5d0305bed6f777.png) + +## 常用命令 + +尽管 Arthas 命令众多,但在实际使用中我们只需聚焦于那些常用命令。本文旨在重点介绍这些常用命令,并提供使用技巧和最佳实践,帮助您更好地运用 Arthas。 + +### 类命令 + +#### getstatic + +查看类的静态属性。推荐直接使用 `ognl` 命令,更加灵活。 + +```Bash +# getstatic class_name field_name +getstatic demo.MathGame random + +# 如果该静态属性是一个复杂对象,还可以支持在该属性上通过 ognl 表达式进行遍历,过滤,访问对象的内部属性等操作。 +# 例如,假设 n 是一个 Map,Map 的 Key 是一个 Enum,我们想过滤出 Map 中 Key 为某个 Enum 的值,可以写如下命令 +getstatic com.alibaba.arthas.Test n 'entrySet().iterator.{? #this.key.name()=="STOP"}' +``` + +#### jad + +反编译指定已加载类的源码。`jad` 只能反编译单个类,如需批量下载指定包的目录的 class 字节码请使用 `dump` 命令。 + +比如我们想知道自己提交的代码是否生效了,这种场景`jad` 命令就特别有用。 + +```Bash +# 反编译 java.lang.String +jad java.lang.String +# 默认情况下,反编译结果里会带有 ClassLoader 信息,通过 --source-only 选项,可以只打印源代码。方便和 mc/retransform 命令结合使用。 +jad --source-only java.lang.String +# 反编译指定的函数 +jad java.lang.String substring +# 当有多个 ClassLoader 都加载了这个类时,jad 命令会输出对应 ClassLoader 实例的 hashcode +# 然后你只需要重新执行 jad 命令,并使用参数 -c 就可以反编译指定 ClassLoader 加载的那个类了 +jad org.apache.log4j.Logger -c 69dcaba4 +``` + +#### retransform + +加载外部的 `.class` 文件,retransform jvm 已加载的类。 + +```Bash +# 结合 jad/mc 命令使用,jad 命令反编译,然后可以用其它编译器,比如 vim 来修改源码 +jad --source-only com.example.demo.arthas.user.UserController > /tmp/UserController.java +# mc 命令来内存编译修改过的代码 +mc /tmp/UserController.java -d /tmp +# 用 retransform 命令加载新的字节码 +retransform /tmp/com/example/demo/arthas/user/UserController.class +``` + +加载指定的 .class 文件,然后解析出 class name,再 retransform jvm 中已加载的对应的类。每加载一个 .class 文件,则会记录一个 retransform entry。 + +如果多次执行 retransform 加载同一个 class 文件,则会有多条 retransform entry。 + +```bash +# 查看 retransform entry +retransform -l +# 删除指定 retransform entry,需要指定 id: +retransform -d 1 +# 删除所有 retransform entry +retransform --deleteAll +# 显式触发 retransform +retransform --classPattern demo.MathGame +``` + +如果对某个类执行 retransform 之后,想消除 retransform 的影响,则需要: + +- 删除这个类对应的 retransform entry。 +- 重新显式触发 retransform。 + +retransform 的限制: + +- 不允许新增加 field/method。 +- 正在跑的函数,没有退出不能生效。 + +使用 `mc` 命令来编译 `jad` 的反编译的代码有可能失败。可以在本地修改代码,编译好后再上传到服务器上。有的服务器不允许直接上传文件,可以使用 `base64` 命令来绕过。 + +1. 在本地先转换 `.class` 文件为 base64,再保存为 result.txt。 + +```Bash + base64 -i /tmp/test.class -o /tmp/result.txt +``` + +2. 到服务器上,新建并编辑 result.txt,复制本地的内容,粘贴再保存。 + +```Bash +vim /tmp/result.txt +``` + +3. 把服务器上的 `result.txt `还原为`.class`。 + +```Bash +base64 -d /tmp/result.txt > /tmp/test.class +``` + +4. 用 md5 命令计算哈希值,校验是否一致。 + +```Bash +md5sum /tmp/test.class +``` + +### 监测排查命令 + +监测排查命令是 Arthas 中最常用的命令。 + +> 请注意,这些命令,都通过字节码增强技术来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此在线上、预发使用时,请尽量明确需要观测的类、方法以及条件,诊断结束要执行 `stop` 或将增强过的类执行 `reset` 命令。 + +#### monitor + +方法执行监控。可对方法的调用次数,成功次数,失败次数等维度进行统计。 + +```Bash +# -b:计算条件表达式过滤统计结果(方法执行完毕之前),默认是方法执行之后过滤 +# -c:统计周期,默认值为 120 秒 +# params[0] <= 2:过滤条件,方法第一个参数小于等于2 +monitor -b -c 5 com.test.testes.MathGame primeFactors "params[0] <= 2" +``` + +#### stack + +输出当前方法被调用的调用路径。 + +很多时候我们都知道一个方法被执行,但这个方法被执行的路径非常多,或者你根本就不知道这个方法是从那里被执行了,此时你需要的是 `stack` 命令。 + +```Bash +# -n:执行次数 +stack demo.MathGame primeFactors -n 2 +``` + +#### thread + +查看当前线程信息,查看线程的堆栈。 + +```Bash +# 没有参数时,默认按照 CPU 增量时间降序排列,只显示第一页数据 +# -i 1000: 统计最近 1000ms 内的线程 CPU 时间 +# -n 3: 展示当前最忙的前 N 个线程并打印堆栈 +# --state WAITING:查看指定状态的线程 +thread + +# 显示指定线程的运行堆栈 +thread id + +# 找出当前阻塞其他线程的线程,注意,目前只支持找出 synchronized 关键字阻塞住的线程, 如果是 java.util.concurrent.Lock 目前还不支持。 +thread -b +``` + +输出: + +- Internal 表示为 JVM 内部线程,参考 `dashboard` 命令的介绍。 +- cpuUsage 为采样间隔时间内线程的 CPU 使用率,与 `dashboard` 命令的数据一致。 +- deltaTime 为采样间隔时间内线程的增量 CPU 时间,小于 1ms 时被取整显示为 0ms。 +- time 为线程运行总 CPU 时间。 + +#### trace + +方法内部调用路径,并输出方法路径上的每个节点上耗时。 + +`trace` 命令在定位性能问题的时候特别有用。 + +```Bash +# -n 1:限制匹配次数 +# --skipJDKMethod false:默认情况下,trace 不会包含 jdk 里的函数调用,如果希望 trace jdk 里的函数,需要显式设置 +# --exclude-class-pattern :排除掉指定的类 +trace javax.servlet.Filter * -n 1 --skipJDKMethod false --exclude-class-pattern com.demo.TestFilter +# 正则表达式匹配路径上的多个类和函数,达到多层 trace 的效果 +trace -E com.test.ClassA|org.test.ClassB method1|method2|method3 +``` + +动态 tradce参考:[https://arthas.aliyun.com/doc/trace.html#动态-trace](https://arthas.aliyun.com/doc/trace.html#动态-trace) + +#### tt + +方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测。 + +说明: + +- tt 命令的实现是:把函数的入参/返回值等,保存到一个`Map`里,默认的大小是 100。 +- tt 相关功能在使用完之后,需要手动释放内存,否则长时间可能导致 OOM。退出 arthas 不会自动清除 tt 的缓存 map。 +- 需要强调的是,tt 命令是将当前环境的对象引用保存起来,但仅仅也只能保存一个引用而已。如果方法内部对入参进行了变更,或者返回的对象经过了后续的处理,那么在 tt 查看的时候将无法看到当时最准确的值。这也是为什么 watch 命令存在的意义。 + +```Bash +# -l:显示tt记录 +tt -l + +# -s:检索tt记录,比如:-s 'method.name=="primeFactors"' +tt -s 'method.name=="primeFactors"' + +# -t:这个参数的表明希望记录下类 *Test 的 print 方法的每次执行情况。 +tt -t + +# 查看具体调用信息 +tt -i 1003 + +# -w:--watch-express 观察时空隧道使用 ognl 表达式 +tt -w '@demo.MathGame@random.nextInt(100)' + +# 重做一次调用,当我们对程序做出了修改之后,希望再次调用观测结果,此时你需要 -p 参数 +# --replay-times:指定调用次数 +# --replay-interval:指定多次调用间隔(单位 ms, 默认 1000ms) +tt -i 1004 -p + +# 通过索引删除指定的 tt 记录 +tt -d 1001 + +# 清除所有的 tt 记录 +tt --delete-all +``` + +Spring MVC里获取对于的 bean: + +```Bash +# 获取Spring Context里的bean +tt -n 1 -t org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter invokeHandlerMethod +tt -i 1000 -w 'target.getApplicationContext().getBean("helloWorldService").getHelloMessage()' +``` + +#### watch + +函数执行数据观测,通过编写 OGNL 表达式进行对应变量的查看。 + +- watch 命令定义了 4 个观察事件点,即 `-b` 函数调用前,`-e` 函数异常后,`-s` 函数返回后,`-f` 函数结束后。 +- 4 个观察事件点 `-b`、`-e`、`-s` 默认关闭,`-f` 默认打开,当指定观察点被打开后,在相应事件点会对观察表达式进行求值并输出。 +- 这里要注意`函数入参`和`函数出参`的区别,有可能在中间被修改导致前后不一致,除了 `-b` 事件点 `params` 代表函数入参外,其余事件都代表函数出参。 +- 当使用 `-b` 时,由于观察事件点是在函数调用前,此时返回值或异常均不存在。 +- 在 watch 命令的结果里,会打印出`location`信息。`location`有三种可能值:`AtEnter`,`AtExit`,`AtExceptionExit`。对应函数入口,函数正常 return,函数抛出异常。 + +```Bash + # -x表示遍历深度,可以调整来打印具体的参数和结果内容,默认值是 1。 + # -x最大值是 4,防止展开结果占用太多内存。用户可以在ognl表达式里指定更具体的 field。 + watch demo.MathGame primeFactors -x 3 + + # 可以使用ognl表达式进行条件过滤 + watch demo.MathGame primeFactors "{params[0],target}" "params[0]<0" "#cost>200" + + # 可以使用 target.field_name 访问当前对象的某个属性 + watch demo.MathGame primeFactors 'target.illegalArgumentCount' + + # watch 构造函数 + watch demo.MathGame '{params,returnObj,throwExp}' -v + + # watch内部类 + watch OuterClass$InnerClass +``` + +### JVM命令 + +#### heapdump + +生成堆转储文件。 + +```Bash +# dump 到指定文件 +heapdump arthas-output/dump.hprof +# 只 dump live 对象 +heapdump --live /tmp/dump.hprof +``` + +#### jfr + +Java Flight Recorder (JFR) 是一种用于收集有关正在运行的 Java 应用程序的诊断和分析数据的工具。 + +它集成到 Java 虚拟机 (JVM) 中,几乎不会造成性能开销,因此即使在负载较重的生产环境中也可以使用。 + +```Bash +# 启动 JFR 记录 +jfr start + +# 启动 jfr 记录,指定记录名,记录持续时间,记录文件保存路径。 +# --duration JFR 记录持续时间,支持单位配置,60s, 2m, 5h, 3d,不带单位就是秒,默认一直记录。 +jfr start -n myRecording --duration 60s -f /tmp/myRecording.jfr + +# 查看所有 JFR 记录信息 +jfr status + +# 查看指定记录 id 的记录信息 +jfr status -r 1 + +# 查看指定状态的记录信息 +jfr status --state closed + +# jfr dump 会输出从开始到运行该命令这段时间内的记录到 JFR 文件,且不会停止 jfr 的记录 +# 生成的结果可以用支持 jfr 格式的工具来查看。比如:JDK Mission Control : https://github.com/openjdk/jmc +jfr dump -r 1 -f /tmp/myRecording1.jfr + +# 停止 jfr 记录 +jfr stop -r 1 +``` + +#### memory + +查看 JVM 内存信息。 + +输出如下: + +```bash +Memory used total max usage +heap 32M 256M 4096M 0.79% +g1_eden_space 11M 68M -1 16.18% +g1_old_gen 17M 184M 4096M 0.43% +g1_survivor_space 4M 4M -1 100.00% +nonheap 35M 39M -1 89.55% +codeheap_'non-nmethods' 1M 2M 5M 20.53% +metaspace 26M 27M -1 96.88% +codeheap_'profiled_nmethods' 4M 4M 117M 3.57% +compressed_class_space 2M 3M 1024M 0.29% +codeheap_'non-profiled_nmethods' 685K 2496K 120032K 0.57% +mapped 0K 0K - 0.00% +direct 48M 48M - 100.00% +``` + +#### dashboard + +当前系统的实时数据面板,按 `ctrl+c` 退出。 + +```Bash +# i:刷新实时数据的时间间隔 (ms),默认 5000m +# n:刷新实时数据的次数 +dashboard -i 5000 -n 3 +``` + +显示 ID 为 -1 的是 JVM的内部线程,JVM 内部线程包括下面几种: + +- JIT 编译线程:如 `C1 CompilerThread0`, `C2 CompilerThread0`。 +- GC 线程:如 `GC Thread0`, `G1 Young RemSet Sampling。` +- 其它内部线程:如 `VM Periodic Task Thread`, `VM Thread`, `Service Thread。` + +当 JVM 堆(heap)/元数据(metaspace) 空间不足或 OOM 时, GC 线程的 CPU 占用率会明显高于其他的线程。 + +#### classloader + +`classloader` 命令将 JVM 中所有的 classloader 的信息统计出来,并可以展示继承树,urls 等。 + +```Bash +# 按类加载类型查看统计信息 +classloader + +# 按类加载实例查看统计信息 +classloader -l + +# 查看 ClassLoader 的继承树 +classloader -t + +# 查看 URLClassLoader 实际的 urls,通过 classloader -l 可以获取到哈希值 +classloader -c 3d4eac69 +``` + +#### logger + +查看 logger 信息,更新 logger level。 + +```Bash +# 查看所有 logger 信息 +logger + +# 查看指定名字的 logger 信息 +logger -n org.springframework.web + +# 更新 logger level +logger --name ROOT --level debug +``` + +#### sc + +查看 JVM 已加载的类信息。 + +```bash +# 模糊搜索 +sc demo.* + +# 打印类的详细信息 +sc -d demo.MathGame + +# 打印出类的 Field 信息 +sc -d -f demo.MathGame +``` + + + +#### mbean + +查看 Mbean 的信息。 + +所谓 MBean 就是托管的Java对象,类似于 JavaBeans 组件,遵循 JMX(Java Management Extensions,即Java管理扩展) 规范中规定的设计模式。 + +MBean可以表示任何需要管理的资源。 + +```Bash +# 列出所有 Mbean 的名称 +mbean + +# 查看 Mbean 的元信息 +mbean -m java.lang:type=Threading + +# 查看 mbean 属性信息,mbean 的 name 支持通配符匹配 mbean java.lang:type=Th* +mbean java.lang:type=Threading + +#通配符匹配特定的属性字段 +mbean java.lang:type=Threading *Count + +# 实时监控使用-i,使用-n命令执行命令的次数(默认为 100 次) +mbean -i 1000 -n 50 java.lang:type=Threading *Count +``` + +比如我们可以使用 `mbean` 命令来查看 Druid 连接池的属性: + +```Bash +mbean com.alibaba.druid.pool:name=dataSource,type=DruidDataSource +``` + +#### profiler + +生成应用热点的火焰图。本质上是通过不断的采样,然后把收集到的采样结果生成火焰图。 + +```Bash +# 启动 profiler +# 生成的是 cpu 的火焰图,即 event 为cpu。可以用--event参数来指定。 +profiler start --event cpu + +# 获取已采集的 sample 的数量 +profiler getSamples + +# 查看 profiler 状态 +profiler status + +# 停止 profiler,生成结果,结果文件是html格式,也可以用--format参数指定 +profiler stop --format html + +# 恢复采样,start和resume的区别是:start是新开始采样,resume会保留上次stop时的数据。 +profiler resume + +# 配置 include/exclude 来过滤数据 +profiler start --include 'java/*' --include 'demo/*' --exclude '*Unsafe.park*' + +# 生成 jfr 格式结果 +profiler start --file /tmp/test.jfr +``` + +#### vmoption + +查看,更新 VM 诊断相关的参数。 + +```Bash +# 查看所有的 option +vmoption + +# 查看指定的 option +vmoption PrintGC + +# 更新指定的 option +vmoption PrintGC true +``` + +#### vmtool + +`vmtool` 利用 JVMTI 接口,实现查询内存对象,强制 GC 等功能。 + +```Bash +# --limit:可以限制返回值数量,避免获取超大数据时对 JVM 造成压力。默认值是 10 +# --action:执行的动作 +vmtool --action getInstances --className java.lang.String --limit 10 + +#强制 GC +vmtool --action forceGc + +# interrupt 指定线程 +vmtool --action interruptThread -t 1 +``` + +### 特殊命令 + +可以使用 `-v` 查看观察匹配表达式的执行结果 + +#### ognl + +执行 ognl 表达式,是Arthas中最为灵活的命令。 + +```bash +# -c:执行表达式的 ClassLoader 的 hashcode,默认值是 SystemClassLoader +# --classLoaderClass:指定执行表达式的 ClassLoader 的 class name +# -x:结果对象的展开层次,默认值 1 +ognl --classLoaderClass org.springframework.boot.loader.LaunchedURLClassLoader @org.springframework.boot.SpringApplication@logger +``` + +有关 ognl 语法介绍,放在下文。 + +#### options + +全局开关,慎用! + +```Bash +# 查看所有的 options +options + +# 设置指定的 option,默认情况下json-format为 false,如果希望watch/tt等命令结果以 json 格式输出,则可以设置json-format为 true。 +options json-format true + +# 默认情况下,watch/trace/tt/trace/monitor等命令不支持java.* package 下的类。可以设置unsafe为 true,则可以增强。 +options unsafe true + +# Arthas 默认启用strict模式,在ognl表达式里,禁止更新对象的 Property 或者调用setter函数 +# 用户如果确定要在ognl表达式里更新对象,可以执行options strict false,关闭strict模式。 +options strict false +``` + +### 帮助命令 + +#### help + +查看命令帮助信息,可以查看当前 arthas 版本支持的指令,或者查看具体指令的使用说明。 + +```Bash +help dashboard +或者 +dashboard -help +``` + +#### history + +打印命令历史。 + +```Bash +#查看最近执行的3条指令 +history 3 + +#清空指令 +history -c +``` + +#### cls + +清空当前屏幕区域。 + +#### quit + +仅退出当前的连接,Attach 到目标进程上的 arthas 还会继续运行,端口会保持开放,下次连接时可以直接连接上。或者直接按 `Q` 也能退出。 + +#### stop + +完全退出 arthas,stop 时会重置所有增强过的类。 + +#### reset + +重置增强类,将被 Arthas 增强过的类全部还原,Arthas 服务端 `stop` 时会重置所有增强过的类。 + +```Bash +# 还原指定类 +reset Test + +# 还原所有类 +reset +``` + +## Advice + +无论是匹配表达式也好、观察表达式也罢,他们核心判断变量都是围绕着一个 Arthas 中的通用通知对象 `Advice` 进行。 + +它的简略代码结构如下: + +```java +public class Advice { + private final ClassLoader loader; + private final Class clazz; + private final ArthasMethod method; + private final Object target; + private final Object[] params; + private final Object returnObj; + private final Throwable throwExp; + private final boolean isBefore; + private final boolean isThrow; + private final boolean isReturn; + + // getter/setter +} +``` + +这里列一个表格来说明不同变量的含义: + +| 变量名 | 变量解释 | +| --------: | :----------------------------------------------------------- | +| loader | 本次调用类所在的 ClassLoader | +| clazz | 本次调用类的 Class 引用 | +| method | 本次调用方法反射引用 | +| target | 本次调用类的实例 | +| params | 本次调用参数列表,这是一个数组,如果方法是无参方法则为空数组 | +| returnObj | 本次调用返回的对象。当且仅当 `isReturn==true` 成立时候有效,表明方法调用是以正常返回的方式结束。如果当前方法无返回值 `void`,则值为 null | +| throwExp | 本次调用抛出的异常。当且仅当 `isThrow==true` 成立时有效,表明方法调用是以抛出异常的方式结束。 | +| isBefore | 辅助判断标记,当前的通知节点有可能是在方法一开始就通知,此时 `isBefore==true` 成立,同时 `isThrow==false` 和 `isReturn==false`,因为在方法刚开始时,还无法确定方法调用将会如何结束。 | +| isThrow | 辅助判断标记,当前的方法调用以抛异常的形式结束。 | +| isReturn | 辅助判断标记,当前的方法调用以正常返回的形式结束。 | + +所有变量都可以在表达式中直接使用,如果在表达式中编写了不符合 OGNL 脚本语法或者引入了不在表格中的变量,则退出命令的执行。 + +用户可以根据当前的异常信息修正 `条件表达式` 或 `观察表达式`。 + +## 快捷键 + +```Bash +# 自动补全,命令后敲 - 或 -- ,然后按 tab 键,可以展示出此命令具体的选项 +Tab + +# 退出当前连接 +Q + +# 后台异步命令相关快捷键 +ctrl + c: 终止当前命令 +ctrl + z: 挂起当前命令,后续可以 bg/fg 重新支持此命令,或 kill 掉 +ctrl + a: 回到行首 +ctrl + e: 回到行尾 +``` + +## OGNL + +OGNL(Object-Graph Navigation Language)是一种表达式语言(EL),简单来说就是一种简化了的Java属性的取值语言,Arthas使用它做表达式过滤。 + +OGNL 表达式官网:[https://commons.apache.org/dormant/commons-ognl/language-guide.htm](https://commons.apache.org/dormant/commons-ognl/language-guide.html) + +**变量引用** + +OGNL支持用变量来保存中间结果,并在后面的代码中再次引用它。 + +OGNL中的所有变量,对整个表达式都是全局可见的,引用变量的方法是在变量名之前加上 `#` 号,OGNL会将当前对象保存在 "this" 变量中,这个变量也可以像其他任何变量一样引用,用 `#this` 表示当前对象。 + +这里列举一些常用的语法: + +```bash +# 调用静态属性 +'@全路径类目@静态属性名' + +# 调用静态方法 +'@全路径类目@静态方法名("参数")' + +# 过滤,判断,筛选 +'params[0]':查看第一个参数 +'params[0].size()':查看第一个参数的size +'params[0]=="xyz"':判断字符串相等 +'params[0]==123456789L':判断long型 +'params[0].{ #this.name }':将结果按name属性映射 +'params[0].{? #this.name == null }':按条件过滤 +'params[0].{? #this.age > 10 }.size()':过滤后统计 +'params[0].{^ #this.name != null}':选择第一个满足条件 +'params[0].{$ #this.name != null}':选择最后一个满足条件 +'params[0].{? #this.age > 10 }.size().(#this > 20 ? #this - 10 : #this + 10)':子表达式求值 +'name in { null,"Untitled" }':这条语句判断name是否等于null或者 Untitled + + +# 构造对象 +'#{ "foo" : "foo value", "bar" : "bar value" }':构造map参数 +'#@java.util.LinkedHashMap@{ "foo" : "foo value", "bar" : "bar value" }':构造特定类型map +'new com.Test("xiaoming",18)':构造方法,new 全路径类名() +'new int[] { 1, 2, 3 }':创建数组并初始化 + + +# 访问对象 +'@com.Test@getPerson("xiaoming",18).name':访问复杂对象属性,用 .属性名 访问属性 +'@com.Test@getChilds({"xiaoming"})[0]':访问List或者数组类型,用 [索引] 访问 +'@com.Test@getMap()["xiaoming"]': 访问Map对象,用 ["key"],key要用双引号 + +# 临时变量 +'#value1=@com.Test@getPerson("xiaoming",18), #value2=@com.Test@setPerson(#value1) ,{#value1,#value2}': 方法A的返回值当做方法B的入参 +'#value1=@System@getProperty("java.home"), #value2=@System@getProperty("java.runtime.name"), {#value1, #value2}':执行多行表达式,赋值给临时变量,返回一个List +'#obj=new com.User("xiaoming",18),@com.Test@inputObj(#obj)':先用构造函数构造一个对象,然后把这个对象当做入参传入 +``` + +## 实用功能 + +### 管道 + +Arthas 命令后可接 `grep` 进行进一步筛选或操作,比如: + +```Bash +classloader -a | grep "String" +``` + +### 后台异步执行 + +当需要排查一个问题,但是这个问题的出现时间不能确定,那我们就可以把检测命令挂在后台运行,并将保存到输出日志。 + +```Bash +# 比如希望执行后台执行 trace 命令,那么调用下面命令 +trace Test t & + +# 如果希望查看当前有哪些 arthas 任务在执行,可以执行 jobs 命令 +jobs + +# 可通过 > 或者 >> 将任务输出结果输出到指定的文件中,可以和 & 一起使用,实现 arthas 命令的后台异步任务。比如: +trace Test t >> test.out & + +#异步执行的命令,如果希望停止,可执行kill命令 +kill + +# 当任务正在前台执行,可以执行 ‘ctrl + z’ 将任务暂停。通过jbos查看任务状态将会变为 Stopped,再通过bg 或者fg 可让任务重新开始执行 +# 可以把对应的任务转到前台继续执行。在前台执行时,无法在 console 中执行其他命令 +fg +# 可以把对应的任务在后台继续执行 +bg +``` + +## 实用技巧 + +```bash +# 获取接口的响应时间 +watch org.springframework.web.servlet.DispatcherServlet doService '{params[0].getRequestURI()+" "+ #cost}' -n 5 -x 3 '#cost>100' -f + +# 获取指定header 头的信息,比如这里 获取 trace-id + watch org.springframework.web.servlet.DispatcherServlet doService '{params[0].getRequestURI()+" header="+params[1].getHeaders("trace-id")}' -n 10 -x 3 -f + +# 查看执行的SQL,下面两个都可以 +watch java.sql.Connection prepareStatement '{params,throwExp}' -n 5 -x 3 +watch org.apache.ibatis.mapping.BoundSql getSql '{params,returnObj,throwExp}' -n 5 -x 3 + + +# 调用任意bean中的方法 +# 1.先获取 classLoaderHash +sc -d com.alibaba.dubbo.config.spring.extension.SpringExtensionFactor + +# 2.ognl 调用对应 bean 的方法,把 34f5090e 替换为对于的 classLoaderHash +ognl -c 34f5090e '#context=@com.alibaba.dubbo.config.spring.extension.SpringExtensionFactory@contexts.iterator.next,#context.getBean("userServiceImpl").find("小明")' + +# 当传参是复杂对象时 +ognl -c 34f5090e '#context=@com.alibaba.dubbo.config.spring.extension.SpringExtensionFactory@contexts.iterator.next,#data=new Children(), #query=new User(),#query.setChildren(#data),#query.setRequestId("1"), #data.setName("小明"),#context.getBean("userServiceImpl").find(#query)' + +# vmtool 命令提供了更简单的语法,也可以调用任意bean中的方法 +vmtool --action getInstances --className org.springframework.context.ApplicationContext --express 'instances[0].getBean("userServiceImpl").find("小明")' + + +# 动态修改 bean 属性值 +# 本质原理就是先获取 bean 实例,通过反射去修改对应属性值 +ognl -c 34f5090e org.ClassLoader +'#context=@com.alibaba.dubbo.config.spring.extension.SpringExtensionFactory@contexts.iterator.next, #instence=#context.getBean("userServiceImpl"),#fieldObj=@com.User@class.getDeclaredField("age"),#fieldObj.setAccessible(true), #fieldObj.set(#instence,18)' + +# 除了 ognl 也可以通过 vmtool 去获取 bean +vmtool --action getInstances --className org.springframework.context.ApplicationContext --express 'instances[0].getBean("userServiceImpl")' +``` + +Arthas 的强大之处确实令人惊叹!本文希望能够启发您去探索更多关于 Arthas 的用法和功能,相信它会为您的开发工作带来很大的帮助和便利。 \ No newline at end of file diff --git "a/docs/md/\345\205\266\344\273\226/\347\224\250\345\245\275PowerMock\357\274\214\350\275\273\346\235\276\346\220\236\345\256\232\351\202\243\344\272\233\350\256\251\344\275\240\345\244\264\347\226\274\347\232\204\345\215\225\345\205\203\346\265\213\350\257\225.md" "b/docs/md/\345\205\266\344\273\226/\347\224\250\345\245\275PowerMock\357\274\214\350\275\273\346\235\276\346\220\236\345\256\232\351\202\243\344\272\233\350\256\251\344\275\240\345\244\264\347\226\274\347\232\204\345\215\225\345\205\203\346\265\213\350\257\225.md" new file mode 100644 index 0000000..e405c0d --- /dev/null +++ "b/docs/md/\345\205\266\344\273\226/\347\224\250\345\245\275PowerMock\357\274\214\350\275\273\346\235\276\346\220\236\345\256\232\351\202\243\344\272\233\350\256\251\344\275\240\345\244\264\347\226\274\347\232\204\345\215\225\345\205\203\346\265\213\350\257\225.md" @@ -0,0 +1,451 @@ +> 面对无法Mock的静态方法、私有方法和final类,PowerMock为你打开一扇新的大门 + +作为一名Java开发者,单元测试是我们保证代码质量的重要环节。但在实际工作中,我们经常会遇到一些难以测试的代码场景:静态工具类、final类、私有方法等。传统的Mockito框架对这些情况束手无策,而PowerMock的出现正好解决了这些痛点。 + +# PowerMock是什么?为什么需要它? + +## PowerMock的核心定位 + +PowerMock是一个强大的Java单元测试框架,它通过扩展现有的Mock框架(如Mockito和EasyMock),提供了更强大的Mock能力。**PowerMock的核心价值在于它能够Mock那些传统Mock工具无法处理的情况**,包括静态方法、final类和方法、私有方法、构造函数等。 + +与普通Mock框架不同,PowerMock使用自定义的类加载器和字节码操作技术(基于Javassist和ASM库),在运行时修改类的行为,从而实现对这些"难以Mock"的场景的完全控制。 + +## PowerMock与Mockito的关系和区别 + +虽然PowerMock和Mockito都是用于单元测试的Mock框架,但它们在功能和定位上有着明显的区别: + +**Mockito**是一个轻量级、简单易用的Mock框架,适用于大多数日常测试场景。但它有明显的局限性:无法Mock静态方法、final类、私有方法和构造函数等。 + +**PowerMock**则是对Mockito的增强,填补了Mockito的功能空白。它不是替代Mockito,而是与Mockito协同工作,共同构建完整的单元测试解决方案。 + +两者核心区别体现在底层实现上:Mockito使用动态代理(CGLIB)技术,而PowerMock通过修改字节码来实现更强大的Mock能力。 + +正因为这种根本差异,PowerMock可以解决Mockito无法解决的问题。 + +## PowerMock解决的痛点 + +在日常开发中,我们经常会遇到以下测试难题: + +- **静态工具类**:如各种Util类中的静态方法。 +- **final类和final方法**:特别是第三方库中的final类。 +- **私有方法**:需要直接测试的私有方法逻辑。 +- **构造函数依赖**:方法内部通过new创建的对象。 +- **静态代码块和系统类**:如System.currentTimeMillis()。 + +这些问题使用传统Mock框架难以解决,而PowerMock为此提供了完整的解决方案 + +# 环境配置与基本用法 + +## 添加Maven依赖 + +要开始使用PowerMock,首先需要在项目中添加相关依赖。由于PowerMock需要与Mockito协同工作,需要同时添加两个依赖: + +```xml + + + org.powermock + powermock-module-junit4 + 2.0.9 + test + + + org.powermock + powermock-api-mockito2 + 2.0.9 + test + +``` + +**版本兼容性注意**:确保PowerMock与Mockito/JUnit版本匹配,具体兼容性关系可参考官方文档。 + +## 基本配置注解 + +使用PowerMock需要在测试类上添加必要的注解: + +```java +@RunWith(PowerMockRunner.class) // 必须使用PowerMockRunner +@PrepareForTest({StaticUtils.class, User.class}) // 声明需增强的类 +@PowerMockIgnore("javax.management.*") // 解决类加载器冲突 +public class UserServiceTest { + // 测试内容 +} +``` + +- `@RunWith(PowerMockRunner.class)`:告诉JUnit使用PowerMock的测试运行器。 +- `@PrepareForTest`:指定需要被PowerMock修改的类(包含静态方法、final方法等的类)。 +- `@PowerMockIgnore`:解决使用PowerMock后可能出现的类加载器冲突问题。 + +# PowerMock核心使用场景详解 + +## 静态方法Mock + +静态方法是最常见的测试难点之一,让我们看看PowerMock如何解决这个问题。 + +**场景示例**:假设我们有一个静态工具类,用于生成唯一ID: + +```java +public class IdGenerator { + public static String generateUniqueId() { + // 实际业务中可能包含复杂的逻辑或外部依赖 + return UUID.randomUUID().toString(); + } +} + +public class OrderService { + public String createOrder() { + String orderId = IdGenerator.generateUniqueId(); + // 创建订单的逻辑 + return "ORDER_" + orderId; + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest({IdGenerator.class, OrderService.class}) +public class OrderServiceTest { + + @Test + public void testCreateOrderWithStaticMock() { + // 1. 准备静态类的Mock + PowerMockito.mockStatic(IdGenerator.class); + + // 2. 预设静态方法行为 + PowerMockito.when(IdGenerator.generateUniqueId()).thenReturn("123e4567"); + + // 3. 创建被测试对象并调用被测方法 + OrderService orderService = new OrderService(); + String result = orderService.createOrder(); + + // 4. 验证结果 + assertEquals("ORDER_123e4567", result); + + // 5. 验证静态方法调用(必须调用) + PowerMockito.verifyStatic(IdGenerator.class); + IdGenerator.generateUniqueId(); + } +} +``` + +**关键点说明**: + +- `mockStatic()`方法用于告诉PowerMock要Mock哪个类的静态方法 +- 静态方法的Stubbing(定义行为)与普通Mockito语法类似 +- **必须调用**`verifyStatic()`来验证静态方法的调用,且需要在验证前调用一次 + +**常见坑点**:忘记调用`verifyStatic()`会导致无法验证静态方法是否被正确调用。 + +## 私有方法Mock + +测试私有方法一直存在争议,但在某些场景下(如复杂算法验证)确实有必要直接测试私有方法。 + +**场景示例**:一个包含复杂校验逻辑的UserService: + +```java +public class UserService { + public boolean validateUser(String username, String password) { + if (!isValidFormat(username) || !isValidFormat(password)) { + return false; + } + return internalComplexValidation(username, password); + } + + private boolean isValidFormat(String input) { + // 复杂的格式校验逻辑 + return input != null && input.length() >= 5; + } + + private boolean internalComplexValidation(String username, String password) { + // 非常复杂的内部校验逻辑 + // 可能涉及加密、数据库查询等 + return true; // 简化示例 + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest(UserService.class) +public class UserServiceTest { + + @Test + public void testPrivateMethod() throws Exception { + // 1. 创建被测类的Spy对象(部分真实调用) + UserService userService = new UserService(); + UserService spyService = PowerMockito.spy(userService); + + // 2. Stubbing:预设私有方法行为 + PowerMockito.doReturn(true).when(spyService, "isValidFormat", Mockito.anyString()); + + // 3. 调用被测方法 + boolean result = spyService.validateUser("testuser", "testpass"); + + // 4. 验证结果 + assertTrue(result); + + // 5. 验证私有方法被调用(可选) + PowerMockito.verifyPrivate(spyService,Mockito.times(2)) + .invoke("isValidFormat", Mockito.anyString()); + } + + @Test + public void testPrivateMethodWithArguments() throws Exception { + UserService userService = new UserService(); + UserService spyService = PowerMockito.spy(userService); + + // Mock有参数的私有方法 + PowerMockito.doReturn(false) + .when(spyService, "internalComplexValidation", "user", "pass"); + + boolean result = spyService.validateUser("user", "pass"); + + assertFalse(result); + } +} +``` + +**关键点说明**: + +- 使用`spy()`方法创建对象,这样未被Mock的方法会保持真实行为。 +- 使用`doReturn().when()`语法来Mock私有方法,需通过方法名字符串指定目标方法。 +- 可以通过`verifyPrivate()`验证私有方法的调用。 + +**最佳实践**:优先通过公共方法测试私有逻辑,仅在复杂算法验证等特殊场景下直接测试私有方法。 + +## final类与方法Mock + +final类和方法由于其不可继承性,在传统Mock框架中无法被Mock,但PowerMock完美解决了这个问题。 + +**场景示例**: + +```java +public final class FinalUtility { + public final String finalMethod() { + return "Final implementation"; + } + + public static final String staticFinalMethod() { + return "Static final implementation"; + } +} + +public class SomeService { + private FinalUtility utility = new FinalUtility(); + + public String useFinalClass() { + return utility.finalMethod() + "_processed"; + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest({FinalUtility.class, SomeService.class}) +public class SomeServiceTest { + + @Test + public void testFinalClassAndMethod() { + // 1. 创建final类的Mock对象 + FinalUtility mockUtility = PowerMockito.mock(FinalUtility.class); + + // 2. 预设final方法行为 + PowerMockito.when(mockUtility.finalMethod()).thenReturn("Mocked final"); + + // 3. 当创建真实对象时返回Mock对象 + PowerMockito.whenNew(FinalUtility.class).withNoArguments().thenReturn(mockUtility); + + // 4. 测试 + SomeService service = new SomeService(); + String result = service.useFinalClass(); + + assertEquals("Mocked final_processed", result); + } + + @Test + public void testStaticFinalMethod() { + // Mock静态final方法 + PowerMockito.mockStatic(FinalUtility.class); + PowerMockito.when(FinalUtility.staticFinalMethod()).thenReturn("Mocked static final"); + + assertEquals("Mocked static final", FinalUtility.staticFinalMethod()); + } +} +``` + +**底层原理**:PowerMock通过修改字节码,去除了final方法的final标识符,从而允许Mock操作。 + +## 构造函数Mock + +当方法内部直接通过new创建对象时,传统Mock难以介入,PowerMock的构造函数Mock功能为此提供了解决方案。 + +**场景示例**: + +```java +public class DatabaseConnection { + private String connectionString; + + public DatabaseConnection(String connectionString) { + this.connectionString = connectionString; + // 可能包含复杂的初始化逻辑 + } + + public boolean execute(String sql) { + // 执行SQL逻辑 + return true; + } +} + +public class UserRepository { + public boolean saveUser(String username) { + // 在方法内部直接创建依赖对象 + DatabaseConnection connection = new DatabaseConnection("jdbc:mysql://localhost:3306/test"); + return connection.execute("INSERT INTO users VALUES ('" + username + "')"); + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest(UserRepository.class) +public class UserRepositoryTest { + + @Test + public void testConstructorMock() throws Exception { + // 1. 创建Mock对象 + DatabaseConnection mockConnection = PowerMockito.mock(DatabaseConnection.class); + + // 2. 预设构造函数行为 + PowerMockito.whenNew(DatabaseConnection.class) + .withParameterTypes(String.class) + .withArguments("jdbc:mysql://localhost:3306/test") + .thenReturn(mockConnection); + + // 3. 预设方法行为 + PowerMockito.when(mockConnection.execute(Mockito.anyString())).thenReturn(true); + + // 4. 执行测试 + UserRepository repository = new UserRepository(); + boolean result = repository.saveUser("testuser"); + + // 5. 验证 + assertTrue(result); + PowerMockito.verifyNew(DatabaseConnection.class) + .withArguments("jdbc:mysql://localhost:3306/test"); + } +} +``` + +**关键点说明**: + +- `whenNew()`用于拦截构造函数调用。 +- `withParameterTypes()`和`withArguments()`用于精确匹配构造函数。 +- 需要使用`verifyNew()`验证构造函数调用。 + +**应用场景**:适用于测试遗留代码中在方法内部直接实例化依赖对象的情况。 + +## 静态代码块处理 + +静态代码块在类加载时执行,可能包含不愿在测试中运行的代码(如初始化昂贵资源),PowerMock可以抑制静态代码块的执行。 + +**示例**: + +```java +public class ConfigurationLoader { + static { + // 静态代码块,可能包含昂贵的初始化操作 + loadConfigurationFromRemote(); + } + + private static void loadConfigurationFromRemote() { + // 模拟昂贵的初始化 + throw new RuntimeException("不应该在测试中执行"); + } + + public static String getConfig(String key) { + return "value"; + } +} +``` + +**测试代码**: + +```java +@RunWith(PowerMockRunner.class) +@PrepareForTest(ConfigurationLoader.class) +public class ConfigurationLoaderTest { + + @Test + public void testSuppressStaticInitializer() throws Exception { + // 抑制静态代码块执行 + PowerMockito.suppress(PowerMockito.method(ConfigurationLoader.class, "loadConfigurationFromRemote")); + + // 现在可以安全测试,静态代码块不会执行 + assertNotNull(ConfigurationLoader.getConfig("testkey")); + } +} +``` + +# PowerMock最佳实践与注意事项 + +## 谨慎使用PowerMock + +虽然PowerMock功能强大,但过度使用可能是代码设计问题的信号。**以下是一些使用原则**: + +- **优先考虑重构**:如果代码中大量使用PowerMock,应该考虑重构代码以提高可测试性。例如,将静态方法改为实例方法,通过依赖注入解耦等。 +- **仅用于遗留代码**:在新项目中,优先通过良好设计避免使用PowerMock,仅在处理难以修改的遗留代码时大量使用。 +- **隔离使用**:将使用PowerMock的测试类单独放置,防止影响其他测试的执行效率。 + +## 性能优化建议 + +PowerMock由于使用自定义类加载器和字节码操作,会对测试执行时间产生显著影响。以下是一些优化建议: + +- **最小化@PrepareForTest**:只将确实需要Mock的类放入注解中,减少字节码操作的范围。 +- **合理使用Mockito**:对于常规Mock场景,仍然使用Mockito,仅在必要时使用PowerMock。 +- **避免过度Mock**:不要Mock系统类或简单值对象,这会给测试带来不必要的复杂性。 + +## 版本选择与兼容性 + +**版本兼容性**:PowerMock与Mockito、JUnit的版本兼容性非常重要。以下是推荐组合: + +- PowerMock 2.x + Mockito 2.x + JUnit 4.12+ +- 避免混合使用不兼容的版本 + +**JUnit 5支持**:截至目前,PowerMock不支持JUnit 5,这是选择测试框架时需要考虑的因素。 + +## 常见问题排查 + +**类加载器冲突**:使用`@PowerMockIgnore`注解排除冲突的包。 + +```java +@PowerMockIgnore({"javax.management.*", "javax.net.ssl.*"}) +``` + +**版本冲突**:确保所有Mock相关库的版本兼容。 + +**静态方法验证失败**:记住每次验证静态方法调用时都要先调用`verifyStatic()`。 + +# 总结 + +PowerMock解决了传统Mock框架无法处理的棘手问题。通过字节码操作技术,PowerMock能够Mock静态方法、final类、私有方法和构造函数等"不可Mock"的元素。 + +**核心价值**: + +- 填补了Mockito的功能空白,完善了Java单元测试的工具链。 +- 特别适用于处理遗留代码和第三方库的测试问题。 +- 通过提高代码覆盖率来提升软件质量。 + +**适用边界**: + +- 不是所有场景都适合使用PowerMock,新项目应优先考虑良好的代码设计。 +- 在测试性能和代码可维护性之间需要权衡。 +- 建议将使用范围控制在确实必要的复杂场景中。 + +希望本文能帮助你在实际项目中更好地使用PowerMock。如果你有任何问题或经验分享,欢迎在评论区留言交流! \ No newline at end of file diff --git "a/docs/md/\345\205\266\344\273\226/\350\211\257\345\277\203\346\216\250\350\215\220\357\274\201\345\207\240\346\254\276\346\224\266\350\227\217\347\232\204\347\245\236\347\272\247IDEA\346\217\222\344\273\266\345\210\206\344\272\253.md" "b/docs/md/\345\205\266\344\273\226/\350\211\257\345\277\203\346\216\250\350\215\220\357\274\201\345\207\240\346\254\276\346\224\266\350\227\217\347\232\204\347\245\236\347\272\247IDEA\346\217\222\344\273\266\345\210\206\344\272\253.md" new file mode 100644 index 0000000..a28017f --- /dev/null +++ "b/docs/md/\345\205\266\344\273\226/\350\211\257\345\277\203\346\216\250\350\215\220\357\274\201\345\207\240\346\254\276\346\224\266\350\227\217\347\232\204\347\245\236\347\272\247IDEA\346\217\222\344\273\266\345\210\206\344\272\253.md" @@ -0,0 +1,340 @@ +本文已收录至Github,推荐阅读 👉 [Java随想录](https://github.com/ZhengShuHai/JavaRecord) + +微信公众号:[Java随想录](https://mmbiz.qpic.cn/mmbiz_jpg/jC8rtGdWScMuzzTENRgicfnr91C5Bg9QNgMZrxFGlGXnTlXIGAKfKAibKRGJ2QrWoVBXhxpibTQxptf8MsPTyHvSg/0?wx_fmt=jpeg) + +[TOC] + + +IDEA 拥有众多优秀的插件,这些插件能够极大地提升我们的开发效率和提供更好的编码体验。正所谓:工欲善其事,必先利其器。借助这些插件,我们能更加高效地进行开发,让编码变得轻松愉快。 + +在本篇中,我将向大家推荐一些个人收藏的实用 IDEA 插件,并根据使用情况对它们进行评级: + +> - 强烈推荐:★★★★★ +> - 推荐:★★★★ + +话不多说,我们正式开始。 + +## CodeGlance + +推荐指数:★★★★ + +编辑区迷你缩放图插件,鼠标悬停还有放大镜的功能。特别适用于处理大量代码时的快速定位需求,让我们更轻松地浏览和编辑代码。 + + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lkrjoWIZJTPU3kQT7QkMEAS2VdFEnbeup6MSib2ibicDVJjB2e8VjuqDMw/640) + +## GsonFormat + +推荐指数:★★★★★ + +Json 转 Java 类,该插件可以快速生成类,提高开发效率。 + +使用方法:先新建一个类,选中类名,右键点击生成,点击 `GsonFormat` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lfxq7dSwqYibDNtt4Z7nYXGQUvEicDuia2Q3hanDTkL4CDDmLPfFojYTQw/640) + +然后输入 JSON,点击OK,即可生成。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lW1pHgoLSWicXY8ic1hcyhTeUOKX4Z6jdccvn97O0ibN0Wxr4libQusGpBw/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lWQAH0QoblXz6sSQl0ZYOke8uPfFKFLfGtIcsTQ4icicuKMnZrB1iaTyvw/640) + +## POJO to Json + +推荐指数:★★★★★ + +跟 `GsonFormat` 是两兄弟,`GsonFormat` 是将 JSON 转为 POJO,而 `POJO to Json` 则是将 POJO 转为 JSON。 + +使用方法:选中类,右击 `Copy JSON` 即可复制。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lXP9ickD1bqAdYZtKUV9cvgu3hCiaHrQMHiagkUaeB4w2A4wicib6ibLbOGiaQ/640) + +## Rainbow Brackets + +推荐指数:★★★★★ + +可以将括号用不同颜色标记出来,方便使用者快速识别代码层次,提高开发效率。 + + + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4l7kSTr2KCicxOtfyY7PDpIoBwFFlWHHoaPKMugcHtsGV1lbM9juiaqJ6g/640) + + + +## Translation + +推荐指数:★★★★★ + +翻译插件,支持谷歌、有道、百度三种翻译。特别是阅读源码的时候,非常有帮助。 + + + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lb8SGl3LiaxoRfFVukicgyLRUox1ibxRSebK37cVRibox51GyjJppJibRGpw/640) + + + +## Lombok + +推荐指数:★★★★★ + +主要用来简化代码,减少 get()、set()等方法的编写,不过有些公司可能禁止使用 Lombok 插件。 + +最常用的就是 `@Data` 注解,在类上直接使用即可。使用的时候记得打开注解处理器:`Annotation Processors > Enable annotation processing`。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lG59c22vDz3icaj2m83IlSwBkkftuJXmPbxkKcKSO2oHpqoABR2CcXVw/640) + + + +## Maven Helper + +推荐指数:★★★★★ + +可以解析 Maven 依赖,处理依赖冲突很方便,Java开发必备。 + +使用方法:安装之后,去到项目的 pom.xml 文件,在 pom.xml 右边下面有个 `Dependency Analyzer` 的Tab选项。 + + + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lQmk7eNPsb5vr5TuUOSa7Eks3JQ9rDdqCxKH36blbqhTtZD9LpA8Kpg/640) + + + +## Alibaba Java Code Guidelines + +推荐指数:★★★★★ + +阿里巴巴的代码规范插件,可以帮助规范代码质量,程序员必装! + +安装完之后,工具栏会显示这两个图标。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lKQKglCzqkk0zyzA5Rfr34wzKE5W8Ae3V8tG5s6TJTnqng03sdrq4WA/640) + + + + + +## GenerateAllSetter + +推荐指数:★★★★★ + +针对已有的实体对象的属性生成 `set()` 方法代码,在造假数据测试时非常有用。 + +选择实例,按 `Alt + Enter`,即可出现选项。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lyx1lRic3kHRV7oFy18DIXdR1icia6SfK7RqKxJO3dyUmsOagELJ8uibN3w/640) + + + +## MybatisX + +推荐指数:★★★★★ + +搭配 `Mybatis-Plus` 使用,这个插件有个最大的优点就是可以快速生成,entity,dao,mapper 文件。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lPWib9tibSrchMypicmCf3icSlSkLx6kz7RA8STMknBxktPoicE9p7VibdsZA/640) + +连接数据库之后, 右键对应的表,选择 `MybatiX-Generator` 选项即可生成。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lAL00Cjm8Ch40m1SrOL1ic3YZ3GXZicU38NrFWno6NicbIxFKhPtmHorSA/640) + + + +## Chinese (Simplified) Language Pack / 中文语言包 + +推荐指数:★★★★★ + +神!IDEA 官方的中文汉化包,对我来说这款插件绝对不能少,可能有人习惯看英文(英语好的略过)。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lIxbBl8xa9WkhJl2rupbORiad9uymLdn3V3UmIWWE1qLOt0o9wiaAXiaCQ/640) + +## Key Promoter X + +推荐指数:★★★★ + +`Key Promoter X` 是一个提示插件,当你在 `IDEA` 里面使用鼠标的时候,如果这个鼠标操作是能够用快捷键替代的,那么`Key Promoter X`会弹出一个提示框,告知你这个鼠标操作可以用什么快捷键替代。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lpCg2YJrG55Yqz6uU68jDXOjo9lGibeF4db1iaGzBXWxmIz9zdAEo2Ruw/640) + +## Arthas Idea + +推荐指数:★★★★★ + +可以自动帮我们生成 Arthas命令,选中类或方法右键点击 `Arthas Command` 即可生成。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lK1AHyuUziauDauWxjwQaOx8PqNeRA13rs42LdBk5xNKL75j3zArXo0A/640) + + + +## GitToolBox + +推荐指数:★★★★ + +在自带的 Git 功能之上,新增了查看 Git 状态、自动拉取代码、提交通知等功能。 + +安装之后可以查看到每一行代码的最近一次提交信息。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lciafx92re4LaxINxgvXMNupqAPoltPnhibLyiby3MsZlgy0hJGBfMRcGw/640) + + + +## VisualGC + +推荐指数:★★★★ + + JVM 堆栈可视化工具,支持查看本地和远程 JVM 进程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lnTWwtQW2rhA0qaBtzZhgBxTSaYNRNlqK9e4wVmJtJmeQLdEYrbZYkw/640) + +## String Manipulation + +推荐指数:★★★★ + +String Manipulation 插件用来对字符串进行处理,比如:变量名使用驼峰形式、常量需要全部大写,编码解码等等,右击字符串即可使用。 + + + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4larl7vsy3HkfYMsnvoCQC0HxYYSAjlibXrmbBZaNqfgMVpK6zrplMBBA/640) + +## SequenceDiagram + +推荐指数:★★★★ + +自动生成方法调用时序图,能够帮助快速梳理代码逻辑。免费版对方法层级有限制,日常使用基本也够了。 + + + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lQtq5tqQ00hibbslaxycXVssCBxlSGznHyaEfZM1MibRQR0KjunBHqpKQ/640) + + + +## CheckStyle-IDEA + +推荐指数:★★★★ + +帮助 JAVA开发人员遵守某些编码规范的工具。它能够自动化代码规范检查过程,右击选择 `Check Current File` 即可给出 Style 建议。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4l2StO6Gic2HvZ02QJHTDMjKoTFYiaia2G5dadogctEcjicofrDr6w087jjg/640) + +## SonarLint + +推荐指数:★★★★ + +帮助开发人员发现和修复代码的错误和漏洞,安装完毕之后下方会有 `SonarLint` 菜单栏。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lNERoUCl3RCmuh7HAYVHCQXlLSMI4n4Xl28C2bkwQcuUgrnAYRGZ25w/640) + +## jclasslib Bytecode Viewer + +推荐指数:★★★★ + +字节码查看器,对于字节码学习非常有帮助。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lwjjrpkI5XIyH57b9XVwLHtSGt8PZnNKVoSDyymce4lXOQEN6DyB7LA/640) + +安装之后在视图栏就可以直接打开查看。 + +## Properties to YAML Converter + +推荐指数:★★★★ + +把 Properties 文件的格式转为 YAML 格式。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lk7z8WiaI0BY8lWYyNKFpXQcK89p97hR2ibswDXBB6VForH9TIibibUKJQw/640) + +鼠标右击 properties 文件选择 `Convert Properties to YAML` 即可转为 YAML 格式。 + +## Alibaba Cloud Tookit + +推荐指数:★★★★★ + +Alibaba Cloud Toolkit 可以帮助开发者更高效地部署、测试、开发和诊断应用。帮助开发人员大大简化应用部署到服务器,尤其是阿里云服务器中的操作。还可以通过其内嵌的 Arthas 程序诊断、Terminal Shell 终端和 MySQL 执行器等工具,简化应用开发、测试和诊断的过程。 + + + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lJFVYwwkY7LaDqvGd2KNjHnFS7st08ymhrqUL1yqeiaPicfMEUhMX5NpQ/640) + +更多使用建议参考官方文档。 + +## One Dark theme + +推荐指数:★★★★★ + +个人最喜欢的主题插件。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4l13M2AicOtZxuEnYJMRbeEEGhicAViaOG0cpeNBNDfeme769usEI6B63Dg/640) + +安装之后可以去主题里修改,这里推荐:`One Dark vivid ltalic`。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lZ7rp7DibibEkLWrWPniclSert9QgHibvW2Pwib5QNTNlvonMT2vhG2ibdc3A/640) + + + + + +## PlantUML Integration + +推荐指数:★★★★★ + +神!开发人员必备插件,平时出技术方案流程图,用例图等全靠它了,关键还免费。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lghc4URyGjzCuWmCutwokpLEcQOF0rQdEtIXtwRfteiaTCX7libH8j7OQ/640) + + + +更多语法参考官网:https://plantuml.com/zh/,官网还支持中文,非常人性化。 + +## any-rule + +推荐指数:★★★★ + +这款插件不是特别大众,但是特别实用,可以快速生成正则表达式。 + +安装之后右击 选择 `AnyRule` 即可使用。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lREQfuNAF2ylTYOFC3mKSEzaLaShRzFFbhTleGKSP2jn1x4CmjqtZicg/640) + + + +## Tabnine + +推荐指数:★★★★ + +代码智能提示插件。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lWXtSTYZco7bq8U09WLzlg5xE4qHen4Kp3DFGcRGysg6YKicjx6AC15A/640) + +编码过程中按 `Tab` 即可采纳建议。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lBzZaOhvYVI6EqJNoIjdeHb6R84FD69MSgwziblsznl9gyicia8ItqdBVA/640) + + + +## TONGYI Lingma + +推荐指数:★★★★★ + +阿里出品的通义灵码,刚发布不久,也是智能AI编码插件。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4lvzL6Ow4Yt0ltQanNQmkoo5O6Xl6cjZxnvVvtNPE5iaZ6e2ricV6jz6aQ/640) + +注意要登陆才能使用。 + +## Git Commit Message Helper + +推荐指数:★★★★★ + +这款插件,知道的人并不多,但是却是我使用频率最高的插件之一。 + +Git Commit Message Helper 能够帮助开发人员提交出规范的 Git Commit。 + +使用也非常简单,提交代码的时候点击右边的图标即可使用。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnKt1yKmAD9Y4EaMjrVD4l7yA9wUlEeTtibJWxMofCN2J1oLlNBvdBl4T3TFU6UfhengA2Kh6u28Q/640) + +这里再分享一篇关于 Git Commit 规范的文章:[如何规范你的Git commit?](https://zhuanlan.zhihu.com/p/182553920) + + + +以上这几款 IDEA 插件是我平常开发中经常用到的,如果大家有更好的插件,欢迎分享出来。 + +> 插件持续更新中。记得收藏! \ No newline at end of file diff --git "a/docs/md/\345\244\247\346\225\260\346\215\256/\344\270\200\347\257\207\345\205\245\351\227\250HBase\357\274\210\347\220\206\350\256\272+\344\273\243\347\240\201\345\256\236\346\210\230\357\274\211.md" "b/docs/md/\345\244\247\346\225\260\346\215\256/HBase\345\205\245\351\227\250\346\214\207\345\215\227.md" similarity index 56% rename from "docs/md/\345\244\247\346\225\260\346\215\256/\344\270\200\347\257\207\345\205\245\351\227\250HBase\357\274\210\347\220\206\350\256\272+\344\273\243\347\240\201\345\256\236\346\210\230\357\274\211.md" rename to "docs/md/\345\244\247\346\225\260\346\215\256/HBase\345\205\245\351\227\250\346\214\207\345\215\227.md" index 9ec337e..559eb9a 100644 --- "a/docs/md/\345\244\247\346\225\260\346\215\256/\344\270\200\347\257\207\345\205\245\351\227\250HBase\357\274\210\347\220\206\350\256\272+\344\273\243\347\240\201\345\256\236\346\210\230\357\274\211.md" +++ "b/docs/md/\345\244\247\346\225\260\346\215\256/HBase\345\205\245\351\227\250\346\214\207\345\215\227.md" @@ -1,29 +1,28 @@ -[TOC] +HBase是一个开源的非关系型分布式数据库,设计初衷是为了解决大量结构化数据存储与处理的需求。 -HBase(Hadoop Database)是一个开源的、分布式的、面向列的NoSQL数据库,它是构建在Hadoop之上的。HBase旨在提供可靠的、高性能的、可扩展的存储和访问大规模数据集的能力。 +它的核心理念、特性以及应用领域在当今的大数据环境中都发挥着至关重要的作用,这也是我们需要深入理解HBase的原因。在这篇文章中,我们将探讨HBase的基础概念,通过这些知识,读者将能够理解HBase的基本工作原理以及如何利用它处理数据问题。 ## HBase特性 以下是HBase的一些关键特性和概念: -1. 分布式架构:HBase是一个分布式数据库,它可以在一个集群中运行在多个机器上。数据以水平分片的方式分布在不同的机器上,这样可以实现数据的高可用性和横向扩展性。 -2. 列存储:**HBase是面向列的数据库**,它将数据存储在表中的列族中。每个列族可以包含多个列,这样可以方便地存储和检索具有不同结构的数据。HBase的列存储特性使得可以高效地读取和写入大量数据。 -3. 强一致性:**HBase提供强一致性的读写操作**。当数据被写入或读取时,HBase会确保所有相关的副本都是最新的。这使得HBase非常适合需要强一致性的应用场景,如金融、电信等领域。 -4. 高可扩展性:HBase可以轻松地扩展到大规模的数据集和集群。通过添加更多的机器和分片数据,可以线性地扩展存储容量和吞吐量。 -5. 快速读写:HBase是为了高性能而设计的。它使用了内存和硬盘的组合来存储数据,可以实现快速的读写操作。此外,HBase还支持批量写入和异步写入,进一步提高了写入性能。 -6. 灵活的数据模型:HBase提供了灵活的数据模型,可以根据应用程序的需求设计表结构。它支持动态添加列,并且可以高效地执行范围查询和单行读写操作。 -7. 数据一致性:HBase通过使用ZooKeeper来管理集群的元数据和协调分布式操作,确保数据的一致性和可用性。 -8. 集成Hadoop生态系统:HBase与Hadoop生态系统紧密集成,可以与Hadoop分布式文件系统(HDFS)和Hadoop的计算框架(如MapReduce)无缝配合使用。这使得HBase能够处理大规模的数据存储和分析任务。 +- **分布式架构**:HBase是一个分布式数据库,它可以在一个集群中运行在多个机器上。数据以水平分片的方式分布在不同的机器上,这样可以实现数据的高可用性和横向扩展性。 +- **列存储**: HBase是面向列的数据库,它将数据存储在表中的列族中。每个列族可以包含多个列,这样可以方便地存储和检索具有不同结构的数据。HBase的列存储特性使得可以高效地读取和写入大量数据。 +- **强一致性**:HBase提供强一致性的读写操作。当数据被写入或读取时,HBase会确保所有相关的副本都是最新的。这使得HBase非常适合需要强一致性的应用场景,如金融、电信等领域。 +- **高可扩展性**:HBase可以轻松地扩展到大规模的数据集和集群。通过添加更多的机器和分片数据,可以线性地扩展存储容量和吞吐量。 +- **快速读写**:HBase是为了高性能而设计的。它使用了内存和硬盘的组合来存储数据,可以实现快速的读写操作。此外,HBase还支持批量写入和异步写入,进一步提高了写入性能。 +- **灵活的数据模型**:HBase提供了灵活的数据模型,可以根据应用程序的需求设计表结构。它支持动态添加列,并且可以高效地执行范围查询和单行读写操作。 +- **集成Hadoop生态系统**:HBase与Hadoop生态系统紧密集成,可以与Hadoop分布式文件系统(HDFS)和Hadoop的计算框架(如MapReduce)无缝配合使用。这使得HBase能够处理大规模的数据存储和分析任务。 ## Hadoop的限制 -尽管Hadoop是一个强大的分布式计算框架,但它也存在一些不足之处,与HBase相比,以下是一些Hadoop的限制: +尽管Hadoop是一个强大的分布式计算框架,但它也存在一些不足之处,与HBase相比,以下是Hadoop的一些限制: -1. 实时性:**Hadoop主要用于批处理任务,对于实时性要求较高的应用场景,如实时数据分析和流式处理,Hadoop的延迟可能会比较高**。Hadoop的MapReduce模型通常不适合处理需要即时响应的数据处理任务。 -2. 存储效率:Hadoop在存储效率方面存在一些问题。为了提供容错性和可靠性,Hadoop将数据复制多次存储在不同的节点上,这会导致存储开销增加。相对于HBase的列存储模型,Hadoop的存储效率可能较低。 -3. 复杂性:Hadoop的配置和管理相对复杂,需要专业知识和经验。搭建和维护一个Hadoop集群需要处理许多参数和组件,对于初学者来说可能存在一定的学习曲线。 -4. 扩展性限制:虽然Hadoop具有良好的可扩展性,可以通过添加更多的节点来扩展集群的存储和计算能力,但在某些情况下,随着集群规模的增加,管理和调度节点可能变得更加困难。 -5. 处理复杂查询的限制:Hadoop的主要计算模型是MapReduce,它适合处理简单的计算任务,但对于复杂的查询和数据分析,如复杂聚合、连接和实时查询等,Hadoop的性能可能不如专门设计的分析数据库。 +- **实时性**:Hadoop主要用于批处理任务,对于实时性要求较高的应用场景,如实时数据分析和流式处理,Hadoop的延迟可能会比较高。Hadoop的MapReduce模型通常不适合处理需要即时响应的数据处理任务。 +- **存储效率**:Hadoop在存储效率方面存在一些问题。为了提供容错性和可靠性,Hadoop将数据复制多次存储在不同的节点上,这会导致存储开销增加。相对于HBase的列存储模型,Hadoop的存储效率可能较低。 +- **复杂性**:Hadoop的配置和管理相对复杂,需要专业知识和经验。搭建和维护一个Hadoop集群需要处理许多参数和组件,对于初学者来说可能存在一定的学习曲线。 +- **扩展性限制**:虽然Hadoop具有良好的可扩展性,可以通过添加更多的节点来扩展集群的存储和计算能力,但在某些情况下,随着集群规模的增加,管理和调度节点可能变得更加困难。 +- **处理复杂查询的限制**:Hadoop的主要计算模型是MapReduce,它适合处理简单的计算任务,但对于复杂的查询和数据分析,如复杂聚合、连接和实时查询等,Hadoop的性能可能不如专门设计的分析数据库。 ## 基本概念 @@ -31,27 +30,29 @@ HBase(Hadoop Database)是一个开源的、分布式的、面向列的NoSQL 命名空间,类似于关系型数据库的Database概念,每个命名空间下有多个表。 -HBase自带两个命名空间,分别是**hbase**和**default**,hbase 中存放的是HBase内置的表,default表是用户默认使用的命名空间,这2个命名空间默认是不展示的。 +HBase自带两个命名空间,分别是**hbase**和**default**,hbase 中存放的是HBase内置的表,default表是用户默认使用的命名空间,这两个命名空间默认是不展示的。 ### Table -类似于关系型数据库的表概念。不同的是,**HBase定义表时只需要声明列族即可,不需要声明具体的列。因为数据存储时稀疏的,空(null)列不占用存储空间,所有往HBase写入数据时,字段可以动态、按需指定。因此,和关系型数据库相比,HBase 能够轻松应对字段变更的场景**。 +类似于关系型数据库的表概念。不同的是,**HBase定义表时只需要声明列族即可,不需要声明具体的列。因为数据存储是稀疏的,空(null)列不占用存储空间,所以往HBase写入数据时,字段可以动态、按需指定。因此,和关系型数据库相比,HBase 能够轻松应对字段变更的场景**。 ### RowKey -HBase表中的每行数据都由一个RowKey和多个Column(列)组成,数据是按照RowKey的字典顺序存储的,**并且查询数据时只能根据RowKey进行检索,所以RowKey的设计十分重要**。 +HBase表中的每行数据都由一个RowKey和多个Column(列)组成,数据是按照RowKey的字典顺序存储的,**并且查询数据时只能根据RowKey进行检索,所以RowKey的设计十分重要**。 ### Column -HBase中的每个列都由Colunn Family (列族)和Column Qualifier (列限定符)进行限定,例如info: name, info: age。 建表时,只需指明列族,而列限定符无需预先定义。 +HBase中的每个列都由**Colunn Family (列族)**和**Column Qualifier (列限定符)**进行限定,例如info: name, info: age。 + +建表时,只需指明列族,而列限定符无需预先定义。 ### TimeStamp -用于标识数据的不同版本(version),每条数据写入时,系统会自动为其加上该字段,其值为写入HBase的时间。 +用于标识数据的不同版本(version),每条数据写入时,系统会自动为其加上该字段,其值为写入HBase的时间。 ### Cell -由{rowkey, column Family:column Qualifier, timestamp} 唯一确定的单元,**Cell 中的数据全部是字节码形式存贮**。 +由 **{rowkey, column Family :column Qualifier, timestamp}** 唯一确定的单元,**Cell 中的数据全部是字节码形式存贮**。 一条数据有多个版本,每个版本都是一个Cell。 @@ -59,28 +60,30 @@ HBase中的每个列都由Colunn Family (列族)和Column Qualifier (列限定 HBase存储结构如下: -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51zYLZjJFodujkK2MYGHubBsciaibibol1M8GpTc5hDKxePcRX1L6OvdCQfw/640?wx_fmt=png) +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51zYLZjJFodujkK2MYGHubBsciaibibol1M8GpTc5hDKxePcRX1L6OvdCQfw/640) + +上面的数据会存储为下面这样: -上面的这种数据会存储为下面这样,底层存储为Byte: +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51zdFtRoyPI36ibQIJP3rE1KdLyibdXoj3V5n41NT3wHcwGgOBGyBu1WHaw/640) -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51zdFtRoyPI36ibQIJP3rE1KdLyibdXoj3V5n41NT3wHcwGgOBGyBu1WHaw/640?wx_fmt=png) +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51z81iaQ7RqX1SOSP6ZrlEnOwemZpDvKtFScQQqU7gRYFFQdw5kQOCMx9w/640) -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51z81iaQ7RqX1SOSP6ZrlEnOwemZpDvKtFScQQqU7gRYFFQdw5kQOCMx9w/640?wx_fmt=png)行分为Region,列分为Store,Region可以放在其他机器上。 +行切分为Region,列切分为Store,Region可以存放在其他机器上。 -**HBase是基于HDFS的,而HDFS是不能够修改数据的,所以HBase其实也是不能修改数据的。HBase使用时间戳实现修改功能。取数据的时候取最新时间戳的数据,取出来的就是最新的数据**。 +**HBase是基于HDFS的,而HDFS是不能够修改数据的,所以HBase也是不能修改数据的。HBase使用时间戳实现修改功能。取数据的时候取最新时间戳的数据,取出来的就是最新的数据。** -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51ziaclXTHSWTAGMLBFQMdC576fMOy4yHhOhlw2IZnGFVCxIWcweoplg1g/640?wx_fmt=png) +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51ziaclXTHSWTAGMLBFQMdC576fMOy4yHhOhlw2IZnGFVCxIWcweoplg1g/640) ## HBase 数据访问形式 HBase数据访问可以通过以下几种形式进行: -1. 单行读写(Get和Put):使用HBase提供的API,可以通过指定行键(Row Key)来读取和写入单行数据。Get操作可以根据行键从表中获取特定行的数据,而Put操作可以将数据写入表的指定行。 -2. 批量读写(Scan和Batch Put):HBase支持批量读写操作,可以一次性读取或写入多行数据。Scan操作可以按照一定的条件扫描表中的多行数据,而Batch Put操作可以一次性写入多行数据。 -3. 全表扫描(Scan):通过Scan操作,可以遍历整个表的数据,按照指定的条件进行过滤和筛选。可以设置起始行键和结束行键,还可以使用过滤器(Filter)进行更精确的数据查询。 -4. 列族范围扫描(Scan):HBase中的数据以列族(Column Family)为单位进行存储,可以通过Scan操作对指定列族的数据进行范围扫描。这种方式可以提高数据查询的效率,只获取所需列族的数据,而不必读取整个表的数据。 -5. 过滤器(Filter):HBase支持多种过滤器来进行数据的精确查询和过滤。可以使用行键过滤器(Row Filter)按照行键的条件进行数据过滤,还可以使用列族过滤器(Family Filter)、列限定符过滤器(Qualifier Filter)和值过滤器(Value Filter)等进行更细粒度的数据过滤。 -6. 原子性操作(Check-and-Put和Check-and-Delete):HBase支持原子性操作,例如Check-and-Put和Check-and-Delete。这些操作允许在写入数据之前进行检查,只有在满足指定条件的情况下才执行写入操作。 +- **单行读写(Get和Put)**:使用HBase提供的API,可以通过指定行键(Row Key)来读取和写入单行数据。Get操作可以根据行键从表中获取特定行的数据,而Put操作可以将数据写入表的指定行。 +- **批量读写(Scan和Batch Put)**:HBase支持批量读写操作,可以一次性读取或写入多行数据。Scan操作可以按照一定的条件扫描表中的多行数据,而Batch Put操作可以一次性写入多行数据。 +- **全表扫描(Scan)**:通过Scan操作,可以遍历整个表的数据,按照指定的条件进行过滤和筛选。可以设置起始行键和结束行键,还可以使用过滤器(Filter)进行更精确的数据查询。 +- **列族范围扫描(Scan)**:HBase中的数据以列族(Column Family)为单位进行存储,可以通过Scan操作对指定列族的数据进行范围扫描。这种方式可以提高数据查询的效率,只获取所需列族的数据,而不必读取整个表的数据。 +- **过滤器(Filter)**:HBase支持多种过滤器来进行数据的精确查询和过滤。可以使用行键过滤器(Row Filter)按照行键的条件进行数据过滤,还可以使用列族过滤器(Family Filter)、列限定符过滤器(Qualifier Filter)和值过滤器(Value Filter)等进行更细粒度的数据过滤。 +- **原子性操作(Check-and-Put和Check-and-Delete)**:HBase支持原子性操作,例如Check-and-Put和Check-and-Delete。这些操作允许在写入数据之前进行检查,只有在满足指定条件的情况下才执行写入操作。 以上形式提供了不同的数据访问方式,可以根据具体的需求和查询条件选择适合的方式来访问和操作HBase中的数据。 @@ -88,58 +91,60 @@ HBase数据访问可以通过以下几种形式进行: HBase的架构体系是基于分布式存储和处理的设计。它包含了以下几个重要的组成部分: -1. HMaster:**HMaster是HBase集群的主节点,负责管理整个集群的元数据和协调各个RegionServer的工作**。它维护了表的结构信息、分片规则、RegionServer的负载均衡等,并协调分布式操作,如Region的分裂和合并。 -2. RegionServer:RegionServer是HBase集群中的工作节点,负责存储和处理数据。**每个RegionServer管理多个Region,每个Region负责存储表中的一部分数据**。RegionServer处理客户端的读写请求,负责数据的存储、读取和写入操作。 -3. ZooKeeper:ZooKeeper是一个分布式协调服务,被HBase用于管理集群的元数据和协调分布式操作。HBase使用ZooKeeper来进行主节点的选举、故障检测、集群配置的同步等任务。 -4. HDFS(Hadoop Distributed File System):HBase使用HDFS作为底层的分布式文件系统,用于存储数据。HDFS将数据分割成块并分布在不同的节点上,提供高可靠性和可扩展性的存储。 -5. HBase客户端:HBase客户端是与HBase交互的应用程序或工具,用于发送读写请求和接收查询结果。客户端可以通过HBase的Java API或者命令行工具(如HBase shell)来访问和操作HBase表。 -6. 表和列族:HBase数据模型是基于表的,表由一个或多个列族(Column Family)组成。每个列族可以包含多个列(Column),列存储着实际的数据。表被分割成多个Region存储在不同的RegionServer上,每个Region负责存储一部分行数据。 +- **HMaster**:HMaster是HBase集群的主节点,负责管理整个集群的元数据和协调各个RegionServer的工作。它维护了表的结构信息、分片规则、RegionServer的负载均衡等,并协调分布式操作,如Region的分裂和合并。 +- **RegionServer**:RegionServer是HBase集群中的工作节点,负责存储和处理数据。每个RegionServer管理多个Region,每个Region负责存储表中的一部分数据。RegionServer处理客户端的读写请求,负责数据的存储、读取和写入操作。 +- **ZooKeeper**:ZooKeeper是一个分布式协调服务,被HBase用于管理集群的元数据和协调分布式操作。HBase使用ZooKeeper来进行主节点的选举、故障检测、集群配置的同步等任务。 +- **HDFS(Hadoop Distributed File System)**:HBase使用HDFS作为底层的分布式文件系统,用于存储数据。HDFS将数据分割成块并分布在不同的节点上,提供高可靠性和可扩展性的存储。 +- **HBase客户端**:HBase客户端是与HBase交互的应用程序或工具,用于发送读写请求和接收查询结果。客户端可以通过HBase的Java API或者命令行工具(如HBase shell)来访问和操作HBase表。 +- **表和列族**:HBase数据模型是基于表的,表由一个或多个列族(Column Family)组成。每个列族可以包含多个列(Column),列存储着实际的数据。表被分割成多个Region存储在不同的RegionServer上,每个Region负责存储一部分行数据。 + +这些组成部分共同构成了HBase的架构体系,实现了分布式存储和处理大规模数据集的能力。 -这些组成部分共同构成了HBase的架构体系,实现了分布式存储和处理大规模数据集的能力。HMaster负责管理元数据和协调工作,RegionServer存储和处理数据,ZooKeeper提供分布式协调服务,HDFS提供底层的分布式文件存储,而HBase客户端用于与HBase进行交互。表和列族的概念提供了数据的组织和存储方式。 +**HMaster负责管理元数据和协调工作,RegionServer存储和处理数据,ZooKeeper提供分布式协调服务,HDFS提供底层的分布式文件存储,而HBase客户端用于与HBase进行交互。表和列族的概念提供了数据的组织和存储方式。** ## HBase组件 -1. MemStore:每个RegionServer都有一个MemStore,**它是位于内存中的临时数据存储区域。当客户端写入数据时,数据首先被写入到MemStore中,以提供快速的写入性能**。 -2. WAL(Write-Ahead-Log):WAL是HBase的日志文件,用于记录所有的写操作。当数据被写入到MemStore时,相应的写操作也会被写入WAL中,以保证数据的持久性和故障恢复能力。 -3. StoreFile:**当MemStore中的数据达到一定大小阈值后,会被刷新到磁盘上的StoreFile中。StoreFile是HBase中实际持久化存储数据的文件形式,它包含了已经写入的数据和相应的索引**。 -4. HFile:HFile是StoreFile的底层存储格式,采用了块索引和时间范围索引的方式,提供了高效的数据查找和扫描能力。HFile使用块(Block)来组织数据,并采用压缩和编码技术来减小存储空间。 +- **MemStore**:每个RegionServer都有一个MemStore,它是位于内存中的临时数据存储区域。当客户端写入数据时,数据首先被写入到MemStore中,以提供快速的写入性能。 +- **WAL(Write-Ahead-Log)**:WAL是HBase的日志文件,用于记录所有的写操作。当数据被写入到MemStore时,相应的写操作也会被写入WAL中,以保证数据的持久性和故障恢复能力。 +- **StoreFile**:当MemStore中的数据达到一定大小阈值后,会被刷新到磁盘上的StoreFile中。StoreFile是HBase中实际持久化存储数据的文件形式,它包含了已经写入的数据和相应的索引。 +- **HFile**:HFile是StoreFile的底层存储格式,采用了块索引和时间范围索引的方式,提供了高效的数据查找和扫描能力。HFile使用块(Block)来组织数据,并采用压缩和编码技术来减小存储空间。 -MemStore提供了临时的内存存储,StoreFile提供了持久化的磁盘存储,WAL用于保证数据的持久性。这种架构设计使得HBase能够提供高可用性、高性能和可扩展性的分布式存储和处理能力。 +**MemStore提供了临时的内存存储,StoreFile提供了持久化的磁盘存储,WAL用于保证数据的持久性。这种架构设计使得HBase能够提供高可用性、高性能和可扩展性的分布式存储和处理能力。** ## HBase读写流程 ### 读流程 -1. 客户端发送读取请求:客户端向HBase集群发送读取请求,包括所需的表名、行键(Row Key)以及其他可选的参数(如列族、列限定符等)。 -2. 定位RegionServer和Region:HBase的客户端会与ZooKeeper进行通信,获取到存储有所需数据的Region所在的RegionServer的信息。 -3. RegionServer处理请求:客户端发送的读取请求到达对应的RegionServer,RegionServer会根据请求的行键定位到包含所需数据的Region。 -4. 数据读取:**RegionServer首先会从MemStore中查找数据,如果数据在MemStore中找到,则直接返回给客户端。如果数据不在MemStore中,RegionServer会在磁盘上的StoreFile中进行查找,根据索引定位到所需的数据块,并将数据块读取到内存中进行处理**。 -5. 数据返回给客户端:RegionServer将读取到的数据返回给客户端,客户端可以根据需要对数据进行进一步的处理和分析。 +1. **客户端发送读取请求**:客户端向HBase集群发送读取请求,包括所需的表名、行键(Row Key)以及其他可选的参数(如列族、列限定符等)。 +2. **定位RegionServer和Region**:HBase的客户端会与ZooKeeper进行通信,获取到存储有所需数据的Region所在的RegionServer的信息。 +3. **RegionServer处理请求**:客户端发送的读取请求到达对应的RegionServer,RegionServer会根据请求的行键定位到包含所需数据的Region。 +4. **数据读取**:RegionServer首先会从MemStore中查找数据,如果数据在MemStore中找到,则直接返回给客户端。如果数据不在MemStore中,RegionServer会在磁盘上的StoreFile中进行查找,根据索引定位到所需的数据块,并将数据块读取到内存中进行处理。 +5. **数据返回给客户端**:RegionServer将读取到的数据返回给客户端,客户端可以根据需要对数据进行进一步的处理和分析。 ### 写流程 -1. 客户端发送写入请求:客户端向HBase集群发送写入请求,包括表名、行键、列族、列限定符和对应的值等信息。 -2. 定位RegionServer和Region:客户端与ZooKeeper通信,获取存储目标数据的Region所在的RegionServer的信息。 -3. RegionServer处理请求:客户端发送的写入请求到达对应的RegionServer,RegionServer根据行键定位到目标Region。 -4. 写入到MemStore:**RegionServer将写入请求中的数据写入到目标Region对应的内存中的MemStore。写入到MemStore是一个追加操作,将数据追加到内存中的MemStore中,并不直接写入磁盘**。 -5. WAL日志记录:**同时,RegionServer将写入请求中的操作写入WAL(Write-Ahead-Log)日志文件,确保数据的持久性和故障恢复能力**。 -6. MemStore刷新到磁盘:当MemStore中的数据达到一定的大小阈值时,RegionServer会将MemStore中的数据刷新到磁盘上的StoreFile中。刷新过程将内存中的数据写入到磁盘上的StoreFile,并生成相应的索引。 -7. 数据返回给客户端:写入完成后,RegionServer向客户端发送写入成功的响应,表示数据已成功写入。 +1. **客户端发送写入请求**:客户端向HBase集群发送写入请求,包括表名、行键、列族、列限定符和对应的值等信息。 +2. **定位RegionServer和Region**:客户端与ZooKeeper通信,获取存储目标数据的Region所在的RegionServer的信息。 +3. **RegionServer处理请求**:客户端发送的写入请求到达对应的RegionServer,RegionServer根据行键定位到目标Region。 +4. **写入到MemStore**:RegionServer将写入请求中的数据写入到目标Region对应的内存中的MemStore。写入到MemStore是一个追加操作,将数据追加到内存中的MemStore中,并不直接写入磁盘。 +5. **WAL日志记录**:同时,RegionServer将写入请求中的操作写入WAL(Write-Ahead-Log)日志文件,确保数据的持久性和故障恢复能力。 +6. **MemStore刷新到磁盘**:当MemStore中的数据达到一定的大小阈值时,RegionServer会将MemStore中的数据刷新到磁盘上的StoreFile中。刷新过程将内存中的数据写入到磁盘上的StoreFile,并生成相应的索引。 +7. **数据返回给客户端**:写入完成后,RegionServer向客户端发送写入成功的响应,表示数据已成功写入。 ## MemStore Flush -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51ziaBxqodA9fibl7OiceFPH1sAQAzymoXgiav2sN8icnazQcgw4ZrYRmH2Rrg/640?wx_fmt=png) +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51ziaBxqodA9fibl7OiceFPH1sAQAzymoXgiav2sN8icnazQcgw4ZrYRmH2Rrg/640) 在HBase中,MemStore Flush是将内存中的数据刷新到磁盘上的StoreFile的过程。**当MemStore中的数据达到一定大小阈值时,或者达到了一定的时间限制,HBase会触发MemStore Flush操作,以将数据持久化到磁盘,确保数据的持久性和可靠性**。 下面是MemStore Flush的基本过程: -1. MemStore Flush触发:当MemStore中的数据量达到一定的阈值(由配置参数控制)或者达到了一定的时间限制时,HBase会触发MemStore Flush操作。这个阈值和时间限制可以根据需求进行配置,以平衡写入性能和数据持久性的要求。 -2. 写入内存快照:在触发Flush操作时,HBase会先将MemStore中的数据做一个内存快照(Snapshot),以保证在Flush期间继续接收新的写入请求。 -3. 刷写到磁盘:内存快照完成后,HBase会将内存中的数据按照列族的维度划分为多个KeyValue,然后将这些KeyValue写入磁盘上的StoreFile。StoreFile采用HFile格式,用于持久化存储数据。 -4. 更新Region元数据:完成刷写到磁盘后,HBase会更新Region的元数据,包括最新的StoreFile列表和相应的时间戳等信息。 -5. MemStore清空:一旦数据刷写到磁盘上的StoreFile,HBase会清空相应的MemStore,以释放内存空间用于接收新的写入请求。 +1. **MemStore Flush触发**:当MemStore中的数据量达到一定的阈值(由配置参数控制)或者达到了一定的时间限制时,HBase会触发MemStore Flush操作。这个阈值和时间限制可以根据需求进行配置,以平衡写入性能和数据持久性的要求。 +2. **写入内存快照**:在触发Flush操作时,HBase会先将MemStore中的数据做一个内存快照(Snapshot),以保证在Flush期间继续接收新的写入请求。 +3. **刷写到磁盘**:内存快照完成后,HBase会将内存中的数据按照列族的维度划分为多个KeyValue,然后将这些KeyValue写入磁盘上的StoreFile。StoreFile采用HFile格式,用于持久化存储数据。 +4. **更新Region元数据**:完成刷写到磁盘后,HBase会更新Region的元数据,包括最新的StoreFile列表和相应的时间戳等信息。 +5. **MemStore清空**:一旦数据刷写到磁盘上的StoreFile,HBase会清空相应的MemStore,以释放内存空间用于接收新的写入请求。 通过MemStore Flush操作,HBase可以将内存中的数据持久化到磁盘,以确保数据的持久性和可靠性。Flush操作的频率和成本可以通过配置参数进行调整,以适应不同的应用场景和性能需求。**频繁的Flush操作可能会影响写入性能,而较长的Flush间隔可能会增加数据丢失的风险**。因此,根据实际情况,需要合理设置Flush操作的参数,以平衡数据的持久性和写入性能的要求。 @@ -147,14 +152,16 @@ MemStore提供了临时的内存存储,StoreFile提供了持久化的磁盘存 MemStore Flush在HBase中由以下几个参数进行控制,它们的含义如下: -1. **hbase.hregion.memstore.flush.size**:该参数指定了MemStore的大小阈值。当MemStore中的数据量达到或超过这个阈值时,将触发MemStore Flush操作。该参数的默认值为 128MB。这个参数在HBase 0.98版本及更高版本中生效。在旧版本中,类似的参数名为 hbase.hregion.memstore.flush.size.upper,但其含义和作用相同。 -2. **hbase.hregion.memstore.block.multiplier**:该参数是用来设置MemStore大小阈值的倍数。当MemStore的大小超过 hbase.hregion.memstore.flush.size 乘以 hbase.hregion.memstore.block.multiplier 时,将触发MemStore Flush操作。默认值为2。这个参数在HBase 0.98版本及更高版本中生效。 -3. **hbase.hregion.memstore.flush.size.lower.limit**:该参数定义了MemStore大小的下限限制。当MemStore中的数据量小于此下限时,不会触发MemStore Flush操作。该参数的默认值为0。在HBase 2.0版本及更高版本中生效。 -4. **hbase.hregion.memstore.flush.size.upper.limit**:该参数定义了MemStore大小的上限限制。当MemStore中的数据量超过此上限时,将强制触发MemStore Flush操作。该参数的默认值为Long.MAX_VALUE。在HBase 2.0版本及更高版本中生效。 +- **hbase.hregion.memstore.flush.size**:该参数指定了MemStore的大小阈值。当MemStore中的数据量达到或超过这个阈值时,将触发MemStore Flush操作。该参数的默认值为 128MB。这个参数在HBase 0.98版本及更高版本中生效。在旧版本中,类似的参数名为 hbase.hregion.memstore.flush.size.upper,但其含义和作用相同。 +- **hbase.hregion.memstore.block.multiplier**:该参数是用来设置MemStore大小阈值的倍数。当MemStore的大小超过 hbase.hregion.memstore.flush.size 乘以 hbase.hregion.memstore.block.multiplier 时,将触发MemStore Flush操作。默认值为2。这个参数在HBase 0.98版本及更高版本中生效。 +- **hbase.hregion.memstore.flush.size.lower.limit**:该参数定义了MemStore大小的下限限制。当MemStore中的数据量小于此下限时,不会触发MemStore Flush操作。该参数的默认值为0。在HBase 2.0版本及更高版本中生效。 +- **hbase.hregion.memstore.flush.size.upper.limit**:该参数定义了MemStore大小的上限限制。当MemStore中的数据量超过此上限时,将强制触发MemStore Flush操作。该参数的默认值为Long.MAX_VALUE。在HBase 2.0版本及更高版本中生效。 **上述的1和2,满足任一条件都会触发MemStore Flush操作**。 -这些参数需要根据具体的应用场景和性能要求进行合理的设置。较小的Flush阈值可以提高数据的持久性,但可能会增加Flush的频率和写入的开销;较大的Flush阈值可以减少Flush的频率和开销,但可能会增加数据丢失的风险。因此,需要根据应用的读写特征和数据的重要性,选择合适的参数值。 +这些参数需要根据具体的应用场景和性能要求进行合理的设置。较小的Flush阈值可以提高数据的持久性,但可能会增加Flush的频率和写入的开销;较大的Flush阈值可以减少Flush的频率和开销,但可能会增加数据丢失的风险。 + +因此,需要根据应用的读写特征和数据的重要性,选择合适的参数值。 ## StoreFile Compaction @@ -162,10 +169,10 @@ StoreFile Compaction(文件合并)是 HBase 中的一个重要操作,它 StoreFile Compaction 的基本过程如下: -1. Compact Selection(选择合并):在进行 Compaction 之前,HBase 首先进行选择性合并。它会根据一定的策略,如大小、时间戳等,选择一组需要合并的 StoreFile。这样可以限制合并的数据量,避免一次合并过多数据。 -2. Minor Compaction(小规模合并):Minor Compaction 主要合并较少数量的 StoreFile。它通过创建一个新的 StoreFile,并从多个旧的 StoreFile 中选择合并的数据,将其合并到新的文件中。**这个过程中,旧的 StoreFile 不会被删除,新的 StoreFile 会被创建并写入新的数据**。 -3. Major Compaction(大规模合并):Major Compaction 是一种更为综合和耗时的合并操作。它会合并一个或多个 HBase 表的所有 StoreFile。Major Compaction 将会创建一个新的 StoreFile,并将所有旧的 StoreFile 中的数据合并到新的文件中。**与 Minor Compaction 不同,Major Compaction 还会删除旧的 StoreFile,从而释放磁盘空间**。 -4. Compaction Policy(合并策略):HBase 提供了不同的合并策略,可以根据数据特点和应用需求进行选择。常见的合并策略包括 SizeTieredCompactionPolicy(按大小合并)和 DateTieredCompactionPolicy(按时间戳合并)等。 +1. **Compact Selection(选择合并)**:在进行 Compaction 之前,HBase 首先进行选择性合并。它会根据一定的策略,如大小、时间戳等,选择一组需要合并的 StoreFile。这样可以限制合并的数据量,避免一次合并过多数据。 +2. **Minor Compaction(小规模合并)**:Minor Compaction 主要合并较少数量的 StoreFile。它通过创建一个新的 StoreFile,并从多个旧的 StoreFile 中选择合并的数据,将其合并到新的文件中。**这个过程中,旧的 StoreFile 不会被删除,新的 StoreFile 会被创建并写入新的数据**。 +3. **Major Compaction(大规模合并)**:Major Compaction 是一种更为综合和耗时的合并操作。它会合并一个或多个 HBase 表的所有 StoreFile。Major Compaction 将会创建一个新的 StoreFile,并将所有旧的 StoreFile 中的数据合并到新的文件中。**与 Minor Compaction 不同,Major Compaction 还会删除旧的 StoreFile,从而释放磁盘空间**。 +4. **Compaction Policy(合并策略)**:HBase 提供了不同的合并策略,可以根据数据特点和应用需求进行选择。常见的合并策略包括 SizeTieredCompactionPolicy(按大小合并)和 DateTieredCompactionPolicy(按时间戳合并)等。 通过 StoreFile Compaction,HBase 可以减少磁盘上的存储空间占用,提高读取性能,同时合并操作还可以优化数据布局,加速数据的访问。合适的合并策略的选择可以根据数据的访问模式和应用需求,以达到最佳的性能和存储效率。 @@ -173,13 +180,13 @@ StoreFile Compaction 的基本过程如下: StoreFile Compaction 过程中涉及到的一些相关参数及其含义如下: -1. hbase.hstore.compaction.min:指定了进行 Minor Compaction 的最小文件数。当 StoreFile 的数量达到或超过该值时,才会触发 Minor Compaction。默认值为 3。 -2. hbase.hstore.compaction.max:指定了进行 Major Compaction 的最大文件数。当 StoreFile 的数量超过该值时,将触发 Major Compaction。默认值为 10。 -3. hbase.hstore.compaction.ratio:指定了触发 Major Compaction 的比率。当一个 Region 中的 StoreFile 的总大小超过其最大文件大小的比率时,将触发 Major Compaction。默认值为 1.2。 -4. hbase.hstore.compaction.min.size:指定了进行 Compaction 的最小文件大小。当一个 StoreFile 的大小小于该值时,将不会参与 Compaction。默认值为 1 KB。 -5. hbase.hstore.compaction.max.size:指定了进行 Compaction 的最大文件大小。当一个 StoreFile 的大小超过该值时,将不会参与 Compaction。默认值为 Long.MAX_VALUE,即无限制。 -6. hbase.hstore.compaction.enabled:指定了是否启用 Compaction。如果设置为 false,则不会触发任何 Compaction 操作。默认值为 true。 -7. hbase.hstore.compaction.checker.interval.multiplier:指定了进行 Compaction 检查的时间间隔。实际检查的时间间隔为 hbase.hstore.compaction.checker.interval.multiplier 乘以 StoreFile 的平均大小。默认值为 1.0。 +- **hbase.hstore.compaction.min**:指定了进行 Minor Compaction 的最小文件数。当 StoreFile 的数量达到或超过该值时,才会触发 Minor Compaction。默认值为 3。 +- **hbase.hstore.compaction.max**:指定了进行 Major Compaction 的最大文件数。当 StoreFile 的数量超过该值时,将触发 Major Compaction。默认值为 10。 +- **hbase.hstore.compaction.ratio**:指定了触发 Major Compaction 的比率。当一个 Region 中的 StoreFile 的总大小超过其最大文件大小的比率时,将触发 Major Compaction。默认值为 1.2。 +- **hbase.hstore.compaction.min.size**:指定了进行 Compaction 的最小文件大小。当一个 StoreFile 的大小小于该值时,将不会参与 Compaction。默认值为 1 KB。 +- **hbase.hstore.compaction.max.size**:指定了进行 Compaction 的最大文件大小。当一个 StoreFile 的大小超过该值时,将不会参与 Compaction。默认值为 Long.MAX_VALUE,即无限制。 +- **hbase.hstore.compaction.enabled**:指定了是否启用 Compaction。如果设置为 false,则不会触发任何 Compaction 操作。默认值为 true。 +- **hbase.hstore.compaction.checker.interval.multiplier**:指定了进行 Compaction 检查的时间间隔。实际检查的时间间隔为 hbase.hstore.compaction.checker.interval.multiplier 乘以 StoreFile 的平均大小。默认值为 1.0。 这些参数可以在 HBase 的配置文件(hbase-site.xml)中进行设置。通过调整这些参数的值,可以根据数据量、存储需求和性能要求来优化 Compaction 操作的触发条件和行为。 @@ -213,7 +220,7 @@ StoreFile Compaction 过程中涉及到的一些相关参数及其含义如下 根据以上判断过程,HBase 在每个 RegionServer 上的每个 Store(列族)会根据配置参数进行定期的 Compaction 检查。一旦满足触发 Compaction 的条件,相应的 Minor Compaction 或 Major Compaction 将被触发,合并和优化存储的数据文件。这样可以提高读取性能、节省磁盘空间,并且在某些情况下可以提高写入性能。 -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51zuVatagnDz4DVu27rQ9VHfPkEAkSCwCUgqu76Iznz3ZTltNibEokA3Sg/640?wx_fmt=png) +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMGaekJA7hHDtCgNEbicQ51zuVatagnDz4DVu27rQ9VHfPkEAkSCwCUgqu76Iznz3ZTltNibEokA3Sg/640) ## Region Split @@ -221,15 +228,15 @@ Region Split(区域分割)是 HBase 中的一个重要操作,它用于在 当一个 Region 的大小达到了预先配置的阈值时,HBase 将触发 Region Split 操作。Region Split 的基本过程如下: -1. Split Policy(分割策略):HBase 提供了多种分割策略,用于决定何时触发 Region Split**。常见的分割策略包括按大小分割(Size-based Split)和按行数分割(Row-count-based Split)**。这些策略可以根据数据特点和应用需求进行选择。 -2. Split Selection(选择分割点):在触发分割之前,HBase 首先选择一个适当的分割点。分割点是指一个 RowKey,它将成为分割后的两个子区域的边界。选择分割点的策略可以是根据大小、行数或其他自定义逻辑进行选择。 -3. Region Split(区域分割):一旦选择了分割点,HBase 将通过创建两个新的子区域来执行分割操作。原始的 Region 将被拆分成两个子区域,每个子区域负责存储分割点两侧的数据。同时,HBase 会为新的子区域生成新的 Region ID,并更新元数据信息。 +1. **Split Policy(分割策略)**:HBase 提供了多种分割策略,用于决定何时触发 Region Split。常见的分割策略包括按大小分割(Size-based Split)和按行数分割(Row-count-based Split)。这些策略可以根据数据特点和应用需求进行选择。 +2. **Split Selection(选择分割点)**:在触发分割之前,HBase 首先选择一个适当的分割点。分割点是指一个 RowKey,它将成为分割后的两个子区域的边界。选择分割点的策略可以是根据大小、行数或其他自定义逻辑进行选择。 +3. **Region Split(区域分割)**:一旦选择了分割点,HBase 将通过创建两个新的子区域来执行分割操作。原始的 Region 将被拆分成两个子区域,每个子区域负责存储分割点两侧的数据。同时,HBase 会为新的子区域生成新的 Region ID,并更新元数据信息。 常见的区域分割方式包括: -1. 均匀分割(Even Split):将一个 Region 均匀地划分为两个子区域。分割点根据数据大小或行数进行选择,以保持两个子区域的大小相近。 -2. 预分区(Pre-splitting):**在创建表时,可以提前定义多个分割点,将表划分为多个初始的子区域。这样可以在表创建之初就实现数据的均衡分布,避免后续的动态分割**。 -3. 自定义分割(Custom Split):根据具体的业务需求和数据特点,可以通过自定义逻辑来选择分割点,实现更灵活的分割方式。 +- **均匀分割(Even Split)**:将一个 Region 均匀地划分为两个子区域。分割点根据数据大小或行数进行选择,以保持两个子区域的大小相近。 +- **预分区(Pre-splitting)**:在创建表时,可以提前定义多个分割点,将表划分为多个初始的子区域。这样可以在表创建之初就实现数据的均衡分布,避免后续的动态分割。 +- **自定义分割(Custom Split)**:根据具体的业务需求和数据特点,可以通过自定义逻辑来选择分割点,实现更灵活的分割方式。 通过合理地使用区域分割,可以充分利用集群资源,提高读写性能和负载均衡能力。不同的分割策略和分割方式可以根据数据规模、访问模式和应用需求进行选择,以满足不同场景下的需求。 @@ -237,13 +244,13 @@ Region Split(区域分割)是 HBase 中的一个重要操作,它用于在 在 HBase 中进行预分区可以通过 HBase Shell 或 HBase API 进行操作。以下是使用 HBase Shell 进行预分区的示例: -1. 打开 HBase Shell: +1. **打开 HBase Shell**: ```shell $ hbase shell ``` -2. 创建表并指定分区: +2. **创建表并指定分区**: ```shell hbase(main):001:0> create 'my_table', 'cf', {SPLITS => ['a', 'b', 'c']} @@ -251,7 +258,7 @@ Region Split(区域分割)是 HBase 中的一个重要操作,它用于在 上述命令创建了一个名为 `my_table` 的表,并指定了三个分区点:'a'、'b' 和 'c'。这将创建四个初始的子区域。 -3. 查看表的分区情况: +3. **查看表的分区情况**: ```shell hbase(main):002:0> describe 'my_table' @@ -353,7 +360,7 @@ scanner.close(); 在HBase中,可以使用`Scan`或`Get`操作来显示指定的列。下面分别介绍两种方式的用法: -1. 使用`Scan`操作显示指定列: +- 使用`Scan`操作显示指定列: ```java Scan scan = new Scan(); @@ -369,7 +376,7 @@ scanner.close(); 在上述示例中,使用`scan.addColumn()`方法来指定要显示的列族和列。在`for`循环中,通过`result.getValue()`方法获取指定列的值。 -1. 使用`Get`操作显示指定列: +- 使用`Get`操作显示指定列: ```java Get get = new Get(Bytes.toBytes("row1")); // 指定行键(row1) @@ -467,9 +474,9 @@ table.setWriteBufferSize(1024 * 1024); // 设置写缓冲区大小为1MB #### Zookeeper 会话超时时间 -属性:zookeeper.session.timeout +属性:**zookeeper.session.timeout** -解释:默认值为 90000 毫秒(90s)。当某个 RegionServer 挂掉,90s 之后 Master 才能察觉到。可适当减小此值,尽可能快地检测 regionserver 故障,可调整至 20-30s。看你能有都能忍耐超时,同时可以调整重试时间和重试次数 +解释:默认值为 90000 毫秒(90s)。当某个 RegionServer 挂掉,90s 之后 Master 才能察觉到。可适当减小此值,尽可能快地检测 regionserver 故障,可调整至 20-30s,同时可以调整重试时间和重试次数 hbase.client.pause(默认值 100ms) @@ -477,31 +484,31 @@ hbase.client.retries.number(默认 15 次) #### 设置 RPC 监听数量 -属性:hbase.regionserver.handler.count +属性:**hbase.regionserver.handler.count** 解释:默认值为 30,用于指定 RPC 监听的数量,可以根据客户端的请求数进行调整,读写请求较多时,增加此值。 #### 手动控制 Major Compaction -属性:hbase.hregion.majorcompaction +属性:**hbase.hregion.majorcompaction** 解释:默认值:604800000 秒(7 天), Major Compaction 的周期,若关闭自动 Major Compaction,可将其设为 0。如果关闭一定记得自己手动合并,因为大合并非常有意义。 #### 优化 HStore 文件大小 -属性:hbase.hregion.max.filesize +属性:**hbase.hregion.max.filesize** 解释:默认值 10737418240(10GB),如果需要运行 HBase 的 MR 任务,可以减小此值,因为一个 region 对应一个 map 任务,如果单个 region 过大,会导致 map 任务执行时间。过长。该值的意思就是,如果 HFile 的大小达到这个数值,则这个 region 会被切分为两个 Hfile。 #### 优化 HBase 客户端缓存 -属性:hbase.client.write.buffer +属性:**hbase.client.write.buffer** 解释:默认值 2097152bytes(2M)用于指定 HBase 客户端缓存,增大该值可以减少 RPC调用次数,但是会消耗更多内存,反之则反之。一般我们需要设定一定的缓存大小,以达到减少 RPC 次数的目的。 #### 指定 scan.next 扫描 HBase 所获取的行数 -属性:hbase.client.scanner.caching +属性:**hbase.client.scanner.caching** 解释:用于指定 scan.next 方法获取的默认行数,值越大,消耗内存越大。 @@ -637,14 +644,14 @@ Phoenix是一个开源的基于Apache HBase的关系型数据库引擎,它提 Phoenix在HBase中的主要用途包括: -1. SQL查询:Phoenix允许开发者使用标准的SQL语句来查询和操作HBase中的数据,无需编写复杂的HBase API代码。这简化了开发过程,降低了使用HBase进行数据访问的门槛。 -2. 索引支持:Phoenix提供了对HBase数据的二级索引支持,开发者可以使用SQL语句创建索引,从而加快查询速度。索引在数据查询和过滤中起到重要的作用,提高了数据的检索效率。 -3. 事务支持:Phoenix引入了基于MVCC(多版本并发控制)的事务机制,使得在HBase中进行复杂的事务操作成为可能。开发者可以通过Phoenix的事务功能来保证数据的一致性和可靠性。 -4. SQL函数和聚合:Phoenix支持各种内置的SQL函数和聚合函数,如SUM、COUNT、MAX、MIN等,使得在HBase上进行数据统计和分析变得更加方便。 +- **SQL查询**:Phoenix允许开发者使用标准的SQL语句来查询和操作HBase中的数据,无需编写复杂的HBase API代码。这简化了开发过程,降低了使用HBase进行数据访问的门槛。 +- **索引支持**:Phoenix提供了对HBase数据的二级索引支持,开发者可以使用SQL语句创建索引,从而加快查询速度。索引在数据查询和过滤中起到重要的作用,提高了数据的检索效率。 +- **事务支持**:Phoenix引入了基于MVCC(多版本并发控制)的事务机制,使得在HBase中进行复杂的事务操作成为可能。开发者可以通过Phoenix的事务功能来保证数据的一致性和可靠性。 +- **SQL函数和聚合**:Phoenix支持各种内置的SQL函数和聚合函数,如SUM、COUNT、MAX、MIN等,使得在HBase上进行数据统计和分析变得更加方便。 要在HBase中使用Phoenix,需要先安装并配置好Phoenix。以下是一个在HBase中使用Phoenix的示例代码: -1. 添加 Maven 依赖: 在 Maven 项目的 `pom.xml` 文件中添加以下依赖: +添加 Maven 依赖: 在 Maven 项目的 `pom.xml` 文件中添加以下依赖: ```xml @@ -655,7 +662,7 @@ Phoenix在HBase中的主要用途包括: ``` -1. 创建 Phoenix 表: 在 HBase 中创建 Phoenix 表。可以使用 Phoenix 提供的 SQL 语法创建表和定义模式。例如,创建一个名为 `users` 的表: +创建 Phoenix 表: 在 HBase 中创建 Phoenix 表。可以使用 Phoenix 提供的 SQL 语法创建表和定义模式。例如,创建一个名为 `users` 的表: ```sql CREATE TABLE users ( @@ -665,7 +672,7 @@ CREATE TABLE users ( ); ``` -1. 使用 Phoenix 进行操作: 在 Java 代码中,可以使用 Phoenix 提供的 `PhoenixConnection` 和 `PhoenixStatement` 来执行 SQL 操作。 +使用 Phoenix 进行操作: 在 Java 代码中,可以使用 Phoenix 提供的 `PhoenixConnection` 和 `PhoenixStatement` 来执行 SQL 操作。 ```java import java.sql.*; @@ -704,3 +711,6 @@ public class PhoenixExample { 然后,可以使用 `ResultSet` 对象遍历查询结果,并提取所需的字段。在此示例中,遍历了 `users` 表的结果,并打印了每行的 ID、Name 和 Age。 +最后,在总结HBase的基础概念时,我们应该强调其作为一个分布式、可扩展、大数据存储系统的关键特性。 + +它允许我们进行实时随机读写访问,以及在数十亿行和数百万列上进行高效操作。HBase的设计理念源于Google's Bigtable,并且与Hadoop生态系统紧密集成。通过使用HBase,开发者和数据科学家可以更好地处理极大规模的数据并提供稳定、高性能的服务。总的来说,HBase是解决当前大数据问题的一种强大工具。 diff --git "a/docs/md/\345\244\247\346\225\260\346\215\256/Spark\345\205\245\351\227\250\347\234\213\350\277\231\347\257\207\345\260\261\345\244\237\344\272\206\357\274\210\344\270\207\345\255\227\351\225\277\346\226\207\357\274\211.md" "b/docs/md/\345\244\247\346\225\260\346\215\256/Spark\345\205\245\351\227\250\346\214\207\345\215\227\357\274\232\344\273\216\345\237\272\347\241\200\346\246\202\345\277\265\345\210\260\345\256\236\350\267\265\345\272\224\347\224\250\345\205\250\350\247\243\346\236\220.md" similarity index 73% rename from "docs/md/\345\244\247\346\225\260\346\215\256/Spark\345\205\245\351\227\250\347\234\213\350\277\231\347\257\207\345\260\261\345\244\237\344\272\206\357\274\210\344\270\207\345\255\227\351\225\277\346\226\207\357\274\211.md" rename to "docs/md/\345\244\247\346\225\260\346\215\256/Spark\345\205\245\351\227\250\346\214\207\345\215\227\357\274\232\344\273\216\345\237\272\347\241\200\346\246\202\345\277\265\345\210\260\345\256\236\350\267\265\345\272\224\347\224\250\345\205\250\350\247\243\346\236\220.md" index c3dcdcf..4bc57cd 100644 --- "a/docs/md/\345\244\247\346\225\260\346\215\256/Spark\345\205\245\351\227\250\347\234\213\350\277\231\347\257\207\345\260\261\345\244\237\344\272\206\357\274\210\344\270\207\345\255\227\351\225\277\346\226\207\357\274\211.md" +++ "b/docs/md/\345\244\247\346\225\260\346\215\256/Spark\345\205\245\351\227\250\346\214\207\345\215\227\357\274\232\344\273\216\345\237\272\347\241\200\346\246\202\345\277\265\345\210\260\345\256\236\350\267\265\345\272\224\347\224\250\345\205\250\350\247\243\346\236\220.md" @@ -1,18 +1,18 @@ -[TOC] +在这个数据驱动的时代,信息的处理和分析变得越来越重要。而在众多的大数据处理框架中,「**Apache Spark**」以其独特的优势脱颖而出。 -之前说到了之后工作中会接触到Spark离线任务相关的内容,也预先学习了Scala,所以这篇文章它来了。本篇文章会介绍Spark的相关概念以及原理,帮助初学者快速入门Spark。 +本篇文章,我们将一起走进Spark的世界,探索并理解其相关的基础概念和使用方法。本文主要目标是让初学者能够对Spark有一个全面的认识,并能实际应用到各类问题的解决之中。 ## Spark是什么 -学习一个东西之前总要知道这个东西是什么。 +学习一个东西之前先要知道这个东西是什么。 -Spark 是一个开源的大数据处理引擎,它提供了一整套开发 API,包括流计算和机器学习。它支持批处理和流处理。 +**Spark 是一个开源的大数据处理引擎,它提供了一整套开发 API,包括流计算和机器学习。它支持批处理和流处理。** **Spark 的一个显著特点是它能够在内存中进行迭代计算,从而加快数据处理速度**。尽管 Spark 是用 Scala 开发的,但它也为 Java、Scala、Python 和 R 等高级编程语言提供了开发接口。 ### Spark组件 -Spark提供了6大组件: +Spark提供了6大核心组件: - Spark Core - Spark SQL @@ -20,25 +20,25 @@ Spark提供了6大组件: - Spark MLlib - Spark GraphX -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOyTOyXWbFqEUiakGXgsQon98rSf8TVazItMotAMjZdUzZKBSu6eubBVj1Duic5C5Ria0jBKMJQCjDPQ/640?wx_fmt=png) +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOyTOyXWbFqEUiakGXgsQon98rSf8TVazItMotAMjZdUzZKBSu6eubBVj1Duic5C5Ria0jBKMJQCjDPQ/640) -- Spark Core +**Spark Core** Spark Core 是 Spark 的基础,它提供了内存计算的能力,是分布式处理大数据集的基础。它将分布式数据抽象为弹性分布式数据集(RDD),并为运行在其上的上层组件提供 API。所有 Spark 的上层组件都建立在 Spark Core 的基础之上。 -- Spark SQL +**Spark SQL** Spark SQL 是一个用于处理结构化数据的 Spark 组件。它允许使用 SQL 语句查询数据。Spark 支持多种数据源,包括 Hive 表、Parquet 和 JSON 等。 -- Spark Streaming +**Spark Streaming** -Spark Streaming 是一个用于处理动态数据流的 Spark 组件。它能够开发出强大的交互和数据查询程序。在**处理动态数据流时,流数据会被分割成微小的批处理,这些微小批处理将会在 Spark Core 上按时间顺序快速执行**。 +Spark Streaming 是一个用于处理动态数据流的 Spark 组件。它能够开发出强大的交互和数据查询程序。**在处理动态数据流时,流数据会被分割成微小的批处理,这些微小批处理将会在 Spark Core 上按时间顺序快速执行**。 -- Spark MLlib +**Spark MLlib** Spark MLlib 是 Spark 的机器学习库。它提供了常用的机器学习算法和实用程序,包括分类、回归、聚类、协同过滤、降维等。MLlib 还提供了一些底层优化原语和高层流水线 API,可以帮助开发人员更快地创建和调试机器学习流水线。 -- Spark GraphX +**Spark GraphX** Spark GraphX 是 Spark 的图形计算库。它提供了一种分布式图形处理框架,可以帮助开发人员更快地构建和分析大型图形。 @@ -46,17 +46,17 @@ Spark GraphX 是 Spark 的图形计算库。它提供了一种分布式图形处 Spark 有许多优势,其中一些主要优势包括: -- 速度:Spark 基于内存计算,能够比基于磁盘的计算快很多。对于迭代式算法和交互式数据挖掘任务,这种速度优势尤为明显。 +- **速度**:Spark 基于内存计算,能够比基于磁盘的计算快很多。对于迭代式算法和交互式数据挖掘任务,这种速度优势尤为明显。 -- 易用性:Spark 支持多种语言,包括 Java、Scala、Python 和 R。它提供了丰富的内置 API,可以帮助开发人员更快地构建和运行应用程序。 +- **易用性**:Spark 支持多种语言,包括 Java、Scala、Python 和 R。它提供了丰富的内置 API,可以帮助开发人员更快地构建和运行应用程序。 -- 通用性:Spark 提供了多种组件,可以支持不同类型的计算任务,包括批处理、交互式查询、流处理、机器学习和图形处理等。 -- 兼容性:Spark 可以与多种数据源集成,包括 Hadoop 分布式文件系统(HDFS)、Apache Cassandra、Apache HBase 和 Amazon S3 等。 -- 容错性:Spark 提供了弹性分布式数据集(RDD)抽象,可以帮助开发人员更快地构建容错应用程序。 +- **通用性**:Spark 提供了多种组件,可以支持不同类型的计算任务,包括批处理、交互式查询、流处理、机器学习和图形处理等。 +- **兼容性**:Spark 可以与多种数据源集成,包括 Hadoop 分布式文件系统(HDFS)、Apache Cassandra、Apache HBase 和 Amazon S3 等。 +- **容错性**:Spark 提供了弹性分布式数据集(RDD)抽象,可以帮助开发人员更快地构建容错应用程序。 ### Word Count -下面是一个简单的Word Count的Spark程序: +上手写一个简单的代码例子,下面是一个Word Count的Spark程序: ```scala import org.apache.spark.{SparkConf, SparkContext} @@ -77,21 +77,25 @@ object SparkWordCount { } } +输出: +(Hello,2) +(World,1) +(Spark,1) ``` 程序首先创建了一个 SparkConf 对象,用来设置应用程序名称和运行模式。然后,它创建了一个 SparkContext 对象,用来连接到 Spark 集群。 -接下来,程序创建了一个包含两个字符串的列表,并使用 parallelize 方法将其转换为一个 RDD。然后,它使用 flatMap 方法将每一行文本拆分成单词,并使用 map 方法将每个单词映射为一个键值对(key-value pair),其中键是单词,值是 1。 +接下来,程序创建了一个包含两个字符串的列表,并使用 `parallelize` 方法将其转换为一个 RDD。然后,它使用 `flatMap` 方法将每一行文本拆分成单词,并使用 `map` 方法将每个单词映射为一个键值对(key-value pair),其中键是单词,值是 1。 -最后,程序使用 reduceByKey 方法将具有相同键的键值对进行合并,并对它们的值进行求和。最终结果是一个包含每个单词及其出现次数的 RDD。程序使用 collect 方法将结果收集到驱动程序,并使用 foreach 方法打印出来。 +最后,程序使用 `reduceByKey` 方法将具有相同键的键值对进行合并,并对它们的值进行求和。最终结果是一个包含每个单词及其出现次数的 RDD。程序使用 `collect` 方法将结果收集到驱动程序,并使用 `foreach` 方法打印出来。 ## Spark基本概念 -Spark的理论较多,所以先了解一下基本概念,有助于后面展开学习Spark。 +Spark的理论较多,为了更有效地学习Spark,首先来理解下其基本概念。 ### Application -用户编写的Spark应用程序。 +**Application指的就是用户编写的Spark应用程序。** 如下,"Word Count"就是该应用程序的名字。 @@ -118,108 +122,116 @@ object WordCount { ### Driver -Driver 是运行 Spark Application 的进程,它负责创建 SparkSession 和 SparkContext 对象,并将代码转换为转换和操作操作。它还负责创建逻辑和物理计划,并与集群管理器协调调度任务。 +Driver 是运行 Spark Application 的进程,它负责创建 SparkSession 和 SparkContext 对象,并将代码转换和操作。 + +它还负责创建逻辑和物理计划,并与集群管理器协调调度任务。 -简而言之,Spark Application 是使用 Spark API 编写的程序,而 Spark Driver 是负责运行该程序并与集群管理器协调的进程。 +**简而言之,Spark Application 是使用 Spark API 编写的程序,而 Spark Driver 是负责运行该程序并与集群管理器协调的进程。** -**可以将Driver 理解为运行 Spark Application `main` 方法的进程**。 +**可以将Driver 理解为运行 Spark Application `main` 方法的进程。** -driver的内存大小可以进行设置: +driver的内存大小可以进行设置,配置如下: ```scala # 设置 driver内存大小 driver-memory 1024m ``` -### Master和Worker +### Master & Worker + +**在Spark中,Master是独立集群的控制者,而Worker是工作者。** -在Spark中,Master是独立集群的控制者,而Worker是工作者。一个Spark独立集群需要启动一个Master和多个Worker。Worker就是物理节点,可以在上面启动Executor进程。 +一个Spark独立集群需要启动一个Master和多个Worker。Worker就是物理节点,Worker上面可以启动Executor进程。 ### Executor -在每个Worker上为某应用启动的一个进程,该进程负责运行Task,并且负责将数据存在内存或者磁盘上,每个任务都有各自独立的Executor。Executor是一个执行Task的容器。实际上它是一组计算资源(cpu核心、memory)的集合。 +在每个Worker上为某应用启动的一个进程,该进程负责运行Task,并且负责将数据存在内存或者磁盘上。 -**一个Worker节点可以有多个Executor。一个Executor可以运行多个Task**。 +每个任务都有各自独立的Executor。Executor是一个执行Task的容器。实际上它是一组计算资源(cpu核心、memory)的集合。 -executor创建成功后,在日志文件会显示如下信息: -`INFO Executor: Starting executor ID [executorId] on host [executorHostname]` +**一个Worker节点可以有多个Executor。一个Executor可以运行多个Task。** -### Job +Executor创建成功后,在日志文件会显示如下信息: -一个Job包含多个RDD及作用于相应RDD上的各种操作,**每个Action的触发就会生成一个job**。用户提交的Job会提交给DAGScheduler,Job会被分解成Stage,Stage会被细化成Task。 +``` +INFO Executor: Starting executor ID [executorId] on host [executorHostname] +``` -### Task +### RDD -被发送到executor上的工作单元。每个Task负责计算一个分区的数据。 +**RDD(Resilient Distributed Dataset)叫做弹性分布式数据集,是Spark中最基本的数据抽象,它代表一个不可变、可分区、里面的元素可并行计算的集合。** -### Stage +RDD的 Partition 是指数据集的分区。它是数据集中元素的集合,这些元素被分区到集群的节点上,可以并行操作。**对于RDD来说,每个分片都会被一个计算任务处理,并决定并行计算的粒度。用户可以在创建RDD时指定RDD的分片个数,如果没有指定,那么就会采用默认值。默认值就是程序所分配到的CPU Core的数目**。 -在 Spark 中,一个作业(job)会被划分为多个阶段(stage)。**同一个 Stage 可以有多个 Task 并行执行(task 数=分区数)**。 +一个函数会被作用在每一个分区。Spark 中 RDD 的计算是以分片为单位的,`compute` 函数会被作用到每个分区上。 -阶段之间的划分是根据数据的依赖关系来确定的。当一个 RDD 的分区依赖于另一个 RDD 的分区时,这两个 RDD 就属于同一个阶段。当一个 RDD 的分区依赖于多个 RDD 的分区时,这些 RDD 就属于不同的阶段。 +**RDD的每次转换都会生成一个新的RDD,所以RDD之间就会形成类似于流水线一样的前后依赖关系。在部分分区数据丢失时,Spark可以通过这个依赖关系重新计算丢失的分区数据,而不是对RDD的所有分区进行重新计算。** -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMQLUpLuRk6aZsckVcTHYoO1iaOmLKChIvXT2Rkib3v06icficTFyeJ3uAznFAGdHTGkHEdD2Rr8EVflA/640?wx_fmt=png) +### Job -上图中,stage表示一个可以顺滑完成的阶段,就是可以单机运行。曲线表示Shuffle。 +一个Job包含多个RDD及作用于相应RDD上的各种操作,**每个Action的触发就会生成一个job**。用户提交的Job会提交给DAG Scheduler,Job会被分解成Stage,Stage会被细化成Task。 -如果stage能够复用前面的stage的话,那么会显示灰色。 +### Task -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMQLUpLuRk6aZsckVcTHYoOSWPWp4BPQBaUVg6sA0Iutic2gmjPicJmn8iaRPCc8248VPKqAYo4gauwA/640?wx_fmt=png) +被发送到Executor上的工作单元。每个Task负责计算一个分区的数据。 +### Stage +在 Spark 中,一个作业(Job)会被划分为多个阶段(Stage)。**同一个 Stage 可以有多个 Task 并行执行(Task 数=分区数)**。 -#### Stage的划分 +阶段之间的划分是根据数据的依赖关系来确定的。当一个 RDD 的分区依赖于另一个 RDD 的分区时,这两个 RDD 就属于同一个阶段。当一个 RDD 的分区依赖于多个 RDD 的分区时,这些 RDD 就属于不同的阶段。 -Stage的划分,简单的说是以宽依赖来划分: +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMQLUpLuRk6aZsckVcTHYoO1iaOmLKChIvXT2Rkib3v06icficTFyeJ3uAznFAGdHTGkHEdD2Rr8EVflA/640) -对于窄依赖,partition 的转换处理在 stage 中完成计算,不划分(将窄依赖尽量放在在同一个 stage 中,可以实现流水线计算)。 -对于宽依赖,由于有 shuffle 的存在,只能在父 RDD 处理完成后,才能开始接下来的计算,也就是说需要要划分 stage。 +上图中,Stage表示一个可以顺滑完成的阶段。曲线表示 Shuffle 过程。 -**Spark 会根据 shuffle/宽依赖使用回溯算法来对 DAG 进行 Stage 划分,从后往前,遇到宽依赖就断开,遇到窄依赖就把当前的 RDD 加入到当前的 stage/阶段中**。 +如果Stage能够复用前面的Stage的话,那么会显示灰色。 -Spark会根据RDD之间的依赖关系将DAG图划分为不同的阶段,对于窄依赖,由于partition依赖关系的确定性,partition的转换处理就可以在同一个线程里完成,窄依赖就被spark划分到同一个stage中,而对于宽依赖,只能等父RDD shuffle处理完成后,下一个stage才能开始接下来的计算。 +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMQLUpLuRk6aZsckVcTHYoOSWPWp4BPQBaUVg6sA0Iutic2gmjPicJmn8iaRPCc8248VPKqAYo4gauwA/640) -至于什么是窄依赖和宽依赖,下面马上就会提及。 -### 窄依赖 & 宽依赖 -- 窄依赖 +#### Shuffle -父 RDD 的一个分区只会被子 RDD 的一个分区依赖。比如:map/filter和union,这种依赖称之为窄依赖。 +在 Spark 中,**Shuffle 是指在不同阶段之间重新分配数据的过程。它通常发生在需要对数据进行聚合或分组操作的时候**,例如 `reduceByKey` 或 `groupByKey` 等操作。 -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOyTOyXWbFqEUiakGXgsQon9tK9NoennqaqefnGbYx3rchUiaMsRGXOSG5f3nFl0Cibkt8sm3YjFmtFg/640?wx_fmt=png) +在 Shuffle 过程中,Spark 会将数据按照键值进行分区,并将属于同一分区的数据发送到同一个计算节点上。这样,每个计算节点就可以独立地处理属于它自己分区的数据。 -**窄依赖的多个分区可以并行计算,并且窄依赖的一个分区的数据如果丢失只需要重新计算对应的分区的数据就可以了**。 +#### Stage的划分 -- 宽依赖 +**Stage的划分,简单来说是以宽依赖来划分的。** -指子RDD的分区依赖于父RDD的所有分区,这是因为shuffle类操作,称之为宽依赖。 +对于窄依赖,Partition 的转换处理在 Stage 中完成计算,不划分(将窄依赖尽量放在在同一个 Stage 中,可以实现流水线计算)。 -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOyTOyXWbFqEUiakGXgsQon97p8hpNl8qlGpxjmibgib0E8tJ6ktbfBX6U6MKDLDQDib2cL1tPJsJRHqA/640?wx_fmt=png) +对于宽依赖,由于有 Shuffle 的存在,只能在父 RDD 处理完成后,才能开始接下来的计算,也就是说需要划分 Stage。 -对于宽依赖,必须等到上一阶段计算完成才能计算下一阶段。 +**Spark 会根据 Shuffle/宽依赖 使用回溯算法来对 DAG 进行 Stage 划分,从后往前,遇到宽依赖就断开,遇到窄依赖就把当前的 RDD 加入到当前的 Stage 阶段中**。 -### Shuffle +至于什么是窄依赖和宽依赖,下文马上就会提及。 -在 Spark 中,shuffle 是指在不同阶段之间重新分配数据的过程。它通常发生在需要对数据进行聚合或分组操作的时候,例如 reduceByKey 或 groupByKey 等操作。 +#### 窄依赖 & 宽依赖 -在 shuffle 过程中,Spark 会将数据按照键值进行分区,并将属于同一分区的数据发送到同一个计算节点上。这样,每个计算节点就可以独立地处理属于它自己分区的数据。 +- 窄依赖 -### RDD +父 RDD 的一个分区只会被子 RDD 的一个分区依赖。比如:`map`,`filter`和`union`,这种依赖称之为「**窄依赖**」。 -**RDD(Resilient Distributed Dataset)叫做弹性分布式数据集,是Spark中最基本的数据抽象,它代表一个不可变、可分区、里面的元素可并行计算的集合**。 +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOyTOyXWbFqEUiakGXgsQon9tK9NoennqaqefnGbYx3rchUiaMsRGXOSG5f3nFl0Cibkt8sm3YjFmtFg/640) -RDD的Partition是指数据集的分区。它是数据集中元素的集合,这些元素被分区到集群的节点上,可以并行操作。**对于RDD来说,每个分片都会被一个计算任务处理,并决定并行计算的粒度。用户可以在创建RDD时指定RDD的分片个数,如果没有指定,那么就会采用默认值。默认值就是程序所分配到的CPU Core的数目**。 +**窄依赖的多个分区可以并行计算,并且窄依赖的一个分区的数据如果丢失只需要重新计算对应的分区的数据就可以了。** -一个函数会被作用在每一个分区。Spark 中 RDD 的计算是以分片为单位的,compute 函数会被作用到每个分区上。 +- 宽依赖 + +指子RDD的分区依赖于父RDD的所有分区,称之为「**宽依赖**」。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOyTOyXWbFqEUiakGXgsQon97p8hpNl8qlGpxjmibgib0E8tJ6ktbfBX6U6MKDLDQDib2cL1tPJsJRHqA/640) -RDD的每次转换都会生成一个新的RDD,所以RDD之间就会形成类似于流水线一样的前后依赖关系。在部分分区数据丢失时,Spark可以通过这个依赖关系重新计算丢失的分区数据,而不是对RDD的所有分区进行重新计算。 +对于宽依赖,必须等到上一阶段计算完成才能计算下一阶段。 ### DAG 有向无环图,其实说白了就是RDD之间的依赖关系图。 -- **开始**:通过 SparkContext 创建的 RDD; +- **开始**:通过 SparkContext 创建的 RDD。 - **结束**:触发 Action,一旦触发 Action 就形成了一个完整的 DAG(**有几个 Action,就有几个 DAG**)。 ## Spark执行流程 @@ -238,13 +250,13 @@ Spark的执行流程大致如下: Spark 支持多种运行模式,包括本地模式、独立模式、Mesos 模式、YARN 模式和 Kubernetes 模式。 -- 本地模式:在本地模式下,Spark 应用程序会在单个机器上运行,不需要连接到集群。这种模式适用于开发和测试,但不适用于生产环境。 -- 独立模式:在独立模式下,Spark 应用程序会连接到一个独立的 Spark 集群,并在集群中运行。这种模式适用于小型集群,但不支持动态资源分配。 -- Mesos 模式:在 Mesos 模式下,Spark 应用程序会连接到一个 Apache Mesos 集群,并在集群中运行。这种模式支持动态资源分配和细粒度资源共享,目前国内使用较少。 -- YARN 模式:在 YARN 模式下,Spark 应用程序会连接到一个 Apache Hadoop YARN 集群,并在集群中运行。这种模式支持动态资源分配和与其他 Hadoop 生态系统组件的集成,Spark在Yarn模式下是不需要Master和Worker的。 -- Kubernetes 模式:在 Kubernetes 模式下,Spark 应用程序会连接到一个 Kubernetes 集群,并在集群中运行。这种模式支持动态资源分配和容器化部署。 +- **本地模式**:在本地模式下,Spark 应用程序会在单个机器上运行,不需要连接到集群。这种模式适用于开发和测试,但不适用于生产环境。 +- **独立模式**:在独立模式下,Spark 应用程序会连接到一个独立的 Spark 集群,并在集群中运行。这种模式适用于小型集群,但不支持动态资源分配。 +- **Mesos 模式**:在 Mesos 模式下,Spark 应用程序会连接到一个 Apache Mesos 集群,并在集群中运行。这种模式支持动态资源分配和细粒度资源共享,目前国内使用较少。 +- **YARN 模式**:在 YARN 模式下,Spark 应用程序会连接到一个 Apache Hadoop YARN 集群,并在集群中运行。这种模式支持动态资源分配和与其他 Hadoop 生态系统组件的集成,Spark在Yarn模式下是不需要Master和Worker的。 +- **Kubernetes 模式**:在 Kubernetes 模式下,Spark 应用程序会连接到一个 Kubernetes 集群,并在集群中运行。这种模式支持动态资源分配和容器化部署。 -## RDD +## RDD详解 RDD的概念在Spark中十分重要,上面只是简单的介绍了一下,下面详细的对RDD展开介绍。 @@ -258,38 +270,28 @@ RDD里面的数据集会被逻辑分成若干个分区,这些分区是分布 ### RDD特性 -- 内存计算 - -Spark RDD运算数据是在内存中进行的,在内存足够的情况下,不会把中间结果存储在磁盘,所以计算速度非常高效。 - -- 惰性求值 +- **内存计算**:Spark RDD运算数据是在内存中进行的,在内存足够的情况下,不会把中间结果存储在磁盘,所以计算速度非常高效。 -所有的转换操作都是惰性的,也就是说不会立即执行任务,只是把对数据的转换操作记录下来而已。只有碰到action操作才会被真正的执行。 +- **惰性求值**:所有的转换操作都是惰性的,也就是说不会立即执行任务,只是把对数据的转换操作记录下来而已。只有碰到action操作才会被真正的执行。 -- 容错性 +- **容错性**:Spark RDD具备容错特性,在RDD失效或者数据丢失的时候,可以根据DAG从父RDD重新把数据集计算出来,以达到数据容错的效果。 -Spark RDD具备容错特性,在RDD失效或者数据丢失的时候,可以根据DAG从父RDD重新把数据集计算出来,以达到数据容错的效果。 +- **不变性**:RDD是进程安全的,因为RDD是不可修改的。它可以在任何时间点被创建和查询,使得缓存,共享,备份都非常简单。在计算过程中,是RDD的不可修改特性保证了数据的一致性。 -- 不变性 - -RDD是进程安全的,因为RDD是不可修改的。它可以在任何时间点被创建和查询,使得缓存,共享,备份都非常简单。在计算过程中,是RDD的不可修改特性保证了数据的一致性。 - -- 持久化 - -可以调用cache或者persist函数,把RDD缓存在内存、磁盘,下次使用的时候不需要重新计算而是直接使用。 +- **持久化**:可以调用cache或者persist函数,把RDD缓存在内存、磁盘,下次使用的时候不需要重新计算而是直接使用。 ### RDD操作 RDD支持两种操作: -- 转换操作(Transformation) -- 行动操作(Actions) +- 转换操作(Transformation)。 +- 行动操作(Actions)。 #### 转换操作(Transformation) 转换操作以RDD做为输入参数,然后输出一个或者多个RDD。转换操作不会修改输入RDD。`Map()`、`Filter()`这些都属于转换操作。 -转换操作是惰性求值操作,只有在碰到行动操作(Actions)的时候,转换操作才会真正实行。转换操作分两种:**窄依赖**和**宽依赖**(上文提到过)。 +转换操作是惰性求值操作,只有在碰到行动操作(Actions)的时候,转换操作才会真正实行。转换操作分两种:「**窄依赖**」和「**宽依赖**」。 下面是一些常见的转换操作: @@ -306,7 +308,7 @@ RDD支持两种操作: #### 行动操作(Action) -Action是数据执行部分,其通过执行count,reduce,collect等方法真正执行数据的计算部分。 +Action是数据执行部分,其通过执行`count`,`reduce`,`collect`等方法真正执行数据的计算部分。 | Action 操作 | 描述 | | -------------- | ------------------------------------------------------ | @@ -323,9 +325,9 @@ Action是数据执行部分,其通过执行count,reduce,collect等方法 创建RDD有3种不同方式: -- 从外部存储系统 -- 从其他RDD -- 由一个已经存在的 Scala 集合创建 +- 从外部存储系统。 +- 从其他RDD。 +- 由一个已经存在的 Scala 集合创建。 #### 从外部存储系统 @@ -367,9 +369,11 @@ rdd2.sortBy(_._2,false).collect//触发action,会去读取HDFS的文件,rdd2会 rdd2.sortBy(_._2,false).collect//触发action,会去读缓存中的数据,执行速度会比之前快,因为rdd2已经持久化到内存中了 ``` -**需要注意的是,在触发action的时候,才会去执行持久化**。 +**需要注意的是,在触发action的时候,才会去执行持久化。** -`cache()`和`persist()`的区别在于,`cache()`是`persist()`的一种简化方式,`cache()`的底层就是调用的`persist()`的无参版本,就是调用`persist(MEMORY_ONLY)`,将数据持久化到内存中。如果需要从内存中去除缓存,那么可以使用`unpersist()`方法。 +`cache()`和`persist()`的区别在于,`cache()`是`persist()`的一种简化方式,`cache()`的底层就是调用的`persist()`的无参版本,就是调用`persist(MEMORY_ONLY)`,将数据持久化到内存中。 + +如果需要从内存中去除缓存,那么可以使用`unpersist()`方法。 ```scala rdd.persist(StorageLevel.MEMORY_ONLY) @@ -394,11 +398,13 @@ RDD存储级别主要有以下几种。 | DISK_ONLY_2 | 低 | 高 | 否 | 是 | 数据存2份 | | OFF_HEAP | | | | | 这个目前是试验型选项,类似MEMORY_ONLY_SER,但是数据是存储在堆外内存的。 | -**对于上述任意一种持久化策略,如果加上后缀_2,代表的是将每个持久化的数据,都复制一份副本,并将副本保存到其他节点上**。这种基于副本的持久化机制主要用于进行容错。假如某个节点挂掉了,节点的内存或磁盘中的持久化数据丢失了,那么后续对RDD计算时还可以使用该数据在其他节点上的副本。如果没有副本的话,就只能将这些数据从源头处重新计算一遍了。 +**对于上述任意一种持久化策略,如果加上后缀_2,代表的是将每个持久化的数据,都复制一份副本,并将副本保存到其他节点上。** + +这种基于副本的持久化机制主要用于进行容错。假如某个节点挂掉了,节点的内存或磁盘中的持久化数据丢失了,那么后续对RDD计算时还可以使用该数据在其他节点上的副本。如果没有副本的话,就只能将这些数据从源头处重新计算一遍了。 ### RDD的血缘关系 -血缘关系是指 RDD 之间的依赖关系。当你对一个 RDD 执行转换操作时,Spark 会生成一个新的 RDD,并记录这两个 RDD 之间的依赖关系。这种依赖关系就是血缘关系。 +**血缘关系是指 RDD 之间的依赖关系。当你对一个 RDD 执行转换操作时,Spark 会生成一个新的 RDD,并记录这两个 RDD 之间的依赖关系。这种依赖关系就是血缘关系。** 血缘关系可以帮助 Spark 在发生故障时恢复数据。当一个分区丢失时,Spark 可以根据血缘关系重新计算丢失的分区,而不需要从头开始重新计算整个 RDD。 @@ -437,8 +443,8 @@ CheckPoint可以将RDD从其依赖关系中抽出来,保存到可靠的存储 CheckPoint分为两类: -- 高可用CheckPoint:容错性优先。这种类型的检查点可确保数据永久存储,如存储在HDFS或其他分布式文件系统上。 这也意味着数据通常会在网络中复制,这会降低检查点的运行速度。 -- 本地CheckPoint:性能优先。 RDD持久保存到执行程序中的本地文件系统。 因此,数据写得更快,但本地文件系统也不是完全可靠的,一旦数据丢失,工作将无法恢复。 +- **高可用CheckPoint**:容错性优先。这种类型的检查点可确保数据永久存储,如存储在HDFS或其他分布式文件系统上。 这也意味着数据通常会在网络中复制,这会降低检查点的运行速度。 +- **本地CheckPoint**:性能优先。 RDD持久保存到执行程序中的本地文件系统。 因此,数据写得更快,但本地文件系统也不是完全可靠的,一旦数据丢失,工作将无法恢复。 开发人员可以使用`RDD.checkpoint()`方法来设置检查点。在使用检查点之前,必须使用`SparkContext.setCheckpointDir(directory: String)`方法设置检查点目录。 @@ -470,10 +476,10 @@ object CheckpointExample { RDD的检查点机制就好比Hadoop将中间计算值存储到磁盘,即使计算中出现了故障,我们也可以轻松地从中恢复。通过对 RDD 启动检查点机制可以实现容错和高可用。 -### Persist与CheckPoint的区别 +### Persist VS CheckPoint -- 位置:Persist 和 Cache 只能保存在本地的磁盘和内存中(或者堆外内存–实验中),而 Checkpoint 可以保存数据到 HDFS 这类可靠的存储上。 -- 生命周期:Cache 和 Persist 的 RDD 会在程序结束后会被清除或者手动调用 unpersist 方法,而 Checkpoint 的 RDD 在程序结束后依然存在,不会被删除。CheckPoint将RDD持久化到HDFS或本地文件夹,如果不被手动remove掉,是一直存在的,也就是说可以被下一个driver使用,而Persist不能被其他dirver使用。 +- **位置**:Persist 和 Cache 只能保存在本地的磁盘和内存中(或者堆外内存–实验中),而 Checkpoint 可以保存数据到 HDFS 这类可靠的存储上。 +- **生命周期**:Cache 和 Persist 的 RDD 会在程序结束后会被清除或者手动调用 unpersist 方法,而 Checkpoint 的 RDD 在程序结束后依然存在,不会被删除**。CheckPoint将RDD持久化到HDFS或本地文件夹,如果不被手动remove掉,是一直存在的,也就是说可以被下一个driver使用,而Persist不能被其他dirver使用**。 ## Spark-Submit @@ -507,7 +513,7 @@ RDD的检查点机制就好比Hadoop将中间计算值存储到磁盘,即使 | ----------------- | ------------------------------------------------------------ | | local | 使用1个worker线程在本地运行Spark应用程序 | | local[K] | 使用K个worker线程在本地运行Spark应用程序 | -| local | 使用所有剩余worker线程在本地运行Spark应用程序 | +| local[*] | 使用所有剩余worker线程在本地运行Spark应用程序 | | spark://HOST:PORT | 连接到Spark Standalone集群,以便在该集群上运行Spark应用程序 | | mesos://HOST:PORT | 连接到Mesos集群,以便在该集群上运行Spark应用程序 | | yarn-client | 以client方式连接到YARN集群,集群的定位由环境变量HADOOP_CONF_DIR定义,该方式driver在client运行。 | @@ -515,13 +521,15 @@ RDD的检查点机制就好比Hadoop将中间计算值存储到磁盘,即使 ## Spark 共享变量 -一般情况下,当一个传递给Spark操作(例如map和reduce)的函数在远程节点上面运行时,Spark操作实际上操作的是这个函数所用变量的一个独立副本。**这些变量被复制到每台机器上,并且这些变量在远程机器上的所有更新都不会传递回驱动程序**。通常跨任务的读写变量是低效的,所以,Spark提供了两种共享变量:**广播变量(broadcast variable)**和**累加器(accumulator)**。 +一般情况下,当一个传递给Spark操作(例如map和reduce)的函数在远程节点上面运行时,Spark操作实际上操作的是这个函数所用变量的一个独立副本。 + +**这些变量被复制到每台机器上,并且这些变量在远程机器上的所有更新都不会传递回驱动程序**。通常跨任务的读写变量是低效的,所以,Spark提供了两种共享变量:「**广播变量(broadcast variable)**」和「**累加器(accumulator)**」。 ### 广播变量 **广播变量**允许程序员缓存一个只读的变量在每台机器上面,而不是每个任务保存一份拷贝。说白了其实就是共享变量。 -**如果Executor端用到了Driver的变量,如果不使用广播变量在Executor有多少task就有多少Driver端的变量副本。如果使用广播变量在每个Executor中只有一份Driver端的变量副本**。 +**如果Executor端用到了Driver的变量,如果不使用广播变量在Executor有多少task就有多少Driver端的变量副本。如果使用广播变量在每个Executor中只有一份Driver端的变量副本。** 一个广播变量可以通过调用`SparkContext.broadcast(v)`方法从一个初始变量v中创建。广播变量是v的一个包装变量,它的值可以通过value方法访问,下面的代码说明了这个过程: @@ -576,7 +584,7 @@ object AccumulatorExample { 这个示例中,我们创建了一个名为 `My Accumulator` 的累加器,并使用 `sc.parallelize(Array(1, 2, 3, 4)).foreach(x => accum.add(x))` 来对其进行累加。最后,我们使用 `println(accum.value)` 来输出累加器的值,结果为 `10`。 -我们可以利用子类AccumulatorParam创建自己的累加器类型。AccumulatorParam接口有两个方法:zero方法为你的数据类型提供一个“0 值”(zero value);addInPlace方法计算两个值的和。例如,假设我们有一个Vector类代表数学上的向量,我们能够如下定义累加器: +我们可以利用子类`AccumulatorParam`创建自己的累加器类型。AccumulatorParam接口有两个方法:zero方法为你的数据类型提供一个“0 值”(zero value),addInPlace方法计算两个值的和。例如,假设我们有一个Vector类代表数学上的向量,我们能够如下定义累加器: ```scala object VectorAccumulatorParam extends AccumulatorParam[Vector] { @@ -597,27 +605,20 @@ Spark为结构化数据处理引入了一个称为Spark SQL的编程模块。它 ### Spark SQL的特性 -- 集成 +- **集成**:无缝地将SQL查询与Spark程序混合。 Spark SQL允许将结构化数据作为Spark中的分布式数据集(RDD)进行查询,在Python,Scala和Java中集成了API。这种紧密的集成使得可以轻松地运行SQL查询以及复杂的分析算法。 -无缝地将SQL查询与Spark程序混合。 Spark SQL允许将结构化数据作为Spark中的分布式数据集(RDD)进行查询,在Python,Scala和Java中集成了API。这种紧密的集成使得可以轻松地运行SQL查询以及复杂的分析算法。 +- **Hive兼容性**:在现有仓库上运行未修改的Hive查询。 Spark SQL重用了Hive前端和MetaStore,提供与现有Hive数据,查询和UDF的完全兼容性。只需将其与Hive一起安装即可。 -- Hive兼容性 +- **标准连接**:通过JDBC或ODBC连接。 Spark SQL包括具有行业标准JDBC和ODBC连接的服务器模式。 -在现有仓库上运行未修改的Hive查询。 Spark SQL重用了Hive前端和MetaStore,提供与现有Hive数据,查询和UDF的完全兼容性。只需将其与Hive一起安装即可。 - -- 标准连接 - -通过JDBC或ODBC连接。 Spark SQL包括具有行业标准JDBC和ODBC连接的服务器模式。 - -- 可扩展性 - -对于交互式查询和长查询使用相同的引擎。 Spark SQL利用RDD模型来支持中查询容错,使其能够扩展到大型作业。不要担心为历史数据使用不同的引擎。 +- **可扩展性**:对于交互式查询和长查询使用相同的引擎。 Spark SQL利用RDD模型来支持中查询容错,使其能够扩展到大型作业。不要担心为历史数据使用不同的引擎。 ### Spark SQL 数据类型 Spark SQL 支持多种数据类型,包括数字类型、字符串类型、二进制类型、布尔类型、日期时间类型和区间类型等。 数字类型包括: + - `ByteType`:代表一个字节的整数,范围是 -128 到 127¹²。 - `ShortType`:代表两个字节的整数,范围是 -32768 到 32767¹²。 - `IntegerType`:代表四个字节的整数,范围是 -2147483648 到 2147483647¹²。 @@ -627,19 +628,24 @@ Spark SQL 支持多种数据类型,包括数字类型、字符串类型、二 - `DecimalType`:代表任意精度的十进制数据,通过内部的 java.math.BigDecimal 支持。BigDecimal 由一个任意精度的整型非标度值和一个 32 位整数组成¹²。 字符串类型包括: + - `StringType`:代表字符字符串值。 二进制类型包括: + - `BinaryType`:代表字节序列值。 布尔类型包括: + - `BooleanType`:代表布尔值。 日期时间类型包括: + - `TimestampType`:代表包含字段年、月、日、时、分、秒的值,与会话本地时区相关。时间戳值表示绝对时间点。 - `DateType`:代表包含字段年、月和日的值,不带时区。 区间类型包括: + - `YearMonthIntervalType (startField, endField)`:表示由以下字段组成的连续子集组成的年月间隔:MONTH(月份),YEAR(年份)。 - `DayTimeIntervalType (startField, endField)`:表示由以下字段组成的连续子集组成的日时间间隔:SECOND(秒),MINUTE(分钟),HOUR(小时),DAY(天)。 @@ -734,7 +740,7 @@ df.show() #### DSL & SQL -在 Spark 中,可以使用两种方式对 DataFrame 进行查询:DSL(Domain-Specific Language)和 SQL。 +在 Spark 中,可以使用两种方式对 DataFrame 进行查询:「**DSL(Domain-Specific Language)**」和「 **SQL**」。 DSL 是一种特定领域语言,它提供了一组用于操作 DataFrame 的方法。例如,下面是一个使用 DSL 进行查询的例子: @@ -840,7 +846,7 @@ df.write.save("path/to/parquet/file") Spark SQL 提供了丰富的内置函数,包括数学函数、字符串函数、日期时间函数、聚合函数等。你可以在 Spark SQL 的官方文档中查看所有可用的内置函数。 -此外,Spark SQL 还支持自定义函数(User-Defined Function,UDF),可以让用户编写自己的函数并在查询中使用。 +此外,Spark SQL 还支持「**自定义函数(User-Defined Function,UDF)**」,可以让用户编写自己的函数并在查询中使用。 下面是一个使用 SQL 语法编写自定义函数的示例代码: @@ -865,7 +871,9 @@ spark.udf.register("square", square) spark.sql("SELECT name, square(age) FROM people").show() ``` -在这个示例中,我们首先定义了一个名为 `square` 的自定义函数,它接受一个整数参数并返回它的平方。然后,我们使用 `createOrReplaceTempView` 方法创建一个临时视图,并使用 `udf.register` 方法注册自定义函数。最后,我们使用 `spark.sql` 方法执行 SQL 查询,并在查询中调用自定义函数。 +在这个示例中,我们首先定义了一个名为 `square` 的自定义函数,它接受一个整数参数并返回它的平方。然后,我们使用 `createOrReplaceTempView` 方法创建一个临时视图,并使用 `udf.register` 方法注册自定义函数。 + +最后,我们使用 `spark.sql` 方法执行 SQL 查询,并在查询中调用自定义函数。 ### DataSet @@ -919,28 +927,28 @@ val ds = spark.createDataset(data) ds.show() ``` -#### DataSet和DataFrame区别 +#### DataSet VS DataFrame DataSet 和 DataFrame 都是 Spark 中用于处理结构化数据的数据结构。它们都提供了丰富的操作,包括筛选、聚合、分组、排序等。 -它们之间的主要区别在于类型安全性。DataFrame 是一种弱类型的数据结构,它的列只有在运行时才能确定类型。这意味着,在编译时无法检测到类型错误,只有在运行时才会抛出异常。 +**它们之间的主要区别在于类型安全性。DataFrame 是一种弱类型的数据结构,它的列只有在运行时才能确定类型。这意味着,在编译时无法检测到类型错误,只有在运行时才会抛出异常。** -而 DataSet 是一种强类型的数据结构,它的类型在编译时就已经确定。这意味着,如果你试图对一个不存在的列进行操作,或者对一个列进行错误的类型转换,编译器就会报错。 +**而 DataSet 是一种强类型的数据结构,它的类型在编译时就已经确定。这意味着,如果你试图对一个不存在的列进行操作,或者对一个列进行错误的类型转换,编译器就会报错。** -此外,DataSet 还提供了一些额外的操作,例如 map、flatMap、reduce 等。 +此外,DataSet 还提供了一些额外的操作,例如 `map`、`flatMap`、`reduce` 等。 ### RDD & DataFrame & Dataset 转化 RDD、DataFrame、Dataset三者有许多共性,有各自适用的场景常常需要在三者之间转换。 -- DataFrame/Dataset转RDD +- DataFrame/Dataset 转 RDD ```SCALA val rdd1=testDF.rdd val rdd2=testDS.rdd ``` -- RDD转DataFrame +- RDD 转 DataSet ```scala import spark.implicits._ @@ -952,14 +960,14 @@ val testDS = rdd.map {line=> 可以注意到,定义每一行的类型(case class)时,已经给出了字段名和类型,后面只要往case class里面添加值即可。 -- Dataset转DataFrame +- Dataset 转 DataFrame ```SCALA import spark.implicits._ val testDF = testDS.toDF ``` -- DataFrame转Dataset +- DataFrame 转 Dataset ```SCALA import spark.implicits._ @@ -969,8 +977,7 @@ val testDS = testDF.as[Coltest] 这种方法就是在给出每一列的类型后,使用`as`方法,转成Dataset,这在数据类型在DataFrame需要针对各个字段处理时极为方便。 -**注意**: -在使用一些特殊的操作时,一定要加上 `import spark.implicits._` 不然`toDF`、`toDS`无法使用。 +**注意**:在使用一些特殊的操作时,一定要加上 `import spark.implicits._` 不然`toDF`、`toDS`无法使用。 ## Spark Streaming @@ -1002,15 +1009,15 @@ ssc.awaitTermination() Spark Streaming 作为一种实时流处理框架,具有以下优点: -- 高性能:Spark Streaming 基于 Spark 引擎,能够快速处理大规模的数据流。 -- 易用性:Spark Streaming 提供了丰富的 API,可以让开发人员快速构建实时流处理应用。 -- 容错性:Spark Streaming 具有良好的容错性,能够在节点故障时自动恢复。 -- 集成性:Spark Streaming 能够与 Spark 生态系统中的其他组件(如 Spark SQL、MLlib 等)无缝集成。 +- **高性能**:Spark Streaming 基于 Spark 引擎,能够快速处理大规模的数据流。 +- **易用性**:Spark Streaming 提供了丰富的 API,可以让开发人员快速构建实时流处理应用。 +- **容错性**:Spark Streaming 具有良好的容错性,能够在节点故障时自动恢复。 +- **集成性**:Spark Streaming 能够与 Spark 生态系统中的其他组件(如 Spark SQL、MLlib 等)无缝集成。 但是,Spark Streaming 也有一些缺点: -- 延迟:由于 Spark Streaming 基于微批处理模型,因此它的延迟相对较高。对于需要极低延迟的应用场景,Spark Streaming 可能不是最佳选择。 -- 复杂性:Spark Streaming 的配置和调优相对复杂,需要一定的经验和技能。 +- **延迟**:由于 Spark Streaming 基于微批处理模型,因此它的延迟相对较高。对于需要极低延迟的应用场景,Spark Streaming 可能不是最佳选择。 +- **复杂性**:Spark Streaming 的配置和调优相对复杂,需要一定的经验和技能。 ### DStream @@ -1069,7 +1076,7 @@ ssc.start() ssc.awaitTermination() ``` -**总结:** **简单来说 DStream 就是对 RDD 的封装,你对 DStream 进行操作,就是对 RDD 进行操作。对于 DataFrame/DataSet/DStream 来说本质上都可以理解成 RDD**。 +**总结:简单来说 DStream 就是对 RDD 的封装,你对 DStream 进行操作,就是对 RDD 进行操作。对于 DataFrame/DataSet/DStream 来说本质上都可以理解成 RDD。** ### 窗口函数 @@ -1077,10 +1084,10 @@ ssc.awaitTermination() Spark Streaming 提供了多种窗口函数,包括: -- `window`:返回一个新的 DStream,它包含了原始 DStream 中指定窗口大小和滑动间隔的数据。 -- `countByWindow`:返回一个新的单元素 DStream,它包含了原始 DStream 中指定窗口大小和滑动间隔的元素个数。 -- `reduceByWindow`:返回一个新的 DStream,它包含了原始 DStream 中指定窗口大小和滑动间隔的元素经过 reduce 函数处理后的结果。 -- `reduceByKeyAndWindow`:类似于 `reduceByWindow`,但是在进行 reduce 操作之前会先按照 key 进行分组。 +- **window**:返回一个新的 DStream,它包含了原始 DStream 中指定窗口大小和滑动间隔的数据。 +- **countByWindow**:返回一个新的单元素 DStream,它包含了原始 DStream 中指定窗口大小和滑动间隔的元素个数。 +- **reduceByWindow**:返回一个新的 DStream,它包含了原始 DStream 中指定窗口大小和滑动间隔的元素经过 reduce 函数处理后的结果。 +- **reduceByKeyAndWindow**:类似于 reduceByWindow,但是在进行 reduce 操作之前会先按照 key 进行分组。 下面是一个使用窗口函数的示例代码: @@ -1108,11 +1115,11 @@ ssc.awaitTermination() Spark Streaming允许DStream的数据输出到外部系统,如数据库或文件系统,输出的数据可以被外部系统所使用,该操作类似于RDD的输出操作。Spark Streaming支持以下输出操作: -- `print()`: 打印DStream中每个RDD的前10个元素到控制台。 -- `saveAsTextFiles(prefix, [suffix])`: 将此DStream中每个RDD的所有元素以文本文件的形式保存。每个批次的数据都会保存在一个单独的目录中,目录名为:`prefix-TIME_IN_MS[.suffix]`。 -- `saveAsObjectFiles(prefix, [suffix])`: 将此DStream中每个RDD的所有元素以Java对象序列化的形式保存。每个批次的数据都会保存在一个单独的目录中,目录名为:`prefix-TIME_IN_MS[.suffix]`。 -- `saveAsHadoopFiles(prefix, [suffix])`: 将此DStream中每个RDD的所有元素以Hadoop文件(SequenceFile等)的形式保存。每个批次的数据都会保存在一个单独的目录中,目录名为:`prefix-TIME_IN_MS[.suffix]`。 -- `foreachRDD(func)`: 最通用的输出操作,将函数func应用于DStream中生成的每个RDD。通过此函数,可以将数据写入任何支持写入操作的数据源。 +- **print() **: 打印DStream中每个RDD的前10个元素到控制台。 +- **saveAsTextFiles(prefix, [suffix] **: 将此DStream中每个RDD的所有元素以文本文件的形式保存。每个批次的数据都会保存在一个单独的目录中,目录名为:`prefix-TIME_IN_MS[.suffix]`。 +- **saveAsObjectFiles(prefix, [suffix])**: 将此DStream中每个RDD的所有元素以Java对象序列化的形式保存。每个批次的数据都会保存在一个单独的目录中,目录名为:`prefix-TIME_IN_MS[.suffix]`。 +- **saveAsHadoopFiles(prefix, [suffix])**:将此DStream中每个RDD的所有元素以Hadoop文件(SequenceFile等)的形式保存。每个批次的数据都会保存在一个单独的目录中,目录名为:`prefix-TIME_IN_MS[.suffix]`。 +- **foreachRDD(func)**:最通用的输出操作,将函数func应用于DStream中生成的每个RDD。通过此函数,可以将数据写入任何支持写入操作的数据源。 ## Structured Streaming @@ -1120,10 +1127,10 @@ Structured Streaming 是 Spark 2.0 版本中引入的一种新的流处理引擎 与 Spark Streaming 相比,Structured Streaming 具有以下优点: -- 易用性:Structured Streaming 提供了与 Spark SQL 相同的 API,可以让开发人员快速构建流处理应用。 -- 高性能:Structured Streaming 基于 Spark SQL 引擎,能够快速处理大规模的数据流。 -- 容错性:Structured Streaming 具有良好的容错性,能够在节点故障时自动恢复。 -- 端到端一致性:Structured Streaming 提供了端到端一致性保证,能够确保数据不丢失、不重复。 +- **易用性**:Structured Streaming 提供了与 Spark SQL 相同的 API,可以让开发人员快速构建流处理应用。 +- **高性能**:Structured Streaming 基于 Spark SQL 引擎,能够快速处理大规模的数据流。 +- **容错性**:Structured Streaming 具有良好的容错性,能够在节点故障时自动恢复。 +- **端到端一致性**:Structured Streaming 提供了端到端一致性保证,能够确保数据不丢失、不重复。 下面是一个简单的 Structured Streaming 示例代码: @@ -1260,7 +1267,7 @@ lines.writeStream .start() ``` -#### output mode +#### Output Mode 每当结果表更新时,我们都希望将更改后的结果行写入外部接收器。 @@ -1272,7 +1279,7 @@ Output mode 指定了数据写入输出接收器的方式。Structured Streaming | Complete | 每当有更新时,将流 DataFrame/Dataset 中的所有行写入接收器。 | | Update | 每当有更新时,只将流 DataFrame/Dataset 中更新的行写入接收器。 | -#### output sink +#### Output Sink Output sink 指定了数据写入的位置。Structured Streaming 支持多种输出接收器,包括文件接收器、Kafka 接收器、Foreach 接收器、控制台接收器和内存接收器等。下面是一些使用 Scala 语言将数据写入到不同输出接收器中的例子: @@ -1392,4 +1399,6 @@ Batch: 0 ## 总结 -总之,Spark是一个强大的大数据处理框架,它具有高性能、易用性和灵活性等优点。希望本文能够帮助你入门Spark,并在实际应用中发挥它的强大功能。如果你想深入学习Spark,可以参考官方文档和相关书籍,也可以加入Spark社区,与其他开发人员交流经验。 +在此,我们对Spark的基本概念、使用方式以及部分原理进行了简单的介绍。Spark以其强大的处理能力和灵活性,已经成为大数据处理领域的一个重要工具。然而,这只是冰山一角。Spark的世界里还有许多深度和广度等待着我们去探索。 + +作为初学者,你可能会觉得这个领域庞大且复杂。但请记住,每个都是从初学者开始的。不断的学习和实践,你将能够更好的理解和掌握Spark,并将其应用于解决实际问题。这篇文章可能不能涵盖所有的知识点,但我希望它能带给你收获和思考。 diff --git "a/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2063W\345\255\227Flink\345\205\245\351\227\250\347\254\224\350\256\260.md" "b/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2063W\345\255\227Flink\345\205\245\351\227\250\347\254\224\350\256\260.md" deleted file mode 100644 index 0187f84..0000000 --- "a/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2063W\345\255\227Flink\345\205\245\351\227\250\347\254\224\350\256\260.md" +++ /dev/null @@ -1,4176 +0,0 @@ -[TOC] - -因为公司用到大数据技术栈的缘故,之前也写过HBase,Spark等文章,公司离线用的是Spark,实时用的是Flink,所以这篇文章是关于Flink的,这篇文章对Flink的相关概念介绍的比较全面,希望对大家学习Flink能有所帮助。 - -Flink的一些概念和Spark非常像,看这篇文章之前,强烈建议翻看之前的Spark文章,这样学习Flink的时候能够举一反三,有助于理解。 - -## 流处理 & 批处理 - -事实上 Flink 本身是流批统一的处理架构,批量的数据集本质上也是流。在 Flink 的视角里,一切数据都可以认为是流,**流数据是无界流,而批数据则是有界流,流数据每输入一条数据,就有一次对应的输出**。 - -批处理,也叫作离线处理。针对的是有界数据集,非常适合需要访问海量的全部数据才能完成的计算工作,一般用于离线统计。 - -流处理主要针对的是数据流,特点是无界、实时,对系统传输的每个数据依次执行操作,一般用于实时统计。 - -### 无界流Unbounded streams - -无界流有定义流的开始,但没有定义流的结束。它们会无休止地产生数据。无界流的数据必须持续处理,即数据被摄取后需要立刻处理。我们不能等到所有数据都到达再处理,因为输入是无限的,在任何时候输入都不会完成。处理无界数据通常要求以特定顺序摄取事件,例如事件发生的顺序,以便能够推断结果的完整性。 - -### 有界流Bounded streams - -有界流有定义流的开始,也有定义流的结束。有界流可以在摄取所有数据后再进行计算。有界流所有数据可以被排序,所以并不需要有序摄取。有界流处理通常被称为批处理。所以在Flink里批计算其实指的就是有界流。 - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnjPQcmW39R8wibMMpqpQGLoiaMFiakpuFUajrXE8dL0yHwib8icmg6Y1fib5Tv06EnMMtH7jtXJJQVAMQ/640?wx_fmt=png) - -## Flink的特点和优势 - -- 同时支持高吞吐、低延迟、高性能。 -- 支持事件时间(Event Time)概念,结合Watermark处理乱序数据 -- 支持有状态计算,并且支持多种状态内存、 文件、RocksDB。 -- 支持高度灵活的窗口(Window) 操作time、 count、 session。 -- 基于轻量级分布式快照(CheckPoint) 实现的容错保证Exactly- Once语义。 -- 基于JVM实现独立的内存管理。 -- Save Points (保存点)。 - -## Flink VS Spark - -Spark 和 Flink 在不同的应用领域上表现会有差别。一般来说,Spark 基于微批处理的方式做同步总有一个“攒批”的过程,所以会有额外开销,因此无法在流处理的低延迟上做到极致。在低延迟流处理场景,Flink 已经有明显的优势。而在海量数据的批处理领域,Spark 能够处理的吞吐量更大。 - -**Spark Streaming的流计算其实是微批计算,实时性不如Flink,还有一点很重要的是Spark Streaming不适合有状态的计算,得借助一些存储如:Redis,才能实现。而Flink天然支持有状态的计算**。 - -## Flink API - -Flink 本身提供了多层 API: - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLp9EtLdWwjR2aiaWXxBr5q1JOc9Q1k8P7OibcCvlYxib3HzE9VQKzx3ktAg/640?wx_fmt=png) - -- **Stateful Stream Processing** 最低级的抽象接口是状态化的数据流接口(stateful streaming)。这个接口是通过 ProcessFunction 集成到 DataStream API 中的。该接口允许用户自由的处理来自一个或多个流中的事件,并使用一致的容错状态。另外,用户也可以通过注册 event time 和 processing time 处理回调函数的方法来实现复杂的计算。 -- **DataStream/DataSet API** DataStream / DataSet API 是 Flink 提供的核心 API ,DataSet 处理有界的数据集,DataStream 处理有界或者无界的数据流。用户可以通过各种方法(map / flatmap / window / keyby / sum / max / min / avg / join 等)将数据进行转换 / 计算。 -- **Table API** Table API 提供了例如 select、project、join、group-by、aggregate 等操作,使用起来却更加简洁,可以在表与 DataStream/DataSet 之间无缝切换,也允许程序将 Table API 与 DataStream 以及 DataSet 混合使用。 -- **SQL** Flink 提供的最高层级的抽象是 SQL,这一层抽象在语法与表达能力上与 Table API 类似,SQL 抽象与 Table API 交互密切,同时 SQL 查询可以直接在 Table API 定义的表上执行。 - -## Dataflows数据流图 - -所有的 Flink 程序都可以归纳为由三部分构成:Source、Transformation 和 Sink。 - -- Source 表示“源算子”,负责读取数据源。 - -- Transformation 表示“转换算子”,利用各种算子进行处理加工。 - -- Sink 表示“下沉算子”,负责数据的输出。 - -source数据源会源源不断的产生数据,transformation将产生的数据进行各种业务逻辑的数据处理,最终由sink输出到外部(console、kafka、redis、DB......)。 - -基于Flink开发的程序都能够映射成一个Dataflows。 - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLptqzt0pibVTiazpib2DbAt6DJKrqnSX5iaJdgt2ShOAF8ibGz7MBVScwiaxCQ/640?wx_fmt=png) - -当source数据源的数量比较大或计算逻辑相对比较复杂的情况下,需要提高并行度来处理数据,采用并行数据流。 - -通过设置不同算子的并行度, source并行度设置为2 , map也是2。代表会启动2个并行的线程来处理数据: - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpCaXHUMG7RLn3A4FX2Pq4SqcvwdAFhN74T5lcquFZEA37untZibCObsw/640?wx_fmt=png) - -## Flink基本架构 - -Flink系统架构中包含了两个角色,分别是JobManager和TaskManager,是一个典型的Master-Slave架构。JobManager相当于是Master,TaskManager相当于是Slave。 - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpvf34vfkbzvdq19zWJoAMNNMVJkWI5tgc1r2Xwcc0w07sswUricnibNQA/640?wx_fmt=png) - -### Job Manager & Task Manager - -在Flink中,JobManager负责整个Flink集群任务的调度以及资源的管理。它从客户端中获取提交的应用,然后根据集群中TaskManager上TaskSlot的使用情况,为提交的应用分配相应的TaskSlot资源并命令TaskManager启动从客户端中获取的应用。 - -TaskManager负责执行作业流的Task,并且缓存和交换数据流。在TaskManager中资源调度的最小单位是Task slot。TaskManager中Task slot的数量表示并发处理Task的数量。**一台机器节点可以运行多个TaskManager** 。 - -**TaskManager会向JobManager发送心跳保持连接**。 - -## 集群 & 部署 - -### 部署模式 - -Flink支持多种部署模式,包括本地模式、Standalone模式、YARN模式、Mesos模式和Kubernetes模式。 - -- 本地模式:本地模式是在单个JVM中启动Flink,主要用于开发和测试。它不需要任何集群管理器,但也不能跨多台机器运行。本地模式的优点是部署简单,缺点是不能利用分布式计算的优势。 -- Standalone模式:Standalone模式是在一个独立的集群中运行Flink。它需要手动启动Flink集群,并且需要手动管理资源。Standalone模式的优点是部署简单,可以跨多台机器运行,缺点是需要手动管理资源。 -- YARN模式:YARN模式是在Hadoop YARN集群中运行Flink。它可以利用YARN进行资源管理和调度。YARN模式的优点是可以利用现有的Hadoop集群,缺点是需要安装和配置Hadoop YARN,**这是在企业中使用最多的方式**。 -- Mesos模式:Mesos模式是在Apache Mesos集群中运行Flink。它可以利用Mesos进行资源管理和调度。Mesos模式的优点是可以利用现有的Mesos集群,缺点是需要安装和配置Mesos。 -- Kubernetes模式:Kubernetes模式是在Kubernetes集群中运行Flink。它可以利用Kubernetes进行资源管理和调度。Kubernetes模式的优点是可以利用现有的Kubernetes集群,缺点是需要安装和配置Kubernetes。 - -每种部署模式都有其优缺点,选择哪种部署模式取决于具体的应用场景和需求。 - -**Session、Per-Job和Application**是Flink在YARN和Kubernetes上运行时的三种不同模式,它们不是独立的部署模式,而是在YARN和Kubernetes部署模式下的子模式。 - -- Session模式:在Session模式下,Flink集群会一直运行,用户可以在同一个Flink集群中提交多个作业。Session模式的优点是作业提交快,缺点是作业之间可能会相互影响。 -- Per-Job模式:在Per-Job模式下,每个作业都会启动一个独立的Flink集群。Per-Job模式的优点是作业之间相互隔离,缺点是作业提交慢。 -- Application模式:Application模式是在Flink 1.11版本中引入的一种新模式,它结合了Session模式和Per-Job模式的优点。在Application模式下,每个作业都会启动一个独立的Flink集群,但是作业提交快。 - -这三种模式都可以在YARN和Kubernetes部署模式下使用。 - -### 提交作业流程 - -1. Session 模式: - - 在 Session 模式下,Flink 运行在交互式会话中,允许用户在一个 Flink 集群上连续地提交和管理多个作业。 - - 用户可以通过 Flink 命令行界面(CLI)或 Web UI 进行交互。 - - 提交流程如下: - - 用户启动 Flink 会话,并连接到 Flink 集群。 - - 用户使用 CLI 或 Web UI 提交作业,提交的作业被发送到 Flink 集群的 JobManager。 - - JobManager 接收作业后,会对作业进行解析和编译,生成作业图(JobGraph)。 - - 生成的作业图被发送到 JobManager 的调度器进行调度。 - - 调度器将作业图划分为任务并将其分配给 TaskManager 执行。 - - TaskManager 在其本地执行环境中运行任务。 -2. Per-Job 模式: - - 在 Per-Job 模式下,每个作业都会启动一个独立的 Flink 集群,用于执行该作业。 - - 这种模式适用于独立的批处理或流处理作业,不需要与其他作业共享资源。 - - 提交流程如下: - - 用户准备好作业程序和所需的配置文件。 - - 用户使用 Flink 提供的命令行工具或编程 API 将作业程序和配置文件打包成一个作业 JAR 文件。 - - 用户将作业 JAR 文件上传到 Flink 集群所在的环境(例如 Hadoop 分布式文件系统)。 - - 用户使用 Flink 提供的命令行工具或编程 API 在指定的 Flink 集群上提交作业。 - - JobManager 接收作业 JAR 文件并进行解析、编译和调度。 - - 调度器将作业图划分为任务并将其分配给可用的 TaskManager 执行。 - - TaskManager 在其本地执行环境中运行任务。 -3. Application 模式: - - Application 模式是 Flink 1.11 版本引入的一种模式,用于在常驻的 Flink 集群上执行多个应用程序。 - - 在 Application 模式下,用户可以在运行中的 Flink 集群上动态提交、更新和停止应用程序。 - - 提交流程如下: - - 用户准备好应用程序程序和所需的配置文件。 - - 用户使用 Flink 提供的命令行工具或编程 API 将应用程序程序和配置文件打包成一个应用程序 JAR 文件。 - - 用户将应用程序 JAR 文件上传到 Flink 集群所在的环境(例如 Hadoop 分布式文件系统)。 - - 用户使用 Flink 提供的命令行工具或编程 API 在指定的 Flink 集群上提交应用程序。 - - JobManager 接收应用程序 JAR 文件并进行解析、编译和调度。 - - 调度器将应用程序图划分为任务并将其分配给可用的 TaskManager 执行。 - - TaskManager 在其本地执行环境中运行任务。 - -## 配置开发环境 - -每个 Flink 应用都需要依赖一组 Flink 类库。Flink 应用至少需要依赖 Flink APIs。许多应用还会额外依赖连接器类库(比如 Kafka、Cassandra 等)。 当用户运行 Flink 应用时(无论是在 IDEA 环境下进行测试,还是部署在分布式环境下),运行时类库都必须可用 - -开发工具:IntelliJ IDEA - -配置开发Maven依赖: - -```xml - - org.apache.flink - flink-scala_2.11 - 1.10.0 - - - org.apache.flink - flink-streaming-scala_2.11 - 1.10.0 - -``` - -注意点: - -- **如果要将程序打包提交到集群运行,打包的时候不需要包含这些依赖,因为集群环境已经包含了这些依赖,此时依赖的作用域应该设置为provided**。 -- Flink 应用在 IntelliJ IDEA 中运行,这些 Flink 核心依赖的作用域需要设置为 compile 而不是 provided 。 否则 IntelliJ 不会添加这些依赖到 classpath,会导致应用运行时抛出 `NoClassDefFountError` 异常。 - -添加打包插件: - -```xml - - - - org.apache.maven.plugins - maven-shade-plugin - 3.1.1 - - - package - - shade - - - - - com.google.code.findbugs:jsr305 - org.slf4j:* - log4j:* - - - - - - *:* - - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - - - my.programs.main.clazz - - - - - - - - -``` - -### WordCount流批计算程序 - -配置好开发环境之后写一个简单的Flink程序。 - -实现:统计HDFS文件单词出现的次数 - -读取HDFS数据需要添加Hadoop依赖 - -```xml - - org.apache.hadoop - hadoop-client - 2.6.5 - -``` - -批计算: - -```scala -val env = ExecutionEnvironment.getExecutionEnvironment -val initDS: DataSet[String] = env.readTextFile("hdfs://node01:9000/flink/data/wc") -val restDS: AggregateDataSet[(String, Int)] = initDS.flatMap(_.split(" ")).map((_,1)).groupBy(0).sum(1) -restDS.print() -``` - ------- - -流计算: - -```scala - /** 准备环境 - * createLocalEnvironment 创建一个本地执行的环境,local - * createLocalEnvironmentWithWebUI 创建一个本地执行的环境,同时还开启Web UI的查看端口,8081 - * getExecutionEnvironment 根据你执行的环境创建上下文,比如local cluster - */ - val env = StreamExecutionEnvironment.getExecutionEnvironment - env.setParallelism(1) - /** - * DataStream:一组相同类型的元素 组成的数据流 - */ - val initStream:DataStream[String] = env.socketTextStream("node01",8888) - val wordStream = initStream.flatMap(_.split(" ")) - val pairStream = wordStream.map((_,1)) - val keyByStream = pairStream.keyBy(0) - val restStream = keyByStream.sum(1) - restStream.print() - //启动Flink 任务 - env.execute("first flink job") -``` - -## 并行度 - -**特定算子的子任务(subtask)的个数称之为并行度(parallel),并行度是几,这个task内部就有几个subtask。** - -怎样实现算子并行呢?其实也很简单,我们把一个算子操作,“复制”多份到多个节点,数据来了之后就可以到其中任意一个执行。这样一来,一个算子任务就被拆分成了多个并行的“子任务”(subtasks),再将它们分发到不同节点,就真正实现了并行计算。 - -**整个流处理程序的并行度,理论上是所有算子并行度中最大的那个,这代表了运行程序需要的 slot 数量**。 - -### 并行度设置 - -在 Flink 中,可以用不同的方法来设置并行度,它们的有效范围和优先级别也是不同的。 - -**代码中设置** - -- 我们在代码中,可以很简单地在算子后跟着调用 `setParallelism()`方法,来设置当前算子的并行度: `stream.map(word -> Tuple2.of(word, 1L)).setParallelism(2);`这种方式设置的并行度,只针对当前算子有效。 -- 我们也可以直接调用执行环境的 `setParallelism()`方法,全局设定并行度:`env.setParallelism(2);`这样代码中所有算子,默认的并行度就都为 2 了。 - -**提交应用时设置** - -在使用 flink run 命令提交应用时,可以增加 `-p` 参数来指定当前应用程序执行的并行度,它的作用类似于执行环境的全局设置。如果我们直接在 Web UI 上提交作业,也可以在对应输入框中直接添加并行度。 - -**配置文件中设置** - -我们还可以直接在集群的配置文件 flink-conf.yaml 中直接更改默认并行度:parallelism.default: 2(初始值为 1) - -这个设置对于整个集群上提交的所有作业有效。 - -**在开发环境中,没有配置文件,默认并行度就是当前机器的 CPU 核心数**。 - -### 并行度生效优先级 - -1. 对于一个算子,首先看在代码中是否单独指定了它的并行度,这个特定的设置优先级最高,会覆盖后面所有的设置。 -2. 如果没有单独设置,那么采用当前代码中执行环境全局设置的并行度。 -3. 如果代码中完全没有设置,那么采用提交时-p 参数指定的并行度。 -4. 如果提交时也未指定-p 参数,那么采用集群配置文件中的默认并行度。 - -**这里需要说明的是,算子的并行度有时会受到自身具体实现的影响。比如读取 socket 文本流的算子 socketTextStream,它本身就是非并行的 Source 算子,所以无论怎么设置,它在运行时的并行度都是 1**。 - -## Task - -在 Flink 中,Task 是一个阶段多个功能相同 subTask 的集合,Flink 会尽可能地将 operator 的 subtask 链接(chain)在一起形成 task。每个 task 在一个线程中执行。将 operators 链接成 task 是非常有效的优化:它能减少线程之间的切换,减少消息的序列化/反序列化,减少数据在缓冲区的交换,减少了延迟的同时提高整体的吞吐量。 - -**要是之前学过Spark,这里可以用Spark的思想来看,Flink的Task就好比Spark中的Stage,而我们知道Spark的Stage是根据宽依赖来拆分的。所以我们也可以认为Flink的Task也是根据宽依赖拆分的(尽管Flink中并没有宽依赖的概念),这样会更好理解,如下图:** - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOiaicvO7VztvmFhl4p3h3icdPLDnH9XhJ7ojiaxpbII45iabaDJico8S9WsHo0qnCMRMJYQjfic2M54CWkw/640?wx_fmt=png) - -## Operator Chain(算子链) - -在Flink中,为了分布式执行,Flink会将算子子任务链接在一起形成任务。每个任务由一个线程执行。将算子链接在一起形成任务是一种有用的优化:**它减少了线程间切换和缓冲的开销,并增加了整体吞吐量,同时降低了延迟**。 - -举个例子,假设我们有一个简单的Flink流处理程序,它从一个源读取数据,然后应用`map`和`filter`操作,最后将结果写入到一个接收器。这个程序可能看起来像这样: - -```Java -DataStream data = env.addSource(new CustomSource()); -data.map(new MapFunction() { - @Override - public String map(String value) throws Exception { - return value.toUpperCase(); - } -}) -.filter(new FilterFunction() { - @Override - public boolean filter(String value) throws Exception { - return value.startsWith("A"); - } -}) -.addSink(new CustomSink()); -``` - -**在这个例子中,`map`和`filter`操作可以被链接在一起形成一个任务,被优化为算子链,这意味着它们将在同一个线程中执行,而不是在不同的线程中执行并通过网络进行数据传输**。 - -## Task Slots - -Task Slots即是任务槽,slot 在 Flink 里面可以认为是资源组,Flink 将每个任务分成子任务并且将这些子任务分配到 slot 来并行执行程序,我们可以通过集群的配置文件来设定 TaskManager 的 slot 数量:**taskmanager.numberOfTaskSlots**: 8。 - -例如,如果 Task Manager 有2个 slot,那么它将为每个 slot 分配 50% 的内存。 可以在一个 slot 中运行一个或多个线程。 同一 slot 中的线程共享相同的 JVM。 - -**需要注意的是,slot 目前仅仅用来隔离内存,不会涉及 CPU 的隔离。在具体应用时,可以将 slot 数量配置为机器的 CPU 核心数,尽量避免不同任务之间对 CPU 的竞争。这也是开发环境默认并行度设为机器 CPU 数量的原因**。 - -### 分发规则 - -- **不同的Task下的subtask要分发到同一个TaskSlot中,降低数据传输、提高执行效率**。 -- **相同的Task下的subtask要分发到不同的TaskSlot**。 - -### Slot共享组 - -如果希望某个算子对应的任务完全独占一个 slot,或者只有某一部分算子共享 slot,在Flink中,可以通过在代码中使用`slotSharingGroup`方法来设置slot共享组。Flink会将具有相同slot共享组的操作放入同一个slot中,同时保持不具有slot共享组的操作在其他slot中。这可以用来隔离slot。 - -例如,你可以这样设置: - -```Java -dataStream.map(...).slotSharingGroup("group1"); -``` - -默认情况下,所有操作都被分配相同的SlotSharingGroup。 - -这样,只有属于同一个 slot 共享组的子任务,才会开启 slot 共享;不同组之间的任务是完全隔离的,必须分配到不同的 slot 上。 - -### 并行度和Slots的例子 - -听了上面并行度和Slots的理论,可能有点疑惑,通过一个例子简单说明下: - -假设一共有3个TaskManager,每一个TaskManager中的slot数量设置为3个,那么一共有9个task slot,表示最多能并行执行9个任务。 - -假设我们写了一个WordCount程序,有四个转换算子:**source —> flatMap —> reduce —> sink**。 - -当所有算子并行度相同时,容易看出source和flatMap可以优化合并算子链,于是最终有三个任务节点:source & flatMap,reduce 和sink。 -如果我们没有任何并行度设置,而配置文件中默认parallelism.default=1,那么程序运行的默认并行度为1,总共有3个任务。**由于不同算子的任务可以共享任务槽,所以最终占用的slot只有1个。9个slot只用了1个,有8个空闲**。如图所示: - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnjPQcmW39R8wibMMpqpQGLbsA32ngsNbhXic9yRxkicMDt9joDuuGeeT3dBtyp0jibQ6Ewib5wFx5Yeg/640?wx_fmt=png) - -我们可以直接把并行度设置为 9,这样所有 3*9=27 个任务就会完全占用 9 个 slot。这是当前集群资源下能执行的最大并行度,计算资源得到了充分的利用。 - -另外再考虑对于某个算子单独设置并行度的场景。例如,如果我们考虑到输出可能是写入文件,那会希望不要并行写入多个文件,就需要设置 sink 算子的并行度为 1。这时其他的算子并行度依然为 9,所以总共会有 19 个子任务。根据 slot 共享的原则,它们最终还是会占用全部的 9 个 slot,而 sink 任务只在其中一个 slot 上执行,通过这个例子也可以明确地看到,**整个流处理程序的并行度,就应该是所有算子并行度中最大的那个,这代表了运行程序需要的 slot 数量**。 - -## DataSource数据源 - -Flink内嵌支持的数据源非常多,比如HDFS、Socket、Kafka、Collections。Flink也提供了addSource方式,可以自定义数据源,下面介绍一些常用的数据源。 - -### File Source - -- 通过读取本地、HDFS文件创建一个数据源。 - -如果读取的是HDFS上的文件,那么需要导入Hadoop依赖 - -```xml - - org.apache.hadoop - hadoop-client - 2.6.5 - -``` - -代码示例:每隔10s去读取HDFS指定目录下的新增文件内容,并且进行WordCount。 - -```scala -import org.apache.flink.api.java.io.TextInputFormat -import org.apache.flink.core.fs.Path -import org.apache.flink.streaming.api.functions.source.FileProcessingMode -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment -//在算子转换的时候,会将数据转换成Flink内置的数据类型,所以需要将隐式转换导入进来,才能自动进行类型转换 -import org.apache.flink.streaming.api.scala._ - -object FileSource { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - //读取hdfs文件 - val filePath = "hdfs://node01:9000/flink/data/" - val textInputFormat = new TextInputFormat(new Path(filePath)) - //每隔10s中读取 hdfs上新增文件内容 - val textStream = env.readFile(textInputFormat,filePath,FileProcessingMode.PROCESS_CONTINUOUSLY,10) - textStream.flatMap(_.split(" ")).map((_,1)).keyBy(0).sum(1).print() - env.execute() - } -} -``` - -**readTextFile底层调用的就是readFile方法,readFile是一个更加底层的方式,使用起来会更加的灵活** - ------- - -### Collection Source - -基于本地集合的数据源,一般用于测试场景,没有太大意义。 - -```scala -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment -import org.apache.flink.streaming.api.scala._ - -object CollectionSource { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.fromCollection(List("hello flink msb","hello msb msb")) - stream.flatMap(_.split(" ")).map((_,1)).keyBy(0).sum(1).print() - env.execute() - } -} -``` - ------- - -### Socket Source - -接受Socket Server中的数据。 - -```scala -val initStream:DataStream[String] = env.socketTextStream("node01",8888) -``` - ------- - -### Kafka Source - -Flink接受Kafka中的数据,首先要配置flink与kafka的连接器依赖。 - -Maven依赖: - -```xml - - org.apache.flink - flink-connector-kafka_2.11 - 1.9.2 - -``` - -代码: - -```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - val prop = new Properties() - prop.setProperty("bootstrap.servers","node01:9092,node02:9092,node03:9092") - prop.setProperty("group.id","flink-kafka-id001") - prop.setProperty("key.deserializer",classOf[StringDeserializer].getName) - prop.setProperty("value.deserializer",classOf[StringDeserializer].getName) - /** - * earliest:从头开始消费,旧数据会频繁消费 - * latest:从最近的数据开始消费,不再消费旧数据 - */ - prop.setProperty("auto.offset.reset","latest") - val kafkaStream = env.addSource(new FlinkKafkaConsumer[(String, String)]("flink-kafka", new KafkaDeserializationSchema[(String, String)] { - override def isEndOfStream(t: (String, String)): Boolean = false - - override def deserialize(consumerRecord: ConsumerRecord[Array[Byte], Array[Byte]]): (String, String) = { - val key = new String(consumerRecord.key(), "UTF-8") - val value = new String(consumerRecord.value(), "UTF-8") - (key, value) - } - //指定返回数据类型 - override def getProducedType: TypeInformation[(String, String)] = - createTuple2TypeInformation(createTypeInformation[String], createTypeInformation[String]) - }, prop)) - kafkaStream.print() - env.execute() -``` - -## Transformations - -Transformations算子可以将一个或者多个算子转换成一个新的数据流,使用Transformations算子组合可以进行复杂的业务处理。 - -### Map - -DataStream → DataStream - -遍历数据流中的每一个元素,产生一个新的元素。 - -### FlatMap - -DataStream → DataStream - -遍历数据流中的每一个元素,产生N个元素 N=0,1,2,......。 - -### Filter - -DataStream → DataStream - -过滤算子,根据数据流的元素计算出一个boolean类型的值,true代表保留,false代表过滤掉。 - -### KeyBy - -DataStream → KeyedStream - -根据数据流中指定的字段来分区,相同指定字段值的数据一定是在同一个分区中,内部分区使用的是HashPartitioner。 - -指定分区字段的方式有三种: - -1、根据索引号指定 -2、通过匿名函数来指定 -3、通过实现KeySelector接口 指定分区字段 - -```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.generateSequence(1, 100) - stream - .map(x => (x % 3, 1)) - //根据索引号来指定分区字段 - // .keyBy(0) - //通过传入匿名函数 指定分区字段 - // .keyBy(x=>x._1) - //通过实现KeySelector接口 指定分区字段 - .keyBy(new KeySelector[(Long, Int), Long] { - override def getKey(value: (Long, Int)): Long = value._1 - }) - .sum(1) - .print() - env.execute() -``` - -### Reduce - -KeyedStream:根据key分组 → DataStream - -**注意,reduce是基于分区后的流对象进行聚合,也就是说,DataStream类型的对象无法调用reduce方法**。 - -```scala -.reduce((v1,v2) => (v1._1,v1._2 + v2._2)) -``` - -代码例子:读取kafka数据,实时统计各个卡口下的车流量。 - -- 实现kafka生产者,读取卡口数据并且往kafka中生产数据: - -```scala - val prop = new Properties() - prop.setProperty("bootstrap.servers", "node01:9092,node02:9092,node03:9092") - prop.setProperty("key.serializer", classOf[StringSerializer].getName) - prop.setProperty("value.serializer", classOf[StringSerializer].getName) - - val producer = new KafkaProducer[String, String](prop) - - val iterator = Source.fromFile("data/carFlow_all_column_test.txt", "UTF-8").getLines() - for (i <- 1 to 100) { - for (line <- iterator) { - //将需要的字段值 生产到kafka集群 car_id monitor_id event-time speed - //车牌号 卡口号 车辆通过时间 通过速度 - val splits = line.split(",") - val monitorID = splits(0).replace("'","") - val car_id = splits(2).replace("'","") - val eventTime = splits(4).replace("'","") - val speed = splits(6).replace("'","") - if (!"00000000".equals(car_id)) { - val event = new StringBuilder - event.append(monitorID + "\t").append(car_id+"\t").append(eventTime + "\t").append(speed) - producer.send(new ProducerRecord[String, String]("flink-kafka", event.toString())) - } - - Thread.sleep(500) - } - } -``` - -- 实现kafka消费者: - -```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - val props = new Properties() - props.setProperty("bootstrap.servers","node01:9092,node02:9092,node03:9092") - props.setProperty("key.deserializer",classOf[StringDeserializer].getName) - props.setProperty("value.deserializer",classOf[StringDeserializer].getName) - props.setProperty("group.id","flink001") - props.getProperty("auto.offset.reset","latest") - - val stream = env.addSource(new FlinkKafkaConsumer[String]("flink-kafka", new SimpleStringSchema(),props)) - stream.map(data => { - val splits = data.split("\t") - val carFlow = CarFlow(splits(0),splits(1),splits(2),splits(3).toDouble) - (carFlow,1) - }).keyBy(_._1.monitorId) - .sum(1) - .print() - env.execute() -``` - -### Aggregations - -KeyedStream → DataStream - -Aggregations代表的是一类聚合算子,具体算子如下: - -```scala -keyedStream.sum(0) -keyedStream.sum("key") -keyedStream.min(0) -keyedStream.min("key") -keyedStream.max(0) -keyedStream.max("key") -keyedStream.minBy(0) -keyedStream.minBy("key") -keyedStream.maxBy(0) -keyedStream.maxBy("key") -``` - -代码例子:实时统计各个卡口最先通过的汽车的信息 - -```scala -val stream = env.addSource(new FlinkKafkaConsumer[String]("flink-kafka", new SimpleStringSchema(),props)) - stream.map(data => { - val splits = data.split("\t") - val carFlow = CarFlow(splits(0),splits(1),splits(2),splits(3).toDouble) - val eventTime = carFlow.eventTime - val format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") - val date = format.parse(eventTime) - (carFlow,date.getTime) - }).keyBy(_._1.monitorId) - .min(1) - .map(_._1) - .print() - env.execute() -``` - -### Union 真合并 - -DataStream → DataStream - -Union of two or more data streams creating a new stream containing all the elements from all the streams - -**合并两个或者更多的数据流产生一个新的数据流,这个新的数据流中包含了所合并的数据流的元素**。 - -注意:需要保证数据流中元素类型一致 - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment - val ds1 = env.fromCollection(List(("a",1),("b",2),("c",3))) - val ds2 = env.fromCollection(List(("d",4),("e",5),("f",6))) - val ds3 = env.fromCollection(List(("g",7),("h",8))) - val unionStream = ds1.union(ds2,ds3) - unionStream.print() - env.execute() - -输出: -("a", 1) -("b", 2) -("c", 3) -("d", 4) -("e", 5) -("f", 6) -("g", 7) -("h", 8) -``` - -### Connect 假合并 - -DataStream,DataStream → ConnectedStreams - -合并两个数据流并且保留两个数据流的数据类型,能够共享两个流的状态 - -```scala -val ds1 = env.socketTextStream("node01", 8888) -val ds2 = env.socketTextStream("node01", 9999) -val wcStream1 = ds1.flatMap(_.split(" ")).map((_,1)).keyBy(0).sum(1) -val wcStream2 = ds2.flatMap(_.split(" ")).map((_,1)).keyBy(0).sum(1) -val restStream: ConnectedStreams[(String, Int), (String, Int)] = wcStream2.connect(wcStream1) -``` - -### CoMap, CoFlatMap - -ConnectedStreams → DataStream - -CoMap, CoFlatMap并不是具体算子名字,而是一类操作名称 - -凡是基于ConnectedStreams数据流做map遍历,这类操作叫做CoMap - -凡是基于ConnectedStreams数据流做flatMap遍历,这类操作叫做CoFlatMap - -**CoMap第一种实现方式:** - -```scala -restStream.map(new CoMapFunction[(String,Int),(String,Int),(String,Int)] { - //对第一个数据流做计算 - override def map1(value: (String, Int)): (String, Int) = { - (value._1+":first",value._2+100) - } - //对第二个数据流做计算 - override def map2(value: (String, Int)): (String, Int) = { - (value._1+":second",value._2*100) - } - }).print() -``` - -**CoMap第二种实现方式:** - -```scala -restStream.map( - //对第一个数据流做计算 - x=>{(x._1+":first",x._2+100)} - //对第二个数据流做计算 - ,y=>{(y._1+":second",y._2*100)} - ).print() -``` - -代码例子:现有一个配置文件存储车牌号与车主的真实姓名,通过数据流中的车牌号实时匹配出对应的车主姓名(注意:配置文件可能实时改变) - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -env.setParallelism(1) -val filePath = "data/carId2Name" -val carId2NameStream = env.readFile(new TextInputFormat(new Path(filePath)),filePath,FileProcessingMode.PROCESS_CONTINUOUSLY,10) -val dataStream = env.socketTextStream("node01",8888) -dataStream.connect(carId2NameStream).map(new CoMapFunction[String,String,String] { - private val hashMap = new mutable.HashMap[String,String]() - override def map1(value: String): String = { - hashMap.getOrElse(value,"not found name") - } - - override def map2(value: String): String = { - val splits = value.split(" ") - hashMap.put(splits(0),splits(1)) - value + "加载完毕..." - } -}).print() -env.execute() -``` - -**CoFlatMap第一种实现方式:** - -```scala -ds1.connect(ds2).flatMap((x,c:Collector[String])=>{ - //对第一个数据流做计算 - x.split(" ").foreach(w=>{ - c.collect(w) - }) - - } - //对第二个数据流做计算 - ,(y,c:Collector[String])=>{ - y.split(" ").foreach(d=>{ - c.collect(d) - }) - }).print -``` - -**CoFlatMap第二种实现方式:** - -```scala - ds1.connect(ds2).flatMap( - //对第一个数据流做计算 - x=>{ - x.split(" ") - } - //对第二个数据流做计算 - ,y=>{ - y.split(" ") - }).print() -``` - -**CoFlatMap第三种实现方式:** - -```scala -ds1.connect(ds2).flatMap(new CoFlatMapFunction[String,String,(String,Int)] { - //对第一个数据流做计算 - override def flatMap1(value: String, out: Collector[(String, Int)]): Unit = { - val words = value.split(" ") - words.foreach(x=>{ - out.collect((x,1)) - }) - } - - //对第二个数据流做计算 - override def flatMap2(value: String, out: Collector[(String, Int)]): Unit = { - val words = value.split(" ") - words.foreach(x=>{ - out.collect((x,1)) - }) - } - }).print() -``` - -### Split - -DataStream → SplitStream - -根据条件将一个流分成两个或者更多的流 - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -val stream = env.generateSequence(1,100) -val splitStream = stream.split( - d => { - d % 2 match { - case 0 => List("even") - case 1 => List("odd") - } - } -) -splitStream.select("even").print() -env.execute() -``` - -### Select - -SplitStream → DataStream - -从SplitStream中选择一个或者多个数据流 - -```scala -splitStream.select("even").print() -``` - -### Iterate - -DataStream → IterativeStream → DataStream - -Iterate算子提供了对数据流迭代的支持 - -迭代由两部分组成:迭代体、终止迭代条件,不满足终止迭代条件的数据流会返回到stream流中,进行下一次迭代,满足终止迭代条件的数据流继续往下游发送: - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -val initStream = env.socketTextStream("node01",8888) -val stream = initStream.map(_.toLong) -stream.iterate { - iteration => { - //定义迭代逻辑 - val iterationBody = iteration.map ( x => { - println(x) - if(x > 0) x - 1 - else x - } ) - //> 0 大于0的值继续返回到stream流中,当 <= 0 继续往下游发送 - (iterationBody.filter(_ > 0), iterationBody.filter(_ <= 0)) - } -}.print() -env.execute() -``` - -### 函数类和富函数类 - -在使用Flink算子的时候,可以通过传入匿名函数和函数类对象。 - -函数类分为:普通函数类、富函数类。 - -富函数类相比于普通的函数,可以获取运行环境的上下文(Context),拥有一些生命周期方法,管理状态,可以实现更加复杂的功能 - -| 普通函数类 | 富函数类 | -| :-------------- | ------------------- | -| MapFunction | RichMapFunction | -| FlatMapFunction | RichFlatMapFunction | -| FilterFunction | RichFilterFunction | -| ...... | ...... | - -- 使用普通函数类过滤掉车速高于100的车辆信息 - -```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.readTextFile("./data/carFlow_all_column_test.txt") - stream.filter(new FilterFunction[String] { - override def filter(value: String): Boolean = { - if (value != null && !"".equals(value)) { - val speed = value.split(",")(6).replace("'", "").toLong - if (speed > 100) - false - else - true - }else - false - } - }).print() - env.execute() - -``` - -- 使用富函数类,将车牌号转化成车主真实姓名,映射表存储在Redis中 - -添加redis依赖,数据写入到redis。 - -```xml - - redis.clients - jedis - ${redis.version} - -``` - -```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.socketTextStream("node01", 8888) - stream.map(new RichMapFunction[String, String] { - - private var jedis: Jedis = _ - - //初始化函数 在每一个thread启动的时候(处理元素的时候,会调用一次) - //在open中可以创建连接redis的连接 - override def open(parameters: Configuration): Unit = { - //getRuntimeContext可以获取flink运行的上下文环境 AbstractRichFunction抽象类提供的 - val taskName = getRuntimeContext.getTaskName - val subtasks = getRuntimeContext.getTaskNameWithSubtasks - println("=========open======"+"taskName:" + taskName + "\tsubtasks:"+subtasks) - jedis = new Jedis("node01", 6379) - jedis.select(3) - } - - //每处理一个元素,就会调用一次 - override def map(value: String): String = { - val name = jedis.get(value) - if(name == null){ - "not found name" - }else - name - } - - //元素处理完毕后,会调用close方法 - //关闭redis连接 - override def close(): Unit = { - jedis.close() - } - }).setParallelism(2).print() - - env.execute() -``` - -### ProcessFunction(处理函数) - -ProcessFunction属于低层次的API,我们前面讲的map、filter、flatMap等算子都是基于这层高层封装出来的。 - -越低层次的API,功能越强大,用户能够获取的信息越多,比如可以拿到元素状态信息、事件时间、设置定时器等 - -- 代码例子:监控每辆汽车,车速超过100迈,2s钟后发出超速的警告通知: - - ```scala - object MonitorOverSpeed02 { - case class CarInfo(carId:String,speed:Long) - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.socketTextStream("node01",8888) - stream.map(data => { - val splits = data.split(" ") - val carId = splits(0) - val speed = splits(1).toLong - CarInfo(carId,speed) - }).keyBy(_.carId) - //KeyedStream调用process需要传入KeyedProcessFunction - //DataStream调用process需要传入ProcessFunction - .process(new KeyedProcessFunction[String,CarInfo,String] { - - override def processElement(value: CarInfo, ctx: KeyedProcessFunction[String, CarInfo, String]#Context, out: Collector[String]): Unit = { - val currentTime = ctx.timerService().currentProcessingTime() - if(value.speed > 100 ){ - val timerTime = currentTime + 2 * 1000 - ctx.timerService().registerProcessingTimeTimer(timerTime) - } - } - - override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[String, CarInfo, String]#OnTimerContext, out: Collector[String]): Unit = { - var warnMsg = "warn... time:" + timestamp + " carID:" + ctx.getCurrentKey - out.collect(warnMsg) - } - }).print() - - env.execute() - } - } - ``` - -### 总结 - -使用Map Filter....算子的适合,可以直接传入一个匿名函数、普通函数类对象(MapFuncation FilterFunction),富函数类对象(RichMapFunction、RichFilterFunction),传入的富函数类对象:可以拿到任务执行的上下文,生命周期方法、管理状态.....。 - -如果业务比较复杂,通过Flink提供这些算子无法满足我们的需求,通过process算子直接使用比较底层API(获取上下文、生命周期方法、测输出流、时间服务等)。 - -KeyedDataStream调用process,KeyedProcessFunction 。 - -DataStream调用process,ProcessFunction 。 - -## Sink - -Flink内置了大量sink,可以将Flink处理后的数据输出到HDFS、kafka、Redis、ES、MySQL等。 - -工程场景中,会经常消费kafka中数据,处理结果存储到Redis或者MySQL中 - -### Redis Sink - -Flink处理的数据可以存储到Redis中,以便实时查询 - -Flink内嵌连接Redis的连接器,只需要导入连接Redis的依赖就可以 - -```xml - - org.apache.bahir - flink-connector-redis_2.11 - -``` - -WordCount写入到Redis中,选择的是HSET数据类型,代码如下: - -```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.socketTextStream("node01",8888) - val result = stream.flatMap(_.split(" ")) - .map((_, 1)) - .keyBy(0) - .sum(1) - - //若redis是单机 - val config = new FlinkJedisPoolConfig.Builder().setDatabase(3).setHost("node01").setPort(6379).build() - //如果是 redis集群 - /*val addresses = new util.HashSet[InetSocketAddress]() - addresses.add(new InetSocketAddress("node01",6379)) - addresses.add(new InetSocketAddress("node01",6379)) - val clusterConfig = new FlinkJedisClusterConfig.Builder().setNodes(addresses).build()*/ - - result.addSink(new RedisSink[(String,Int)](config,new RedisMapper[(String,Int)] { - - override def getCommandDescription: RedisCommandDescription = { - new RedisCommandDescription(RedisCommand.HSET,"wc") - } - - override def getKeyFromData(t: (String, Int)) = { - t._1 - } - - override def getValueFromData(t: (String, Int)) = { - t._2 + "" - } - })) - env.execute() -``` - -### Kafka Sink - -处理结果写入到kafka topic中,Flink也是默认支持,需要添加连接器依赖,跟读取kafka数据用的连接器依赖相同,之前添加过就不需要再次添加了 - -```xml - - org.apache.flink - flink-connector-kafka_2.11 - ${flink-version} - -``` - -```scala -import java.lang -import java.util.Properties - -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment -import org.apache.flink.streaming.api.scala._ -import org.apache.flink.streaming.connectors.kafka.{FlinkKafkaProducer, KafkaSerializationSchema} -import org.apache.kafka.clients.producer.ProducerRecord -import org.apache.kafka.common.serialization.StringSerializer - -object KafkaSink { - def main(args: Array[String]): Unit = { - - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.socketTextStream("node01",8888) - val result = stream.flatMap(_.split(" ")) - .map((_, 1)) - .keyBy(0) - .sum(1) - - val props = new Properties() - props.setProperty("bootstrap.servers","node01:9092,node02:9092,node03:9092") -// props.setProperty("key.serializer",classOf[StringSerializer].getName) -// props.setProperty("value.serializer",classOf[StringSerializer].getName) - - - /** - public FlinkKafkaProducer( - FlinkKafkaProducer(defaultTopic: String, serializationSchema: KafkaSerializationSchema[IN], producerConfig: Properties, semantic: FlinkKafkaProducer.Semantic) - */ - result.addSink(new FlinkKafkaProducer[(String,Int)]("wc",new KafkaSerializationSchema[(String, Int)] { - override def serialize(element: (String, Int), timestamp: lang.Long): ProducerRecord[Array[Byte], Array[Byte]] = { - new ProducerRecord("wc",element._1.getBytes(),(element._2+"").getBytes()) - } - },props,FlinkKafkaProducer.Semantic.EXACTLY_ONCE)) - - env.execute() - } -} -``` - -### MySQL Sink - -Flink处理结果写入到MySQL中,这并不是Flink默认支持的,需要添加MySQL的驱动依赖 - -```xml - - mysql - mysql-connector-java - 5.1.44 - -``` - -因为不是内嵌支持的,所以需要基于RichSinkFunction自定义sink。 - -代码例子:消费kafka中数据,统计各个卡口的流量,并且存入到MySQL中 - -注意点:需要去重,操作MySQL需要幂等性 - -```scala -import java.sql.{Connection, DriverManager, PreparedStatement} -import java.util.Properties - -import org.apache.flink.api.common.functions.ReduceFunction -import org.apache.flink.api.common.typeinfo.TypeInformation -import org.apache.flink.configuration.Configuration -import org.apache.flink.streaming.api.functions.sink.{RichSinkFunction, SinkFunction} -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment -import org.apache.flink.streaming.api.scala._ -import org.apache.flink.streaming.connectors.kafka.{FlinkKafkaConsumer, KafkaDeserializationSchema} -import org.apache.kafka.clients.consumer.ConsumerRecord -import org.apache.kafka.common.serialization.StringSerializer - -object MySQLSink { - - case class CarInfo(monitorId: String, carId: String, eventTime: String, Speed: Long) - - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - - //设置连接kafka的配置信息 - val props = new Properties() - //注意 sparkstreaming + kafka(0.10之前版本) receiver模式 zookeeper url(元数据) - props.setProperty("bootstrap.servers", "node01:9092,node02:9092,node03:9092") - props.setProperty("group.id", "flink-kafka-001") - props.setProperty("key.deserializer", classOf[StringSerializer].getName) - props.setProperty("value.deserializer", classOf[StringSerializer].getName) - - //第一个参数 : 消费的topic名 - val stream = env.addSource(new FlinkKafkaConsumer[(String, String)]("flink-kafka", new KafkaDeserializationSchema[(String, String)] { - //什么时候停止,停止条件是什么 - override def isEndOfStream(t: (String, String)): Boolean = false - - //要进行序列化的字节流 - override def deserialize(consumerRecord: ConsumerRecord[Array[Byte], Array[Byte]]): (String, String) = { - val key = new String(consumerRecord.key(), "UTF-8") - val value = new String(consumerRecord.value(), "UTF-8") - (key, value) - } - - //指定一下返回的数据类型 Flink提供的类型 - override def getProducedType: TypeInformation[(String, String)] = { - createTuple2TypeInformation(createTypeInformation[String], createTypeInformation[String]) - } - }, props)) - - stream.map(data => { - val value = data._2 - val splits = value.split("\t") - val monitorId = splits(0) - (monitorId, 1) - }).keyBy(_._1) - .reduce(new ReduceFunction[(String, Int)] { - //t1:上次聚合完的结果 t2:当前的数据 - override def reduce(t1: (String, Int), t2: (String, Int)): (String, Int) = { - (t1._1, t1._2 + t2._2) - } - }).addSink(new MySQLCustomSink) - - env.execute() - } - - //幂等性写入外部数据库MySQL - class MySQLCustomSink extends RichSinkFunction[(String, Int)] { - var conn: Connection = _ - var insertPst: PreparedStatement = _ - var updatePst: PreparedStatement = _ - - //每来一个元素都会调用一次 - override def invoke(value: (String, Int), context: SinkFunction.Context[_]): Unit = { - println(value) - updatePst.setInt(1, value._2) - updatePst.setString(2, value._1) - updatePst.execute() - println(updatePst.getUpdateCount) - if(updatePst.getUpdateCount == 0){ - println("insert") - insertPst.setString(1, value._1) - insertPst.setInt(2, value._2) - insertPst.execute() - } - } - - //thread初始化的时候执行一次 - override def open(parameters: Configuration): Unit = { - conn = DriverManager.getConnection("jdbc:mysql://node01:3306/test", "root", "123123") - insertPst = conn.prepareStatement("INSERT INTO car_flow(monitorId,count) VALUES(?,?)") - updatePst = conn.prepareStatement("UPDATE car_flow SET count = ? WHERE monitorId = ?") - } - - //thread关闭的时候 执行一次 - override def close(): Unit = { - insertPst.close() - updatePst.close() - conn.close() - } - } - -} -``` - -### Socket Sink - -Flink处理结果发送到套接字(Socket),基于RichSinkFunction自定义sink: - -```scala -import java.io.PrintStream -import java.net.{InetAddress, Socket} -import java.util.Properties - -import org.apache.flink.api.common.functions.ReduceFunction -import org.apache.flink.api.common.typeinfo.TypeInformation -import org.apache.flink.configuration.Configuration -import org.apache.flink.streaming.api.functions.sink.{RichSinkFunction, SinkFunction} -import org.apache.flink.streaming.api.scala.{StreamExecutionEnvironment, createTuple2TypeInformation, createTypeInformation} -import org.apache.flink.streaming.connectors.kafka.{FlinkKafkaConsumer, KafkaDeserializationSchema} -import org.apache.kafka.clients.consumer.ConsumerRecord -import org.apache.kafka.common.serialization.StringSerializer - -//sink 到 套接字 socket -object SocketSink { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - - //设置连接kafka的配置信息 - val props = new Properties() - //注意 sparkstreaming + kafka(0.10之前版本) receiver模式 zookeeper url(元数据) - props.setProperty("bootstrap.servers", "node01:9092,node02:9092,node03:9092") - props.setProperty("group.id", "flink-kafka-001") - props.setProperty("key.deserializer", classOf[StringSerializer].getName) - props.setProperty("value.deserializer", classOf[StringSerializer].getName) - - //第一个参数 : 消费的topic名 - val stream = env.addSource(new FlinkKafkaConsumer[(String, String)]("flink-kafka", new KafkaDeserializationSchema[(String, String)] { - //什么时候停止,停止条件是什么 - override def isEndOfStream(t: (String, String)): Boolean = false - - //要进行序列化的字节流 - override def deserialize(consumerRecord: ConsumerRecord[Array[Byte], Array[Byte]]): (String, String) = { - val key = new String(consumerRecord.key(), "UTF-8") - val value = new String(consumerRecord.value(), "UTF-8") - (key, value) - } - - //指定一下返回的数据类型 Flink提供的类型 - override def getProducedType: TypeInformation[(String, String)] = { - createTuple2TypeInformation(createTypeInformation[String], createTypeInformation[String]) - } - }, props)) - - stream.map(data => { - val value = data._2 - val splits = value.split("\t") - val monitorId = splits(0) - (monitorId, 1) - }).keyBy(_._1) - .reduce(new ReduceFunction[(String, Int)] { - //t1:上次聚合完的结果 t2:当前的数据 - override def reduce(t1: (String, Int), t2: (String, Int)): (String, Int) = { - (t1._1, t1._2 + t2._2) - } - }).addSink(new SocketCustomSink("node01",8888)) - - env.execute() - } - - class SocketCustomSink(host:String,port:Int) extends RichSinkFunction[(String,Int)]{ - var socket: Socket = _ - var writer:PrintStream = _ - - override def open(parameters: Configuration): Unit = { - socket = new Socket(InetAddress.getByName(host), port) - writer = new PrintStream(socket.getOutputStream) - } - - override def invoke(value: (String, Int), context: SinkFunction.Context[_]): Unit = { - writer.println(value._1 + "\t" +value._2) - writer.flush() - } - - override def close(): Unit = { - writer.close() - socket.close() - } - } -} -``` - -### File Sink - -Flink处理的结果保存到文件,这种使用方式不是很常见 - -支持分桶写入,每一个桶就是一个目录,默认每隔一个小时会产生一个分桶,每个桶下面会存储每一个Thread的处理结果,可以设置一些文件滚动的策略(文件打开、文件大小等),防止出现大量的小文件。 - -Flink默认支持,导入连接文件的连接器依赖 - -```xml - - org.apache.flink - flink-connector-filesystem_2.11 - 1.9.2 - -``` - -```scala -import org.apache.flink.api.common.functions.ReduceFunction -import org.apache.flink.api.common.serialization.SimpleStringEncoder -import org.apache.flink.api.common.typeinfo.TypeInformation -import org.apache.flink.core.fs.Path -import org.apache.flink.streaming.api.functions.sink.filesystem.StreamingFileSink -import org.apache.flink.streaming.api.functions.sink.filesystem.rollingpolicies.DefaultRollingPolicy -import org.apache.flink.streaming.api.scala.{StreamExecutionEnvironment, createTuple2TypeInformation, createTypeInformation} -import org.apache.flink.streaming.connectors.kafka.{FlinkKafkaConsumer, KafkaDeserializationSchema} -import org.apache.kafka.clients.consumer.ConsumerRecord -import org.apache.kafka.common.serialization.StringSerializer - -object FileSink { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - - //设置连接kafka的配置信息 - val props = new Properties() - //注意 sparkstreaming + kafka(0.10之前版本) receiver模式 zookeeper url(元数据) - props.setProperty("bootstrap.servers", "node01:9092,node02:9092,node03:9092") - props.setProperty("group.id", "flink-kafka-001") - props.setProperty("key.deserializer", classOf[StringSerializer].getName) - props.setProperty("value.deserializer", classOf[StringSerializer].getName) - - //第一个参数 : 消费的topic名 - val stream = env.addSource(new FlinkKafkaConsumer[(String, String)]("flink-kafka", new KafkaDeserializationSchema[(String, String)] { - //什么时候停止,停止条件是什么 - override def isEndOfStream(t: (String, String)): Boolean = false - - //要进行序列化的字节流 - override def deserialize(consumerRecord: ConsumerRecord[Array[Byte], Array[Byte]]): (String, String) = { - val key = new String(consumerRecord.key(), "UTF-8") - val value = new String(consumerRecord.value(), "UTF-8") - (key, value) - } - - //指定一下返回的数据类型 Flink提供的类型 - override def getProducedType: TypeInformation[(String, String)] = { - createTuple2TypeInformation(createTypeInformation[String], createTypeInformation[String]) - } - }, props)) - - val restStream = stream.map(data => { - val value = data._2 - val splits = value.split("\t") - val monitorId = splits(0) - (monitorId, 1) - }).keyBy(_._1) - .reduce(new ReduceFunction[(String, Int)] { - //t1:上次聚合完的结果 t2:当前的数据 - override def reduce(t1: (String, Int), t2: (String, Int)): (String, Int) = { - (t1._1, t1._2 + t2._2) - } - }).map(x=>x._1 + "\t" + x._2) - - //设置文件滚动策略 - val rolling:DefaultRollingPolicy[String,String] = DefaultRollingPolicy.create() - //当文件超过2s没有写入新数据,则滚动产生一个小文件 - .withInactivityInterval(2000) - //文件打开时间超过2s 则滚动产生一个小文件 每隔2s产生一个小文件 - .withRolloverInterval(2000) - //当文件大小超过256 则滚动产生一个小文件 - .withMaxPartSize(256*1024*1024) - .build() - - /** - * 默认: - * 每一个小时对应一个桶(文件夹),每一个thread处理的结果对应桶下面的一个小文件 - * 当小文件大小超过128M或者小文件打开时间超过60s,滚动产生第二个小文件 - */ - val sink: StreamingFileSink[String] = StreamingFileSink.forRowFormat( - new Path("d:/data/rests"), - new SimpleStringEncoder[String]("UTF-8")) - .withBucketCheckInterval(1000) - .withRollingPolicy(rolling) - .build() - -// val sink = StreamingFileSink.forBulkFormat( -// new Path("./data/rest"), -// ParquetAvroWriters.forSpecificRecord(classOf[String]) -// ).build() - - restStream.addSink(sink) - env.execute() - } -} -``` - -### HBase Sink - -计算结果写入sink 两种实现方式: - -1. map算子写入,频繁创建hbase连接。 -2. process写入,适合批量写入hbase。 - -导入HBase依赖包 - -```xml - - org.apache.hbase - hbase-client - ${hbase.version} - - - org.apache.hbase - hbase-common - ${hbase.version} - - - org.apache.hbase - hbase-server - ${hbase.version} - -``` - -读取kafka数据,统计卡口流量保存至HBase数据库中 - -1. HBase中创建对应的表 - -``` -create 'car_flow',{NAME => 'count', VERSIONS => 1} -``` - -2. 实现代码 - -```scala -import java.util.{Date, Properties} - -import com.msb.stream.util.{DateUtils, HBaseUtil} -import org.apache.flink.api.common.serialization.SimpleStringSchema -import org.apache.flink.configuration.Configuration -import org.apache.flink.streaming.api.functions.ProcessFunction -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment -import org.apache.flink.streaming.api.scala._ -import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer -import org.apache.flink.util.Collector -import org.apache.hadoop.hbase.HBaseConfiguration -import org.apache.hadoop.hbase.client.{HTable, Put} -import org.apache.hadoop.hbase.util.Bytes -import org.apache.kafka.common.serialization.StringSerializer - - -object HBaseSinkTest { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - - //设置连接kafka的配置信息 - val props = new Properties() - //注意 sparkstreaming + kafka(0.10之前版本) receiver模式 zookeeper url(元数据) - props.setProperty("bootstrap.servers", "node01:9092,node02:9092,node03:9092") - props.setProperty("group.id", "flink-kafka-001") - props.setProperty("key.deserializer", classOf[StringSerializer].getName) - props.setProperty("value.deserializer", classOf[StringSerializer].getName) - - val stream = env.addSource(new FlinkKafkaConsumer[String]("flink-kafka", new SimpleStringSchema(), props)) - - - stream.map(row => { - val arr = row.split("\t") - (arr(0), 1) - }).keyBy(_._1) - .reduce((v1: (String, Int), v2: (String, Int)) => { - (v1._1, v1._2 + v2._2) - }).process(new ProcessFunction[(String, Int), (String, Int)] { - - var htab: HTable = _ - - override def open(parameters: Configuration): Unit = { - val conf = HBaseConfiguration.create() - conf.set("hbase.zookeeper.quorum", "node01:2181,node02:2181,node03:2181") - val hbaseName = "car_flow" - htab = new HTable(conf, hbaseName) - } - - override def close(): Unit = { - htab.close() - } - - override def processElement(value: (String, Int), ctx: ProcessFunction[(String, Int), (String, Int)]#Context, out: Collector[(String, Int)]): Unit = { - // rowkey:monitorid 时间戳(分钟) value:车流量 - val min = DateUtils.getMin(new Date()) - val put = new Put(Bytes.toBytes(value._1)) - put.addColumn(Bytes.toBytes("count"), Bytes.toBytes(min), Bytes.toBytes(value._2)) - htab.put(put) - } - }) - env.execute() - } -} -``` - -## 分区策略 -在 Apache Flink 中,分区(Partitioning)是将数据流按照一定的规则划分成多个子数据流或分片,以便在不同的并行任务或算子中并行处理数据。分区是实现并行计算和数据流处理的基础机制。Flink 的分区决定了数据在作业中的流动方式,以及在并行任务之间如何分配和处理数据。 - -在 Flink 中,数据流可以看作是一个有向图,图中的节点代表算子(Operators),边代表数据流(Data Streams)。数据从源算子流向下游算子,这些算子可能并行地处理输入数据,而分区就是决定数据如何从一个算子传递到另一个算子的机制。 - -### shuffle - -场景:增大分区、提高并行度,解决数据倾斜 - -DataStream → DataStream - -**分区元素随机均匀分发到下游分区,网络开销比较大** - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -val stream = env.generateSequence(1,10).setParallelism(1) -println(stream.getParallelism) -stream.shuffle.print() -env.execute() -``` - -console result:上游数据比较随意的分发到下游 - -```scala -2> 1 -1> 4 -7> 10 -4> 6 -6> 3 -5> 7 -8> 2 -1> 5 -1> 8 -1> 9 -``` - -### rebalance - -场景:增大分区、提高并行度,解决数据倾斜 - -DataStream → DataStream - -轮询分区元素,均匀的将元素分发到下游分区,下游每个分区的数据比较均匀,在发生数据倾斜时非常有用,网络开销比较大 - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -env.setParallelism(3) -val stream = env.generateSequence(1,100) -val shuffleStream = stream.rebalance -shuffleStream.print() -env.execute() -``` - -console result:上游数据比较均匀的分发到下游 - -```scala -8> 6 -3> 1 -5> 3 -7> 5 -1> 7 -2> 8 -6> 4 -4> 2 -3> 9 -4> 10 -``` - -### rescale - -场景:减少分区 防止发生大量的网络传输 不会发生全量的重分区 - -DataStream → DataStream - -通过轮询分区元素,将一个元素集合从上游分区发送给下游分区,发送单位是集合,而不是一个个元素 - -注意:rescale发生的是本地数据传输,而不需要通过网络传输数据,比如taskmanager的槽数。简单来说,上游的数据只会发送给本TaskManager中的下游。 - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -val stream = env.generateSequence(1,10).setParallelism(2) -stream.writeAsText("./data/stream1").setParallelism(2) -stream.rescale.writeAsText("./data/stream2").setParallelism(4) -env.execute() -``` - -console result:stream1:1内容分发给stream2:1和stream2:2 - -stream1:1 - -```scala -1 -3 -5 -7 -9 -``` - -stream1:2 - -```scala -2 -4 -6 -8 -10 -``` - -stream2:1 - -```scala -1 -5 -9 -``` - -stream2:2 - -```scala -3 -7 -``` - -stream2:3 - -```scala -2 -6 -10 -``` - -stream2:4 - -```scala -4 -8 -``` - -### broadcast - -场景:需要使用映射表、并且映射表会经常发生变动的场景 - -DataStream → DataStream - -上游中每一个元素内容广播到下游每一个分区中 - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -val stream = env.generateSequence(1,10).setParallelism(2) -stream.writeAsText("./data/stream1").setParallelism(2) -stream.broadcast.writeAsText("./data/stream2").setParallelism(4) -env.execute() -``` - -console result:stream1:1、2内容广播到了下游每个分区中 - -stream1:1 - -```scala -1 -3 -5 -7 -9 -``` - -stream1:2 - -```scala -2 -4 -6 -8 -10 -``` - -stream2:1 - -```scala -1 -3 -5 -7 -9 -2 -4 -6 -8 -10 -``` - -### global - -场景:并行度降为1 - -DataStream → DataStream - -上游分区的数据只分发给下游的第一个分区 - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -val stream = env.generateSequence(1,10).setParallelism(2) -stream.writeAsText("./data/stream1").setParallelism(2) -stream.global.writeAsText("./data/stream2").setParallelism(4) -env.execute() -``` - -console result:stream1:1、2内容只分发给了stream2:1 - -stream1:1 - -```scala -1 -3 -5 -7 -9 -``` - -stream1:2 - -```scala -2 -4 -6 -8 -10 -``` - -stream2:1 - -```scala -1 -3 -5 -7 -9 -2 -4 -6 -8 -10 -``` - -### forward - -场景:一对一的数据分发,map、flatMap、filter 等都是这种分区策略 - -DataStream → DataStream - -上游分区数据分发到下游对应分区中 - -partition1->partition1 - -partition2->partition2 - -注意:必须保证上下游分区数(并行度)一致,不然会有如下异常: - -```scala -Forward partitioning does not allow change of parallelism -* Upstream operation: Source: Sequence Source-1 parallelism: 2, -* downstream operation: Sink: Unnamed-4 parallelism: 4 -* stream.forward.writeAsText("./data/stream2").setParallelism(4) -``` - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -val stream = env.generateSequence(1,10).setParallelism(2) -stream.writeAsText("./data/stream1").setParallelism(2) -stream.forward.writeAsText("./data/stream2").setParallelism(2) -env.execute() -``` - -console result:stream1:1->stream2:1、stream1:2->stream2:2 - -stream1:1 - -```scala -1 -3 -5 -7 -9 -``` - -stream1:2 - -```scala -2 -4 -6 -8 -10 -``` - -stream2:1 - -```scala -1 -3 -5 -7 -9 -``` - -stream2:2 - -```scala -2 -4 -6 -8 -10 -``` - -### keyBy - -场景:与业务场景匹配 - -DataStream → DataStream - -根据上游分区元素的Hash值与下游分区数取模计算出,将当前元素分发到下游哪一个分区 - -```scala -MathUtils.murmurHash(keyHash)(每个元素的Hash值) % maxParallelism(下游分区数) -``` - -```scala -val env = StreamExecutionEnvironment.getExecutionEnvironment -val stream = env.generateSequence(1,10).setParallelism(2) -stream.writeAsText("./data/stream1").setParallelism(2) -stream.keyBy(0).writeAsText("./data/stream2").setParallelism(2) -env.execute() -``` - -console result:根据元素Hash值分发到下游分区中 - -### PartitionCustom - -DataStream → DataStream - -通过自定义的分区器,来决定元素是如何从上游分区分发到下游分区 - -```scala -object ShuffleOperator { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - env.setParallelism(2) - val stream = env.generateSequence(1,10).map((_,1)) - stream.writeAsText("./data/stream1") - stream.partitionCustom(new customPartitioner(),0) - .writeAsText("./data/stream2").setParallelism(4) - env.execute() - } - class customPartitioner extends Partitioner[Long]{ - override def partition(key: Long, numPartitions: Int): Int = { - key.toInt % numPartitions - } - } -} -``` - -## Flink State状态 - -Flink是一个有状态的流式计算引擎,所以会将中间计算结果(状态)进行保存,默认保存到TaskManager的堆内存中,但是当task挂掉,那么这个task所对应的状态都会被清空,造成了数据丢失,无法保证结果的正确性,哪怕想要得到正确结果,所有数据都要重新计算一遍,效率很低。想要保证 At -least-once 和 Exactly-once,需要把数据状态持久化到更安全的存储介质中,Flink提供了堆内内存、堆外内存、HDFS、RocksDB等存储介质。 - -先来看下Flink提供的状态有哪些,Flink中状态分为两种类型: - -- Keyed State - - 基于KeyedStream上的状态,这个状态是跟特定的Key绑定,KeyedStream流上的每一个Key都对应一个State,每一个Operator可以启动多个Thread处理,但是相同Key的数据只能由同一个Thread处理,因此一个Keyed状态只能存在于某一个Thread中,一个Thread会有多个Keyed state。 - -- Non-Keyed State(Operator State) - - Operator State与Key无关,而是与Operator绑定,整个Operator只对应一个State。比如:Flink中的Kafka Connector就使用了Operator State,它会在每个Connector实例中,保存该实例消费Topic的所有(partition, offset)映射。 - -Flink针对Keyed State提供了以下可以保存State的数据结构 - -- ValueState:类型为T的单值状态,这个状态与对应的Key绑定,最简单的状态,通过update更新值,通过value获取状态值。 -- ListState:Key上的状态值为一个列表,这个列表可以通过add方法往列表中添加值,也可以通过get()方法返回一个Iterable来遍历状态值。 -- ReducingState:每次调用add()方法添加值的时候,会调用用户传入的reduceFunction,最后合并到一个单一的状态值。 -- MapState:状态值为一个Map,用户通过put或putAll方法添加元素,get(key)通过指定的key获取value,使用entries()、keys()、values()检索。 -- AggregatingState``:保留一个单值,表示添加到状态的所有值的聚合。和 `ReducingState` 相反的是, 聚合类型可能与添加到状态的元素的类型不同。使用 `add(IN)` 添加的元素会调用用户指定的 `AggregateFunction` 进行聚合。 -- FoldingState:已过时建议使用AggregatingState 保留一个单值,表示添加到状态的所有值的聚合。 与 `ReducingState` 相反,聚合类型可能与添加到状态的元素类型不同。 使用`add(T)`添加的元素会调用用户指定的 `FoldFunction` 折叠成聚合值。 - -案例1:使用ValueState keyed state检查车辆是否发生了急加速 - -```scala -object ValueStateTest { - - case class CarInfo(carId: String, speed: Long) - - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.socketTextStream("node01", 8888) - stream.map(data => { - val arr = data.split(" ") - CarInfo(arr(0), arr(1).toLong) - }).keyBy(_.carId) - .map(new RichMapFunction[CarInfo, String]() { - - //保存上一次车速 - private var lastTempState: ValueState[Long] = _ - - override def open(parameters: Configuration): Unit = { - val lastTempStateDesc = new ValueStateDescriptor[Long]("lastTempState", createTypeInformation[Long]) - lastTempState = getRuntimeContext.getState(lastTempStateDesc) - } - - override def map(value: CarInfo): String = { - val lastSpeed = lastTempState.value() - this.lastTempState.update(value.speed) - if ((value.speed - lastSpeed).abs > 30 && lastSpeed != 0) - "over speed" + value.toString - else - value.carId - } - }).print() - env.execute() - } -} -``` - -案例2:使用 MapState 统计单词出现次数 - -```scala -import org.apache.flink.api.common.functions.RichMapFunction -import org.apache.flink.api.common.state.{MapState, MapStateDescriptor} -import org.apache.flink.configuration.Configuration -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment -import org.apache.flink.streaming.api.scala._ - -//MapState 实现 WordCount -object KeyedStateTest { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.fromCollection(List("I love you","hello spark","hello flink","hello hadoop")) - val pairStream = stream.flatMap(_.split(" ")).map((_,1)).keyBy(_._1) - pairStream.map(new RichMapFunction[(String,Int),(String,Int)] { - - private var map:MapState[String,Int] = _ - override def open(parameters: Configuration): Unit = { - //定义map state存储的数据类型 - val desc = new MapStateDescriptor[String,Int]("sum",createTypeInformation[String],createTypeInformation[Int]) - //注册map state - map = getRuntimeContext.getMapState(desc) - } - - override def map(value: (String, Int)): (String, Int) = { - val key = value._1 - val v = value._2 - if(map.contains(key)){ - map.put(key,map.get(key) + 1) - }else{ - map.put(key,1) - } - val iterator = map.keys().iterator() - while (iterator.hasNext){ - val key = iterator.next() - println("word:" + key + "\t count:" + map.get(key)) - } - value - } - }).setParallelism(3) - env.execute() - } -} - -``` - -案例3:使用ReducingState统计每辆车的速度总和 - -```scala -import com.msb.state.ValueStateTest.CarInfo -import org.apache.flink.api.common.functions.{ReduceFunction, RichMapFunction} -import org.apache.flink.api.common.state.{ReducingState, ReducingStateDescriptor} -import org.apache.flink.configuration.Configuration -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment -import org.apache.flink.streaming.api.scala._ - -//统计每辆车的速度总和 -object ReduceStateTest { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.socketTextStream("node01", 8888) - stream.map(data => { - val arr = data.split(" ") - CarInfo(arr(0), arr(1).toLong) - }).keyBy(_.carId) - .map(new RichMapFunction[CarInfo, CarInfo] { - private var reduceState: ReducingState[Long] = _ - - override def map(elem: CarInfo): CarInfo = { - reduceState.add(elem.speed) - println("carId:" + elem.carId + " speed count:" + reduceState.get()) - elem - } - - override def open(parameters: Configuration): Unit = { - val reduceDesc = new ReducingStateDescriptor[Long]("reduceSpeed", new ReduceFunction[Long] { - override def reduce(value1: Long, value2: Long): Long = value1 + value2 - }, createTypeInformation[Long]) - reduceState = getRuntimeContext.getReducingState(reduceDesc) - } - }) - env.execute() - } -} -``` - -案例4:使用AggregatingState统计每辆车的速度总和 - -```scala -import com.msb.state.ValueStateTest.CarInfo -import org.apache.flink.api.common.functions.{AggregateFunction, ReduceFunction, RichMapFunction} -import org.apache.flink.api.common.state.{AggregatingState, AggregatingStateDescriptor, ReducingState, ReducingStateDescriptor} -import org.apache.flink.configuration.Configuration -import org.apache.flink.streaming.api.scala.StreamExecutionEnvironment -import org.apache.flink.streaming.api.scala._ - -//统计每辆车的速度总和 -object ReduceStateTest { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - val stream = env.socketTextStream("node01", 8888) - stream.map(data => { - val arr = data.split(" ") - CarInfo(arr(0), arr(1).toLong) - }).keyBy(_.carId) - .map(new RichMapFunction[CarInfo, CarInfo] { - private var aggState: AggregatingState[Long,Long] = _ - - override def map(elem: CarInfo): CarInfo = { - aggState.add(elem.speed) - println("carId:" + elem.carId + " speed count:" + aggState.get()) - elem - } - - override def open(parameters: Configuration): Unit = { - val aggDesc = new AggregatingStateDescriptor[Long,Long,Long]("agg",new AggregateFunction[Long,Long,Long] { - //初始化累加器值 - override def createAccumulator(): Long = 0 - - //往累加器中累加值 - override def add(value: Long, acc: Long): Long = acc + value - - //返回最终结果 - override def getResult(accumulator: Long): Long = accumulator - - //合并两个累加器值 - override def merge(a: Long, b: Long): Long = a+b - },createTypeInformation[Long]) - - aggState = getRuntimeContext.getAggregatingState(aggDesc) - } - }) - env.execute() - } -} -``` - -### CheckPoint & SavePoint - -有状态流应用中的检查点(checkpoint),其实就是所有任务的状态在某个时间点的一个快照(一份拷贝)。简单来讲,就是一次“存盘”,让我们之前处理数据的进度不要丢掉。在一个流应用程序运行时,Flink 会定期保存检查点,在检查点中会记录每个算子的 id 和状态;如果发生故障,Flink 就会用最近一次成功保存的检查点来恢复应用的状态,重新启动处理流程,就如同“读档”一样。 - -默认情况下,检查点是被禁用的,需要在代码中手动开启。直接调用执行环境的enableCheckpointing()方法就可以开启检查点。 - -```Java -StreamExecutionEnvironment env = StreamExecutionEnvironment.getEnvironment(); -env.enableCheckpointing(1000); -``` - -这里传入的参数是检查点的间隔时间,单位为毫秒。 - -除了检查点之外,Flink 还提供了“保存点”(savepoint)的功能。保存点在原理和形式上跟检查点完全一样,也是状态持久化保存的一个快照;**保存点与检查点最大的区别,就是触发的时机。检查点是由 Flink 自动管理的,定期创建,发生故障之后自动读取进行恢复,这是一个“自动存盘”的功能;而保存点不会自动创建,必须由用户明确地手动触发保存操作,所以就是“手动存盘”。因此两者尽管原理一致,但用途就有所差别了:检查点主要用来做故障恢复,是容错机制的核心;保存点则更加灵活,可以用来做有计划的手动备份和恢复**。 - -检查点具体的持久化存储位置,取决于“检查点存储”(CheckpointStorage)的设置。默认情况下,检查点存储在 JobManager 的堆(heap)内存中。而对于大状态的持久化保存,Flink也提供了在其他存储位置进行保存的接口,这就是 CheckpointStorage。具体可以通过调用检查点配置的 setCheckpointStorage()来配置,需要传入一个CheckpointStorage 的实现类。Flink 主要提供了两种 CheckpointStorage:作业管理器的堆内存(JobManagerCheckpointStorage)和文件系统(FileSystemCheckpointStorage)。对于实际生产应用,我们一般会将 CheckpointStorage 配置为高可用的分布式文件系统(HDFS,S3 等)。 - -Flink中基于异步轻量级的分布式快照技术提供了Checkpoint容错机制,分布式快照可以将同一时间点Task/Operator的状态数据全局统一快照处理,包括上面提到的用户自定义使用的Keyed State和Operator State,当未来程序出现问题,可以基于保存的快照容错。 - -#### CheckPoint原理 - -Flink会在输入的数据集上间隔性地生成checkpoint barrier,通过栅栏(barrier)将间隔时间段内的数据划分到相应的checkpoint中。当程序出现异常时,Operator就能够从上一次快照中恢复所有算子之前的状态,从而保证数据的一致性。例如在KafkaConsumer算子中维护offset状态,当系统出现问题无法从Kafka中消费数据时,可以将offset记录在状态中,当任务重新恢复时就能够从指定的偏移量开始消费数据。 - -默认情况Flink不开启检查点,用户需要在程序中通过调用方法配置和开启检查点,另外还可以调整其他相关参数 - -- Checkpoint开启和时间间隔指定 - - 开启检查点并且指定检查点时间间隔为1000ms,根据实际情况自行选择,如果状态比较大,则建议适当增加该值 - - ```scala - env.enableCheckpointing(1000) - ``` - -- exactly-ance和at-least-once语义选择 - - 选择exactly-once语义保证整个应用内端到端的数据一致性,这种情况比较适合于数据要求比较高,不允许出现丢数据或者数据重复,与此同时,Flink的性能也相对较弱,而at-least-once语义更适合于时廷和吞吐量要求非常高但对数据的一致性要求不高的场景。如下通过setCheckpointingMode()方法来设定语义模式,默认情况下使用的是exactly-once模式 - - ```scala - env.getCheckpointConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE) - ``` - -- Checkpoint超时时间 - - 超时时间指定了每次Checkpoint执行过程中的上限时间范围,一旦Checkpoint执行时间超过该阈值,Flink将会中断Checkpoint过程,并按照超时处理。该指标可以通过setCheckpointTimeout方法设定,默认为10分钟 - - ```scala - env.getCheckpointConfig.setCheckpointTimeout(5 * 60 * 1000) - ``` - -- Checkpoint之间最小时间间隔 - - 该参数主要目的是设定两个Checkpoint之间的最小时间间隔,防止Flink应用密集地触发Checkpoint操作,会占用了大量计算资源而影响到整个应用的性能 - - ```scala - env.getCheckpointConfig.setMinPauseBetweenCheckpoints(600) - ``` - -- 最大并行执行的Checkpoint数量 - - 在默认情况下只有一个检查点可以运行,根据用户指定的数量可以同时触发多个Checkpoint,进而提升Checkpoint整体的效率 - - ```scala - env.getCheckpointConfig.setMaxConcurrentCheckpoints(1) - ``` - -- 任务取消后,是否删除Checkpoint中保存的数据 - - 设置为RETAIN_ON_CANCELLATION:表示一旦Flink处理程序被cancel后,会保留CheckPoint数据,以便根据实际需要恢复到指定的CheckPoint - - 设置为DELETE_ON_CANCELLATION:表示一旦Flink处理程序被cancel后,会删除CheckPoint数据,只有Job执行失败的时候才会保存CheckPoint - - ```scala - env.getCheckpointConfig.enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION) - ``` - -- 容忍的检查的失败数 - - 设置可以容忍的检查的失败数,超过这个数量则系统自动关闭和停止任务 - - ```scala - env.getCheckpointConfig.setTolerableCheckpointFailureNumber(1) - ``` - -#### SavePoint原理 - -Savepoints 是检查点的一种特殊实现,底层实现其实也是使用Checkpoints的机制。Savepoints是用户以手工命令的方式触发Checkpoint,并将结果持久化到指定的存储路径中,其主要目的是帮助用户在升级和维护集群过程中保存系统中的状态数据,避免因为停机运维或者升级应用等正常终止应用的操作而导致系统无法恢复到原有的计算状态的情况,从而无法实现从端到端的 Excatly-Once 语义保证。 - -要使用Savepoints,需要按照以下步骤进行: - -1. 配置状态后端: 在Flink中,状态可以保存在不同的后端存储中,例如内存、文件系统或分布式存储系统(如HDFS)。要启用Savepoint,您需要在Flink配置文件中配置合适的状态后端。通常,使用分布式存储系统作为状态后端是比较常见的做法,因为它可以提供更好的可靠性和容错性。 - -2. 生成Savepoint: 在您的Flink应用程序运行时,可以通过以下方式手动触发生成Savepoint: - - ```bash - bin/flink savepoint [targetDirectory] - ``` - - 其中,``是您要保存状态的Flink作业的Job ID,`[targetDirectory]`是可选的目标目录,用于保存Savepoint数据。如果没有提供`targetDirectory`,Savepoint将会保存到Flink配置中所配置的状态后端中。 - -3. 恢复Savepoint: 要恢复到Savepoint状态,可以通过以下方式提交作业: - - ```bash - bin/flink run -s :savepointPath [:runArgs] - ``` - - 其中,`savepointPath`是之前生成的Savepoint的路径,`runArgs`是您提交作业时的其他参数。 - -4. 确保应用程序状态的兼容性: 在使用Savepoints时,应用程序的状态结构和代码必须与生成Savepoint的版本保持兼容。这意味着在更新应用程序代码后,可能需要做一些额外的工作来保证状态的向后兼容性,以便能够成功恢复到旧的Savepoint。 - -### StateBackend状态后端 - -在Flink中提供了StateBackend来存储和管理状态数据 - -Flink一共实现了三种类型的状态管理器:MemoryStateBackend、FsStateBackend、RocksDBStateBackend - -#### MemoryStateBackend - -基于内存的状态管理器将状态数据全部存储在JVM堆内存中。基于内存的状态管理具有非常快速和高效的特点,但也具有非常多的限制,最主要的就是内存的容量限制,一旦存储的状态数据过多就会导致系统内存溢出等问题,从而影响整个应用的正常运行。同时如果机器出现问题,整个主机内存中的状态数据都会丢失,进而无法恢复任务中的状态数据。因此从数据安全的角度建议用户尽可能地避免在生产环境中使用MemoryStateBackend。 - -Flink将MemoryStateBackend作为默认状态后端管理器 - -```scala -env.setStateBackend(new MemoryStateBackend(100*1024*1024)) -``` - -注意:聚合类算子的状态会同步到JobManager内存中,因此对于聚合类算子比较多的应用会对JobManager的内存造成一定的压力,进而影响集群。 - -#### FsStateBackend - -和MemoryStateBackend有所不同,FsStateBackend是基于文件系统的一种状态管理器,这里的文件系统可以是本地文件系统,也可以是HDFS分布式文件系统 - -``` -env.setStateBackend(new FsStateBackend("path",true)) -``` - -如果path是本地文件路径,其格式:file:/// - -如果path是HDFS文件路径,格式为:hdfs:// - -第二个参数代表是否异步保存状态数据到HDFS,异步方式能够尽可能避免checkpoint的过程中影响流式计算任务。FsStateBackend更适合任务量比较大的应用,例如:包含了时间范围非常长的窗口计算,或者状态比较大的场景。 - -#### RocksDBStateBackend - -RocksDBStateBackend是Flink中内置的第三方状态管理器,和前面的状态管理器不同,RocksDBStateBackend需要单独引入相关的依赖包到工程中。 - -```maven - - org.apache.flink - flink-statebackend-rocksdb_2.11 - 1.9.2 - -``` - -```scala -env.setStateBackend(new RocksDBStateBackend("hdfs://")) -``` - -RocksDBStateBackend采用异步的方式进行状态数据的Snapshot,任务中的状态数据首先被写入本地RockDB中,这样在RockDB仅会存储正在进行计算的热数据,而需要进行CheckPoint的时候,会把本地的数据直接复制到远端的FileSystem中。 - -与FsStateBackend相比,RocksDBStateBackend在性能上要比FsStateBackend高一些,主要是因为借助于RocksDB在本地存储了最新热数据,然后通过异步的方式再同步到文件系统中,但RocksDBStateBackend和MemoryStateBackend相比性能就会较弱一些。RocksDB克服了State受内存限制的缺点,同时又能够持久化到远端文件系统中,推荐在生产中使用。 - -#### 集群级配置StateBackend - -全局配置需要需改集群中的配置文件,修改flink-conf.yaml - -- 配置FsStateBackend - -``` -state.backend: filesystem -state.checkpoints.dir: hdfs://namenode-host:port/flink-checkpoints -``` - -- 配置MemoryStateBackend - -``` -state.backend: jobmanager -``` - -- 配置RocksDBStateBackend - -``` - state.backend.rocksdb.checkpoint.transfer.thread.num: 1 同时操作RocksDB的线程数 - state.backend.rocksdb.localdir: 本地path RocksDB存储状态数据的本地文件路径 -``` - -## Window - -在流处理中,我们往往需要面对的是连续不断、无休无止的无界流,不可能等到所有数据都到齐了才开始处理。所以聚合计算其实在实际应用中,我们往往更关心一段时间内数据的统计结果,比如在过去的 1 分钟内有多少用户点击了网页。在这种情况下,我们就可以定义一个窗口,收集最近一分钟内的所有用户点击数据,然后进行聚合统计,最终输出一个结果就可以了。 - -**说白了窗口就是将无界流通过窗口切割成一个个的有界流,窗口是左开右闭的**。 - -**Flink中的窗口分为两类:基于时间的窗口(Time-based Window)和基于数量的窗口(Count-based Window)**。 - -- 时间窗口(Time Window):按照时间段去截取数据,这在实际应用中最常见。 -- 计数窗口(Count Window):由数据驱动,也就是说按照固定的个数,来截取一段数据集。 - -时间窗口中又包含了:**滚动时间窗口(Tumbling Window)、滑动时间窗口(Sliding Window)、会话窗口(Session Window)**。 - -计数窗口包含了:**滚动计数窗口和滑动计数窗口**。 - -时间窗口、计数窗口只是对窗口的一个大致划分。在具体应用时,还需要定义更加精细的规则,来控制数据应该划分到哪个窗口中去。不同的分配数据的方式,就可以由不同的功能应用。 - -根据分配数据的规则,窗口的具体实现可以分为 4 类:滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)、会话窗口(Session Window),以及全局窗口(Global Window)。 - -### 滚动窗口(Tumbling Windows) - -滚动窗口每个窗口的大小固定,且相邻两个窗口之间没有重叠。滚动窗口可以基于时间定义,也可以基于数据个数定义;需要的参数只有窗口大小,我们可以定义一个长度为1小时的滚动时间窗口,那么每个小时就会进行一次统计;或者定义一个长度为10的滚动计数窗口,就会每10个数进行一次统计。 - -基于时间的滚动窗口: - -```java -DataStream input = ... -// tumbling event-time windows -input - .keyBy(...) - .window(TumblingEventTimeWindows.of(Time.seconds(5))) - . (...) - -// tumbling processing-time windows -input - .keyBy(...) - .window(TumblingProcessingTimeWindows.of(Time.seconds(5))) - . (...) -``` - -在上面的代码中,我们使用了`TumblingEventTimeWindows`和`TumblingProcessingTimeWindows`来创建基于Event Time或Processing Time的滚动时间窗口。窗口的长度可以用`org.apache.flink.streaming.api.windowing.time.Time`中的`seconds`、`minutes`、`hours`和`days`来设置。 - -基于计数的滚动窗口: - -```java -import org.apache.flink.api.common.functions.ReduceFunction; -import org.apache.flink.streaming.api.datastream.DataStream; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; - -public class TumblingCountWindowExample { - public static void main(String[] args) throws Exception { - StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); - - DataStream input = env.fromElements(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L); - - input - .keyBy(value -> 1) - .countWindow(3) - .reduce(new ReduceFunction() { - @Override - public Long reduce(Long value1, Long value2) throws Exception { - return value1 + value2; - } - }) - .print(); - - env.execute(); - } -} -``` - -在上面的代码中,我们使用了`countWindow`方法来创建一个基于数量的滚动窗口,窗口大小为3个元素。当窗口中的元素数量达到3时,窗口就会触发计算。在这个例子中,我们使用了`reduce`函数来对窗口中的元素进行求和。 - -### 滑动窗口(Sliding Windows) - -滑动窗口的大小固定,但窗口之间不是首尾相接,而有部分重合。同样,滑动窗口也可以基于时间和计算定义。 - -滑动窗口的参数有两个:**窗口大小和滑动步长。滑动步长是固定的**。 - -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnIpzib7JiaDHLjvtsZnfQWtEeuYhwFF04QvTRmK6FcXaBshE5c8QBYBg7SaMfzTPmqFMQY8lXWuNQ/640?wx_fmt=png) - -基于时间的滑动窗口: - -```java -DataStream input = ... -// sliding event-time windows -input - .keyBy(...) - .window(SlidingEventTimeWindows.of(Time.seconds(10), Time.seconds(5))) - . (...) -``` - -基于计数的滑动窗口: - -```java -DataStream input = ... -input - .keyBy(...) - .countWindow(10, 5) - . (...) -``` - -`countWindow`方法来创建一个基于计数的滑动窗口,窗口大小为10个元素,滑动步长为5个元素。当窗口中的元素数量达到10时,窗口就会触发计算。 - -### 会话窗口(Session Windows) - -会话窗口是Flink中一种基于时间的窗口类型,每个窗口的大小不固定,且相邻两个窗口之间没有重叠。**“会话”终止的标志就是隔一段时间没有数据来**: - -```java -import org.apache.flink.streaming.api.windowing.assigners.EventTimeSessionWindows; -import org.apache.flink.streaming.api.windowing.time.Time; - -DataStream input = ... -input - .keyBy(...) - .window(EventTimeSessionWindows.withGap(Time.minutes(10))) - . (...) -``` - -在上面的代码中,使用了`EventTimeSessionWindows`来创建基于Event Time的会话窗口。`withGap`方法用来设置会话窗口之间的间隔时间,当两个元素之间的时间差超过这个值时,它们就会被分配到不同的会话窗口中。 - -### 按键分区窗口和非按键分区窗口 - -在Flink中,数据流可以按键分区(keyed)或非按键分区(non-keyed)。按键分区是指将数据流根据特定的键值进行分区,使得相同键值的元素被分配到同一个分区中。这样可以保证相同键值的元素由同一个worker实例处理。只有按键分区的数据流才能使用键分区状态和计时器。 - -非按键分区是指数据流没有根据特定的键值进行分区。这种情况下,数据流中的元素可以被任意分配到不同的分区中。 - -在定义窗口操作之前,首先需要确定,到底是基于按键分区(Keyed)来开窗,还是直接在没有按键分区的DataStream上开窗。也就是在调用窗口算子之前是否有keyBy操作。 - -按键分区窗口: - -```java -import org.apache.flink.api.common.functions.ReduceFunction; -import org.apache.flink.streaming.api.datastream.DataStream; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows; -import org.apache.flink.streaming.api.windowing.time.Time; - -public class KeyedWindowExample { - public static void main(String[] args) throws Exception { - StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); - - DataStream input = env.fromElements(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L); - - input - .keyBy(value -> 1) - .window(TumblingEventTimeWindows.of(Time.seconds(5))) - .reduce(new ReduceFunction() { - @Override - public Long reduce(Long value1, Long value2) throws Exception { - return value1 + value2; - } - }) - .print(); - - env.execute(); - } -} -``` - -在上面的代码中,使用了`keyBy`方法来对数据流进行按键分区,然后使用`window`方法来创建一个基于Event Time的滚动时间窗口。在这个例子中,我们使用了`reduce`函数来对窗口中的元素进行求和。 - -非按键分区窗口: - -```java -import org.apache.flink.api.common.functions.ReduceFunction; -import org.apache.flink.streaming.api.datastream.AllWindowedStream; -import org.apache.flink.streaming.api.datastream.DataStream; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows; -import org.apache.flink.streaming.api.windowing.time.Time; - -public class NonKeyedWindowExample { - public static void main(String[] args) throws Exception { - StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); - - DataStream input = env.fromElements(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L); - - AllWindowedStream windowedStream = input.windowAll(TumblingEventTimeWindows.of(Time.seconds(5))); - - windowedStream.reduce(new ReduceFunction() { - @Override - public Long reduce(Long value1, Long value2) throws Exception { - return value1 + value2; - } - }).print(); - - env.execute(); - } -} -``` - -在上面的代码中,使用了`windowAll`方法来对非按键分区的数据流进行窗口操作。`windowAll`方法接受一个`WindowAssigner`参数,用来指定窗口类型。然后使用了`reduce`函数来对窗口中的元素进行求和。 - -按键分区窗口(Keyed Windows)经过按键分区keyBy操作后,数据流会按照key被分为多条逻辑流(logical streams),这就是KeyedStream。基于KeyedStream进行窗口操作时,窗口计算会在多个并行子任务上同时执行。相同key的数据会被发送到同一个并行子任务,而窗口操作会基于每个key进行单独的处理。所以可以认为,每个key上都定义了一组窗口,各自独立地进行统计计算。 - -非按键分区(Non-Keyed Windows)如果没有进行keyBy,那么原始的DataStream就不会分成多条逻辑流。这时窗口逻辑只能在一个任务(task)上执行,就相当于并行度变成了1。所以在实际应用中一般不推荐使用这种方式 - -### 窗口函数(WindowFunction) - -所谓的“窗口函数”(window functions),就是定义窗口如何进行计算的操作。 - -窗口函数根据处理的方式可以分为两类:增量聚合函数和全量聚合函数。 - -#### 增量聚合函数 - -增量聚合函数每来一条数据就立即进行计算,中间保持着聚合状态;但是不立即输出结果。等到窗口到了结束时间需要输出计算结果的时候,取出之前聚合的状态直接输出。 - -常见的增量聚合的函数有:reduce(reduceFunction)、aggregate(aggregateFunction)、sum()、min()、max()。 - -下面是一个使用增量聚合函数的Java代码示例: -```java -DataStream> input = ... -input.keyBy(new KeySelector, String>() { - @Override - public String getKey(Tuple2 value) throws Exception { - return value.f0; - } - }) - .timeWindow(Time.seconds(5)) - .reduce(new ReduceFunction>() { - @Override - public Tuple2 reduce(Tuple2 t0, Tuple2 t1) throws Exception { - return new Tuple2<>(t0.f0, t0.f1 + t1.f1); - } - }); -``` - -这段代码首先使用`keyBy`方法按照Tuple2中的第一个元素(f0)进行分组。然后,它定义了一个5秒的时间窗口,并使用`reduce`方法对每个窗口内的数据进行聚合操作。在这个例子中,聚合操作是将具有相同key(即f0相同)的元素的第二个元素(f1)相加。最终,这段代码将输出一个包含每个key在每个5秒窗口内f1值之和的数据流。 - -另外还有一个常用的函数是**聚合函数(AggregateFunction)**,ReduceFunction和AggregateFunction都是增量聚合函数,但它们之间有一些区别。AggregateFunction则更加灵活,ReduceFunction的输入类型、输出类型和中间状态类型必须相同,而AggregateFunction则允许这三种类型不同。 - -**例如,如果我们希望计算一组数据的平均值,应该怎样做聚合呢?这时我们需要计算两个状态量:数据的总和(sum),以及数据的个数(count),而最终输出结果是两者的商(sum/count)。如果用ReduceFunction,那么我们应该先把数据转换成二元组 (sum, count)的形式,然后进行归约聚合,最后再将元组的两个元素相除转换得到最后的平均值。本来应该只是一个任务,可我们却需要 map-reduce-map 三步操作,这显然不够高效。而使用AggregateFunction则可以更加简单地实现这个需求**。 - -下面是使用AggregateFunction计算平均值的代码示例: -```java -DataStream> input = ... -input - .keyBy(new KeySelector, String>() { - @Override - public String getKey(Tuple2 value) throws Exception { - return value.f0; - } - }) - .window(TumblingEventTimeWindows.of(Time.seconds(5))) - .aggregate(new AggregateFunction, Tuple2, Double>() { - @Override - public Tuple2 createAccumulator() { - return new Tuple2<>(0.0, 0); - } - - @Override - public Tuple2 add(Tuple2 value, Tuple2 accumulator) { - return new Tuple2<>(accumulator.f0 + value.f1, accumulator.f1 + 1); - } - - @Override - public Double getResult(Tuple2 accumulator) { - return accumulator.f0 / accumulator.f1; - } - - @Override - public Tuple2 merge(Tuple2 a, Tuple2 b) { - return new Tuple2<>(a.f0 + b.f0, a.f1 + b.f1); - } - }); -``` - -这段代码首先使用`keyBy`方法按照Tuple2中的第一个元素(f0)进行分组。然后,它定义了一个5秒的翻滚事件时间窗口,并使用`aggregate`方法对每个窗口内的数据进行聚合操作。在这个例子中,聚合操作是计算具有相同key(即f0相同)的元素的第二个元素(f1)的平均值。最终,这段代码将输出一个包含每个key在每个5秒窗口内f1值平均值的数据流。 - -#### 全量聚合函数 - -全量聚合函数(Full Window Functions)是指在整个窗口中的所有数据都准备好后才进行计算。Flink中的全窗口函数有两种:**WindowFunction和ProcessWindowFunction**。 - -与增量聚合函数不同,全窗口函数可以访问窗口中的所有数据,因此可以执行更复杂的计算。例如,可以计算窗口中数据的中位数,或者对窗口中的数据进行排序。 - -WindowFunction接收一个Iterable类型的输入,其中包含了窗口中所有的数据。ProcessWindowFunction则更加强大,它不仅可以访问窗口中的所有数据, 还可以获取到一个“上下文对象”(Context)。这个上下文对象非常强大,不仅能够获取窗口信息,还可以访问当前的时间和状态信息。这里的时间就包括了处理时间(processing time)和事件时间水位线(event time watermark)。这就使得 ProcessWindowFunction 更加灵活、功能更加丰富。WindowFunction作用可以被 ProcessWindowFunction 全覆盖。**一般在实际应用,用 ProcessWindowFunction比较多,直接使用 ProcessWindowFunction 就可以了**。 - -下面是使用WindowFunction计算窗口内数据总和的代码示例: - -```java -public class SumWindowFunction extends WindowFunction, Tuple2, String, TimeWindow> { - @Override - public void apply(String key, TimeWindow window, Iterable> input, Collector> out) throws Exception { - int sum = 0; - for (Tuple2 value : input) { - sum += value.f1; - } - out.collect(new Tuple2<>(key, sum)); - } -} - -DataStream> input = ... -input.keyBy(new KeySelector, String>() { - @Override - public String getKey(Tuple2 value) throws Exception { - return value.f0; - } - }) - .window(TumblingEventTimeWindows.of(Time.seconds(5))) - .apply(new SumWindowFunction()); -``` - -下面是一个使用ProcessWindowFunction统计网站1天UV的代码示例。在这个例子中,我们使用了状态来存储每个窗口中访问过网站的用户ID,以便在窗口结束时计算UV。此外,我们还使用了定时器,在窗口结束时触发计算UV的操作。我们还使用了context对象来获取窗口的开始时间和结束时间,并将它们输出到结果中: - -```java -public class UVProcessWindowFunction extends ProcessWindowFunction, Tuple3, String, TimeWindow> { - private ValueState> userIdState; // 状态,用来存储每个窗口中访问过网站的用户ID - - @Override - public void open(Configuration parameters) throws Exception { - super.open(parameters); - // 初始化状态 - ValueStateDescriptor> stateDescriptor = new ValueStateDescriptor<>("userIdState", new SetTypeInfo<>(Types.STRING)); - userIdState = getRuntimeContext().getState(stateDescriptor); - } - - @Override - public void process(String key, Context context, Iterable> input, Collector> out) throws Exception { - Set userIds = userIdState.value(); - if (userIds == null) { - userIds = new HashSet<>(); - } - for (Tuple2 value : input) { - userIds.add(value.f0); // 将用户ID添加到状态中 - } - userIdState.update(userIds); - context.timerService().registerEventTimeTimer(context.window().getEnd()); // 注册定时器,在窗口结束时触发计算UV的操作 - } - - @Override - public void onTimer(long timestamp, OnTimerContext ctx, Collector> out) throws Exception { - super.onTimer(timestamp, ctx, out); - Set userIds = userIdState.value(); - if (userIds != null) { - long windowStart = ctx.window().getStart(); - out.collect(new Tuple3<>(ctx.getCurrentKey(), windowStart, userIds.size())); // 计算UV并输出结果,包括窗口的开始时间和结束时间 - userIdState.clear(); // 清空状态 - } - } -} - -DataStream> input = ... // 输入数据流,其中第一个字段为用户ID,第二个字段为网站URL -input.keyBy(new KeySelector, String>() { - @Override - public String getKey(Tuple2 value) throws Exception { - return value.f1; // 按照网站URL分组 - } - }) - .window(TumblingEventTimeWindows.of(Time.days(1))) // 设置窗口大小为1天 - .process(new UVProcessWindowFunction()); -``` - -#### 增量聚合函数和全量聚合函数结合使用 - -全窗口函数需要先收集窗口中的数据,并在内部缓存起来,等到窗口要输出结果的时候再取出数据进行计算。所以运行效率较低,很少直接单独使用,往往会和增量聚合函数结合在一起,共同实现窗口的处理计算。 - -增量聚合的优点:高效,输出更加实时。增量聚合相当于把计算量“均摊”到了窗口收集数据的过程中,自然就会比全窗口聚合更加高效、输出更加实时。 - -全窗口的优点:提供更多的信息,可以认为是更加“通用”的窗口操作。 - 它只负责收集数据、提供上下文相关信息,把所有的原材料都准备好,至于拿来做什么我们完全可以任意发挥。这就使得窗口计算更加灵活,功能更加强大。 - -在实际应用中,我们往往希望兼具这两者的优点,把它们结合在一起使用。Flink 的Window API 就给我们实现了这样的用法。 - -之前在调用 WindowedStream 的.reduce()和.aggregate()方法时,只是简单地直接传入了一个 ReduceFunction 或 AggregateFunction 进行增量聚合。除此之外,其实还可以传入第二个参数:一个全窗口函数,可以是 WindowFunction 或者ProcessWindowFunction。 - -```xml -// ReduceFunction 与 WindowFunction 结合 -public SingleOutputStreamOperator reduce(ReduceFunction reduceFunction, WindowFunction function) - -// ReduceFunction 与 ProcessWindowFunction 结合 -public SingleOutputStreamOperator reduce(ReduceFunction reduceFunction, ProcessWindowFunction function) - -// AggregateFunction 与 WindowFunction 结合 -public SingleOutputStreamOperator aggregate(AggregateFunction aggFunction, WindowFunction windowFunction) - -// AggregateFunction 与 ProcessWindowFunction 结合 -public SingleOutputStreamOperator aggregate(AggregateFunction aggFunction, ProcessWindowFunction windowFunction) -``` - -这样调用的处理机制是:基于第一个参数(增量聚合函数)来处理窗口数据,每来一个数据就做一次聚合;等到窗口需要触发计算时,则调用第二个参数(全窗口函数)的处理逻辑输出结果。**需要注意的是,这里的全窗口函数就不再缓存所有数据了,而是直接将增量聚合函数的结果拿来当作了 Iterable 类型的输入。一般情况下,这时的可迭代集合中就只有一个元素了**。 - -下面我们举一个具体的实例来说明。在网站的各种统计指标中,一个很重要的统计指标就是热门的链接,想要得到热门的 url,前提是得到每个链接的“热门度”。一般情况下,可以用url 的浏览量(点击量)表示热门度。我们这里统计 10 秒钟的 url 浏览量,每 5 秒钟更新一次;另外为了更加清晰地展示,还应该把窗口的起始结束时间一起输出。我们可以定义滑动窗口,并结合增量聚合函数和全窗口函数来得到统计结果: - -```java -import org.apache.flink.api.common.eventtime.SerializableTimestampAssigner; -import org.apache.flink.api.common.eventtime.WatermarkStrategy; -import org.apache.flink.api.common.functions.AggregateFunction; -import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction; -import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows; -import org.apache.flink.streaming.api.windowing.time.Time; -import org.apache.flink.streaming.api.windowing.windows.TimeWindow; -import org.apache.flink.util.Collector; - -public class UrlCountViewExample { - - public static void main(String[] args) throws Exception { - StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); - env.setParallelism(1); - env.getConfig().setAutoWatermarkInterval(100); - - SingleOutputStreamOperator stream = env.addSource(new ClickSource()) - //乱序流的watermark生成 - .assignTimestampsAndWatermarks(WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(0)) - .withTimestampAssigner(new SerializableTimestampAssigner() { - @Override - public long extractTimestamp(Event element, long recordTimestamp) { - return element.timestamp; - } - })); - stream.print("input"); - - //统计每个url的访问量 - stream.keyBy(data -> data.url) - .window(TumblingEventTimeWindows.of(Time.seconds(10))) - .aggregate(new UrlViewCountAgg(),new UrlViewCountResult()) - .print(); - - - env.execute(); - } - - //增量聚合,来一条数据 + 1 - public static class UrlViewCountAgg implements AggregateFunction{ - - @Override - public Long createAccumulator() { - return 0L; - } - - @Override - public Long add(Event value, Long accumulator) { - return accumulator + 1; - } - - @Override - public Long getResult(Long accumulator) { - return accumulator; - } - - @Override - public Long merge(Long a, Long b) { - return null; - } - } - - //包装窗口信息,输出UrlViewCount - public static class UrlViewCountResult extends ProcessWindowFunction{ - - @Override - public void process(String s, Context context, Iterable elements, Collector out) throws Exception { - Long start = context.window().getStart(); - Long end = context.window().getEnd(); - Long count = elements.iterator().next(); - out.collect(new UrlViewCount(s,count,start,end)); - } - } -} -``` - -为了方便处理,单独定义了一个POJO类,来表示输出结果的数据类型 - -```kotlin -public class UrlViewCount { - public String url; - public Long count; - public Long windowStart; - public Long windowEnd; - - public UrlViewCount() { - } - - public UrlViewCount(String url, Long count, Long windowStart, Long windowEnd) { - this.url = url; - this.count = count; - this.windowStart = windowStart; - this.windowEnd = windowEnd; - } - - @Override - public String toString() { - return "UrlViewCount{" + - "url='" + url + '\'' + - ", count=" + count + - ", windowStart=" + new Timestamp(windowStart) + - ", windowEnd=" + new Timestamp(windowEnd) + - '}'; - } -} -``` - -代码中用一个 AggregateFunction 来实现增量聚合,每来一个数据就计数加一,得到的结果交给 ProcessWindowFunction,结合窗口信息包装成我们想要的 UrlViewCount,最终输出统计结果。 - -窗口处理的主体还是增量聚合,而引入全窗口函数又可以获取到更多的信息包装输出,这样的结合兼具了两种窗口函数的优势,在保证处理性能和实时性的同时支持了更加丰富的应用场景。 - -### Window重叠优化 - -窗口重叠是指在使用滑动窗口时,多个窗口之间存在重叠部分。这意味着同一批数据可能会被多个窗口同时处理。 - -例如,假设我们有一个数据流,它包含了0到9的整数。我们定义了一个大小为5的滑动窗口,滑动距离为2。那么,我们将会得到以下三个窗口: - -- 窗口1:包含0, 1, 2, 3, 4 -- 窗口2:包含2, 3, 4, 5, 6 -- 窗口3:包含4, 5, 6, 7, 8 - -在这个例子中,窗口1和窗口2之间存在重叠部分,即2, 3, 4。同样,窗口2和窗口3之间也存在重叠部分,即4, 5, 6。 - -`enableOptimizeWindowOverlap`方法是用来启用Flink的窗口重叠优化功能的。它可以减少计算重叠窗口时的计算量。 - -在我之前给出的代码示例中,我没有使用`enableOptimizeWindowOverlap`方法来启用窗口重叠优化功能。这意味着Flink不会尝试优化计算重叠窗口时的计算量。 - -如果你想使用窗口重叠优化功能,你可以在你的代码中添加以下行: - -```Java -env.getConfig().enableOptimizeWindowOverlap(); -``` - -这将启用窗口重叠优化功能,Flink将尝试优化计算重叠窗口时的计算量。 - -## 触发器(Trigger) - -触发器主要是用来控制窗口什么时候触发计算。所谓的“触发计算”,本质上就是执行窗口函数,所以可以认为是计算得到结果并输出的过程。 - -基于 WindowedStream 调用.trigger()方法,就可以传入一个自定义的窗口触发器(Trigger)。 - -```css -stream.keyBy(...) - .window(...) - .trigger(new MyTrigger()) -``` - -Trigger 是窗口算子的内部属性,每个窗口分配器(WindowAssigner)都会对应一个默认的触发器;对于 Flink 内置的窗口类型,它们的触发器都已经做了实现。例如,所有事件时间窗口,默认的触发器都是EventTimeTrigger;类似还有 ProcessingTimeTrigger 和 CountTrigger。所以一般情况下是不需要自定义触发器的,这块了解一下即可。 - -## 移除器(Evictor) - -在 Apache Flink 中,移除器(Evictor)是用于在滚动窗口或会话窗口中控制数据保留和清理的组件。它可以根据特定的策略从窗口中删除一些数据,以确保窗口中保留的数据量不超过指定的限制。移除器通常与窗口分配器一起使用,窗口分配器负责确定数据属于哪个窗口,而移除器则负责清理窗口中的数据。 - -以下是一个使用 Flink 移除器的代码示例,演示如何在滚动窗口中使用基于计数的移除器。 - -```java -javaCopy codeimport org.apache.flink.api.common.functions.AggregateFunction; -import org.apache.flink.api.common.state.ListState; -import org.apache.flink.api.common.state.ListStateDescriptor; -import org.apache.flink.api.java.tuple.Tuple2; -import org.apache.flink.configuration.Configuration; -import org.apache.flink.streaming.api.datastream.DataStream; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction; -import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows; -import org.apache.flink.streaming.api.windowing.evictors.CountEvictor; -import org.apache.flink.streaming.api.windowing.time.Time; -import org.apache.flink.streaming.api.windowing.triggers.CountTrigger; -import org.apache.flink.streaming.api.windowing.windows.TimeWindow; -import org.apache.flink.util.Collector; - -public class FlinkEvictorExample { - - public static void main(String[] args) throws Exception { - StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); - - // 创建一个包含整数和时间戳的流 - DataStream> dataStream = env.fromElements( - Tuple2.of(1, System.currentTimeMillis()), - Tuple2.of(2, System.currentTimeMillis() + 1000), - Tuple2.of(3, System.currentTimeMillis() + 2000), - Tuple2.of(4, System.currentTimeMillis() + 3000), - Tuple2.of(5, System.currentTimeMillis() + 4000), - Tuple2.of(6, System.currentTimeMillis() + 5000) - ); - - // 在滚动窗口中使用基于计数的移除器,保留最近3个元素 - dataStream - .keyBy(value -> value.f0) - .window(TumblingEventTimeWindows.of(Time.seconds(5))) - .trigger(CountTrigger.of(3)) - .evictor(CountEvictor.of(3)) - .aggregate(new MyAggregateFunction(), new MyProcessWindowFunction()) - .print(); - - env.execute("Flink Evictor Example"); - } - - // 自定义聚合函数 - private static class MyAggregateFunction implements AggregateFunction, Integer, Integer> { - - @Override - public Integer createAccumulator() { - return 0; - } - - @Override - public Integer add(Tuple2 value, Integer accumulator) { - return accumulator + 1; - } - - @Override - public Integer getResult(Integer accumulator) { - return accumulator; - } - - @Override - public Integer merge(Integer a, Integer b) { - return a + b; - } - } - - // 自定义处理窗口函数 - private static class MyProcessWindowFunction extends ProcessWindowFunction { - - private transient ListState countState; - - @Override - public void open(Configuration parameters) throws Exception { - super.open(parameters); - ListStateDescriptor descriptor = new ListStateDescriptor<>("countState", Integer.class); - countState = getRuntimeContext().getListState(descriptor); - } - - @Override - public void process(Integer key, Context context, Iterable elements, Collector out) throws Exception { - int count = elements.iterator().next(); - countState.add(count); - - long windowStart = context.window().getStart(); - long windowEnd = context.window().getEnd(); - String result = "Window: " + windowStart + " to " + windowEnd + ", Count: " + countState.get().iterator().next(); - out.collect(result); - } - } -} -``` - -在上述示例中,创建了一个包含整数和时间戳的数据流,并使用基于计数的移除器将滚动窗口的大小限制为最近的3个元素。在聚合函数中,我们简单地将元素的数量累加起来,并在处理窗口函数中收集结果。最后,我们打印窗口的开始时间、结束时间和元素数量。 - -## Flink Time时间语义 - -Flink定义了三类时间 - -- **事件时间(Event Time)**数据在数据源产生的时间,一般由事件中的时间戳描述,比如用户日志中的TimeStamp。 -- **处理时间(Process Time)**数据进入Flink被处理的系统时间(Operator处理数据的系统时间)。 -- **摄取时间(Ingestion Time)**数据进入Flink的时间,记录被Source节点观察到的系统时间。 - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpBZRCkBIDh9K3y5YzxXux7GLYLpqhPy6DpDwb0geTjibkv5JPnDoEtXQ/640?wx_fmt=png) - -Flink流式计算的时候需要显示定义时间语义,根据不同的时间语义来处理数据,比如指定的时间语义是事件时间,那么我们就要切换到事件时间的世界观中,窗口的起始与终止时间都是以事件时间为依据 - -在Flink中默认使用的是Process Time,如果要使用其他的时间语义,在执行环境中可以设置 - -```scala -//设置时间语义为Ingestion Time -env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime) -//设置时间语义为Event Time 我们还需要指定一下数据中哪个字段是事件时间(下文会讲) -env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime) -``` - -- 基于事件时间的Window操作 - - ```scala - import org.apache.flink.streaming.api.TimeCharacteristic - import org.apache.flink.streaming.api.scala.{StreamExecutionEnvironment, _} - import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows - import org.apache.flink.streaming.api.windowing.time.Time - - object EventTimeWindow { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime) - val stream = env.socketTextStream("node01", 8888).assignAscendingTimestamps(data => { - val splits = data.split(" ") - splits(0).toLong - }) - - stream - .flatMap(x=>x.split(" ").tail) - .map((_, 1)) - .keyBy(_._1) - // .timeWindow(Time.seconds(10)) - .window(TumblingEventTimeWindows.of(Time.seconds(10))) - .reduce((v1: (String, Int), v2: (String, Int)) => { - (v1._1, v1._2 + v2._2) - }) - .print() - - env.execute() - } - } - ``` - ------- - -### Watermark(水印) - -**Watermark本质就是时间戳,说白了Watermark就是来处理迟到数据的**。 - -在使用Flink处理数据的时候,数据通常都是按照事件产生的时间(事件时间)的顺序进入到Flink,但是在遇到特殊情况下,比如遇到网络延迟或者使用Kafka(多分区) 很难保证数据都是按照事件时间的顺序进入Flink,很有可能是乱序进入。 - -如果使用的是事件时间这个语义,数据一旦是乱序进入,那么在使用Window处理数据的时候,就会出现延迟数据不会被计算的问题 - -- 举例: Window窗口长度10s,滚动窗口 - - 001 zs 2020-04-25 10:00:01 - - 001 zs 2020-04-25 10:00:02 - - 001 zs 2020-04-25 10:00:03 - - 001 zs 2020-04-25 10:00:11 窗口触发执行 - - 001 zs 2020-04-25 10:00:05 延迟数据,不会被上一个窗口所计算导致计算结果不正确 - -Watermark+Window可以很好的解决延迟数据的问题。 - -Flink窗口计算的过程中,如果数据全部到达就会到窗口中的数据做处理,如果过有延迟数据,那么窗口需要等待全部的数据到来之后,再触发窗口执行,需要等待多久?不可能无限期等待,我们用户可以自己来设置延迟时间,这样就可以尽可能保证延迟数据被处理。 - -根据用户指定的延迟时间生成水印(Watermak = 最大事件时间-指定延迟时间),当Watermak 大于等于窗口的停止时间,这个窗口就会被触发执行。 - -- 举例:Window窗口长度10s(01~10),滚动窗口,指定延迟时间3s - - 001 ls 2020-04-25 10:00:01 wm:2020-04-25 09:59:58 - - 001 ls 2020-04-25 10:00:02 wm:2020-04-25 09:59:59 - - 001 ls 2020-04-25 10:00:03 wm:2020-04-25 10:00:00 - - 001 ls 2020-04-25 10:00:09 wm:2020-04-25 10:00:06 - - 001 ls 2020-04-25 10:00:12 wm:2020-04-25 10:00:09 - - 001 ls 2020-04-25 10:00:08 wm:2020-04-25 10:00:05 延迟数据 - - 001 ls 2020-04-25 10:00:13 wm:2020-04-25 10:00:10 - -**如果没有Watermark在倒数第三条数据来的时候,就会触发执行,那么倒数第二条的延迟数据就不会被计算,那么有了水印可以处理延迟3s内的数据**。 - -**注意:如果数据不会乱序进入Flink,没必要使用Watermark** - -DataStream API提供了自定义水印生成器和内置水印生成器。 - -#### 生成水印策略 - -- 周期性水印(Periodic Watermark)根据事件或者处理时间周期性的触发水印生成器(Assigner),默认100ms,每隔100毫秒自动向流里注入一个Watermark - - 周期性水印API 1: - - ```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime) - env.getConfig.setAutoWatermarkInterval(100) - val stream = env.socketTextStream("node01", 8888).assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[String](Time.seconds(3)) { - override def extractTimestamp(element: String): Long = { - element.split(" ")(0).toLong - } - }) - ``` - - 周期性水印API 2: - - ```scala - import org.apache.flink.streaming.api.TimeCharacteristic - import org.apache.flink.streaming.api.functions.AssignerWithPeriodicWatermarks - import org.apache.flink.streaming.api.scala.function.ProcessWindowFunction - import org.apache.flink.streaming.api.scala.{StreamExecutionEnvironment, _} - import org.apache.flink.streaming.api.watermark.Watermark - import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows - import org.apache.flink.streaming.api.windowing.time.Time - import org.apache.flink.streaming.api.windowing.windows.TimeWindow - import org.apache.flink.util.Collector - - object EventTimeDelayWindow { - - class MyTimestampAndWatermarks(delayTime:Long) extends AssignerWithPeriodicWatermarks[String] { - - var maxCurrentWatermark: Long = _ - - //水印=最大事件时间-延迟时间 后被调用 水印是递增,小于上一个水印不会被发射出去 - override def getCurrentWatermark: Watermark = { - //产生水印 - new Watermark(maxCurrentWatermark - delayTime) - } - - //获取当前的时间戳 先被调用 - override def extractTimestamp(element: String, previousElementTimestamp: Long): Long = { - val currentTimeStamp = element.split(" ")(0).toLong - maxCurrentWatermark = math.max(currentTimeStamp,maxCurrentWatermark) - currentTimeStamp - } - } - - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime) - env.getConfig.setAutoWatermarkInterval(100) - val stream = env.socketTextStream("node01", 8888).assignTimestampsAndWatermarks(new MyTimestampAndWatermarks(3000L)) - - stream - .flatMap(x => x.split(" ").tail) - .map((_, 1)) - .keyBy(_._1) - // .timeWindow(Time.seconds(10)) - .window(TumblingEventTimeWindows.of(Time.seconds(10))) - .process(new ProcessWindowFunction[(String, Int), (String, Int), String, TimeWindow] { - override def process(key: String, context: Context, elements: Iterable[(String, Int)], out: Collector[(String, Int)]): Unit = { - val start = context.window.getStart - val end = context.window.getEnd - var count = 0 - for (elem <- elements) { - count += elem._2 - } - println("start:" + start + " end:" + end + " word:" + key + " count:" + count) - } - }) - .print() - - env.execute() - } - } - ``` - -- 间歇性水印生成器 - - 间歇性水印(Punctuated Watermark)在观察到事件后,会依据用户指定的条件来决定是否发射水印。 - - 比如,在车流量的数据中,001卡口通信经常异常,传回到服务器的数据会有延迟问题,其他的卡口都是正常的,那么这个卡口的数据需要打上水印。 - - ```scala - import org.apache.flink.streaming.api.TimeCharacteristic - import org.apache.flink.streaming.api.functions.AssignerWithPunctuatedWatermarks - import org.apache.flink.streaming.api.scala.{StreamExecutionEnvironment, _} - import org.apache.flink.streaming.api.watermark.Watermark - import org.apache.flink.streaming.api.windowing.time.Time - - object PunctuatedWatermarkTest { - def main(args: Array[String]): Unit = { - val env = StreamExecutionEnvironment.getExecutionEnvironment - env.setParallelism(1) - env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime) - //卡口号、时间戳 - env.socketTextStream("node01", 8888) - .map(data => { - val splits = data.split(" ") - (splits(0), splits(1).toLong) - }) - .assignTimestampsAndWatermarks(new myWatermark(3000)) - .keyBy(_._1) - .timeWindow(Time.seconds(5)) - .reduce((v1: (String, Long), v2: (String, Long)) => { - (v1._1 + "," + v2._1, v1._2 + v2._2) - }).print() - - env.execute() - } - - class myWatermark(delay: Long) extends AssignerWithPunctuatedWatermarks[(String, Long)] { - var maxTimeStamp:Long = _ - - override def checkAndGetNextWatermark(elem: (String, Long), extractedTimestamp: Long): Watermark = { - maxTimeStamp = extractedTimestamp.max(maxTimeStamp) - if ("001".equals(elem._1)) { - new Watermark(maxTimeStamp - delay) - } else { - new Watermark(maxTimeStamp) - } - } - - override def extractTimestamp(element: (String, Long), previousElementTimestamp: Long): Long = { - element._2 - } - } - } - ``` - -### 允许延迟(Allowed Lateness) - -#### 将迟到的数据放入侧输出流 - -Flink 还提供了另外一种方式处理迟到数据。我们可以将未收入窗口的迟到数据,放入“侧输出流”(side output)进行另外的处理。所谓的侧输出流,相当于是数据流的一个“分支”,这个流中单独放置那些错过了、本该被丢弃的数据。 - -基于 WindowedStream 调用.sideOutputLateData() 方法,就可以实现这个功能。方法需要传入一个“输出标签”(OutputTag),用来标记分支的迟到数据流。因为保存的就是流中的原始数据,所以 OutputTag 的类型与流中数据类型相同。 - -sideOutputLateData() 方法,传入一个输出标签,用来标记分治的迟到数据流 - -```dart -DataStream stream = env.addSource(...); -OutputTag outputTag = new OutputTag("late") {}; -stream.keyBy(...) - .window(TumblingEventTimeWindows.of(Time.hours(1))) - .sideOutputLateData(outputTag) -``` - -将迟到数据放入侧输出流之后,还应该可以将它提取出来。基于窗口处理完成之后的DataStream,调用.getSideOutput()方法,传入对应的输出标签,就可以获取到迟到数据所在的流了。 - -```dart -SingleOutputStreamOperator winAggStream = stream.keyBy(...) - .window(TumblingEventTimeWindows.of(Time.hours(1))) - .sideOutputLateData(outputTag) - .aggregate(new MyAggregateFunction()) -DataStream lateStream = winAggStream.getSideOutput(outputTag); -``` - -这里注意,getSideOutput()是 SingleOutputStreamOperator 的方法,获取到的侧输出流数据类型应该和 OutputTag 指定的类型一致,与窗口聚合之后流中的数据类型可以不同。 - -## Flink关联维表实战 - -在Flink实际开发过程中,可能会遇到source 进来的数据,需要连接数据库里面的字段,再做后面的处理,比如,想要通过id获取对应的地区名字,这时候需要通过id查询地区维度表,获取具体的地区名。 - -对于不同的应用场景,关联维度表的方式不同 - -- 场景1:维度表信息基本不发生改变,或者发生改变的频率很低。 - - 实现方案:采用Flink提供的CachedFile。 - - Flink提供了一个分布式缓存(CachedFile),类似于hadoop,可以使用户在并行函数中很方便的读取本地文件,并把它放在TaskManager节点中,防止task重复拉取。 此缓存的工作机制如下:程序注册一个文件或者目录(本地或者远程文件系统,例如hdfs或者s3),通过ExecutionEnvironment注册缓存文件并为它起一个名称。 当程序执行,Flink自动将文件或者目录复制到所有TaskManager节点的本地文件系统,**仅会执行一次**。用户可以通过这个指定的名称查找文件或者目录,然后从TaskManager节点的本地文件系统访问它。 - - ```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - env.registerCachedFile("/root/id2city","id2city") - - val socketStream = env.socketTextStream("node01",8888) - val stream = socketStream.map(_.toInt) - stream.map(new RichMapFunction[Int,String] { - - private val id2CityMap = new mutable.HashMap[Int,String]() - override def open(parameters: Configuration): Unit = { - val file = getRuntimeContext().getDistributedCache().getFile("id2city") - val str = FileUtils.readFileUtf8(file) - val strings = str.split("\r\n") - for(str <- strings){ - val splits = str.split(" ") - val id = splits(0).toInt - val city = splits(1) - id2CityMap.put(id,city) - } - } - override def map(value: Int): String = { - id2CityMap.getOrElse(value,"not found city") - } - }).print() - env.execute() - ``` - - 在集群中查看对应TaskManager的log日志,发现注册的file会被拉取到各个TaskManager的工作目录区。 - -- 场景2:对于维度表更新频率比较高并且对于查询维度表的实时性要求比较高 - - 实现方案:使用定时器,定时加载外部配置文件或者数据库 - - ```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - env.setParallelism(1) - val stream = env.socketTextStream("node01",8888) - - stream.map(new RichMapFunction[String,String] { - - private val map = new mutable.HashMap[String,String]() - - override def open(parameters: Configuration): Unit = { - println("init data ...") - query() - val timer = new Timer(true) - timer.schedule(new TimerTask { - override def run(): Unit = { - query() - } - //1s后,每隔2s执行一次 - },1000,2000) - } - - def query()={ - val source = Source.fromFile("D:\\code\\StudyFlink\\data\\id2city","UTF-8") - val iterator = source.getLines() - for (elem <- iterator) { - val vs = elem.split(" ") - map.put(vs(0),vs(1)) - } - } - - override def map(key: String): String = { - map.getOrElse(key,"not found city") - } - }).print() - - env.execute() - - ``` - -- 场景3:对于维度表更新频率高并且对于查询维度表的实时性要求高 - - 实现方案:将更改的信息同步值Kafka配置Topic中,然后将kafka的配置流信息变成广播流,广播到业务流的各个线程中。 - - -```scala - val env = StreamExecutionEnvironment.getExecutionEnvironment - - //设置连接kafka的配置信息 - val props = new Properties() - //注意 sparkstreaming + kafka(0.10之前版本) receiver模式 zookeeper url(元数据) - props.setProperty("bootstrap.servers","node01:9092,node02:9092,node03:9092") - props.setProperty("group.id","flink-kafka-001") - props.setProperty("key.deserializer",classOf[StringSerializer].getName) - props.setProperty("value.deserializer",classOf[StringSerializer].getName) - val consumer = new FlinkKafkaConsumer[String]("configure",new SimpleStringSchema(),props) - //从topic最开始的数据读取 -// consumer.setStartFromEarliest() - //从最新的数据开始读取 - consumer.setStartFromLatest() - - //动态配置信息流 - val configureStream = env.addSource(consumer) - //业务流 - val busStream = env.socketTextStream("node01",8888) - - val descriptor = new MapStateDescriptor[String, String]("dynamicConfig", - BasicTypeInfo.STRING_TYPE_INFO, - BasicTypeInfo.STRING_TYPE_INFO) - //设置广播流的数据描述信息 - val broadcastStream = configureStream.broadcast(descriptor) - - //connect关联业务流与配置信息流,broadcastStream流中的数据会广播到下游的各个线程中 - busStream.connect(broadcastStream) - .process(new BroadcastProcessFunction[String,String,String] { - override def processElement(line: String, ctx: BroadcastProcessFunction[String, String, String]#ReadOnlyContext, out: Collector[String]): Unit = { - val broadcast = ctx.getBroadcastState(descriptor) - val city = broadcast.get(line) - if(city == null){ - out.collect("not found city") - }else{ - out.collect(city) - } - } - - //kafka中配置流信息,写入到广播流中 - override def processBroadcastElement(line: String, ctx: BroadcastProcessFunction[String, String, String]#Context, out: Collector[String]): Unit = { - val broadcast = ctx.getBroadcastState(descriptor) - //kafka中的数据 - val elems = line.split(" ") - broadcast.put(elems(0),elems(1)) - } - }).print() - env.execute() -``` - -## Table API & Flink SQL - -在Spark中有DataFrame这样的关系型编程接口,因其强大且灵活的表达能力,能够让用户通过非常丰富的接口对数据进行处理,有效降低了用户的使用成本。Flink也提供了关系型编程接口Table API以及基于Table API的SQL API,让用户能够通过使用结构化编程接口高效地构建Flink应用。同时Table API以及SQL能够统一处理批量和实时计算业务,无须切换修改任何应用代码就能够基于同一套API编写流式应用和批量应用,从而达到真正意义的批流统一 - -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpJCicXKXH7TiaetzpicQGVxxmjkCalOFRRDkabzHia16WjeDZOicMYrlnmcA/640?wx_fmt=png) - -在 Flink 1.8 架构里,如果用户需要同时流计算、批处理的场景下,用户需要维护两套业务代码,开发人员也要维护两套技术栈,非常不方便。 Flink 社区很早就设想过将批数据看作一个有界流数据,将批处理看作流计算的一个特例,从而实现流批统一,阿里巴巴的 Blink 团队在这方面做了大量的工作,已经实现了 Table API & SQL 层的流批统一。阿里巴巴已经将 Blink 开源回馈给 Flink 社区。 - -### 开发环境构建 - -在 Flink 1.9 中,Table 模块迎来了核心架构的升级,引入了阿里巴巴Blink团队贡献的诸多功能,取名叫: Blink Planner。在使用Table API和SQL开发Flink应用之前,通过添加Maven的依赖配置到项目中,在本地工程中引入相应的依赖库,库中包含了Table API和SQL接口。 - -```xml - - org.apache.flink - flink-table-planner_2.11 - 1.9.1 - - - org.apache.flink - flink-table-api-scala-bridge_2.11 - 1.9.1 - -``` - -### Table Environment - -和DataStream API一样,Table API和SQL中具有相同的基本编程模型。首先需要构建对应的TableEnviroment创建关系型编程环境,才能够在程序中使用Table API和SQL来编写应用程序,另外Table API和SQL接口可以在应用中同时使用,Flink SQL基于Apache Calcite框架实现了SQL标准协议,是构建在Table API之上的更高级接口。 - -首先需要在环境中创建TableEnvironment对象,TableEnvironment中提供了注册内部表、执行Flink SQL语句、注册自定义函数等功能。根据应用类型的不同,TableEnvironment创建方式也有所不同,但是都是通过调用create()方法创建 - -流计算环境下创建TableEnviroment: - -```scala -//创建流式计算的上下文环境 -val env = StreamExecutionEnvironment.getExecutionEnvironment -//创建Table API的上下文环境 -val tableEvn =StreamTableEnvironment.create(env) -``` - -### Table API - -Table API 顾名思义,就是基于“表”(Table)的一套 API,专门为处理表而设计的,它提供了关系型编程模型,可以用来处理结构化数据,支持表和视图的概念。在此基础上,Flink 还基于 Apache Calcite 实现了对 SQL 的支持。这样一来,我们就可以在 Flink 程序中直接写 SQL 来实现处理需求了,非常实用。 - -下面是一个简单的例子,它使用Java编写了一个Flink程序,该程序使用Table API从CSV文件中读取数据,然后执行简单的查询并将结果写入到另一个CSV文件中。 - -首先我们需要导入maven依赖: - -```xml - - org.apache.flink - flink-table-api-java-bridge_${scala.binary.version} - -``` - -代码示例如下: - -```Java -import org.apache.flink.api.java.DataSet; -import org.apache.flink.api.java.ExecutionEnvironment; -import org.apache.flink.api.java.tuple.Tuple2; -import org.apache.flink.table.api.Table; -import org.apache.flink.table.api.bridge.java.BatchTableEnvironment; - -public class TableAPIExample { - public static void main(String[] args) throws Exception { - ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment(); - BatchTableEnvironment tableEnv = BatchTableEnvironment.create(env); - - DataSet> data = env.readCsvFile("input.csv") - .includeFields("11") - .types(String.class, Integer.class); - - Table table = tableEnv.fromDataSet(data, "name, age"); - tableEnv.createTemporaryView("people", table); - - Table result = tableEnv.sqlQuery("SELECT name, age FROM people WHERE age > 30"); - - DataSet> output = tableEnv.toDataSet(result, Tuple2.class); - output.writeAsCsv("output.csv"); - - env.execute(); - } -} -``` - -在这个例子中,使用`readCsvFile`方法从CSV文件中读取数据,并使用`includeFields`和`types`方法指定要包含的字段和字段类型。接下来,使用`fromDataSet`方法将数据集转换为表,并使用`createTemporaryView`方法创建一个临时视图。然后,使用`sqlQuery`方法执行SQL查询,并使用`toDataSet`方法将结果转换为数据集。最后,使用`writeAsCsv`方法将结果写入到CSV文件中,并使用`execute`方法启动执行。 - -除了上面这种写法外,我们还有下面2种写法: - -```Java -//这里每个方法的参数都是一个“表达式”(Expression),用方法调用的形式直观地说明 -//“$”符号用来指定表中的一个字段。代码和直接执行SQL是等效的。 -Table maryClickTable = eventTable.where($("user").isEqual("Alice")).select($("url"),$("user")) - -//这其实是一种简略的写法,我们将 Table 对象名 eventTable 直接以字符串拼接的形式添加到 SQL 语句中,在解析时会自动注册一个同名的虚拟表到环境中,这样就省略了创建虚拟视图的步骤。 -Table clickTable = tableEnvironment.sqlQuery("select url, user from " +eventTable); -``` - -#### Virtual Tables(虚拟表) - -在环境中注册之后,我们就可以在 SQL 中直接使用这张表进行查询转换了。 - -```Java -Table newTable = tableEnv.sqlQuery("SELECT ... FROM MyTable... "); -``` - -得到的 newTable 是一个中间转换结果,如果之后又希望直接使用这个表执行 SQL,又该怎么做呢?由于 newTable 是一个 Table 对象,并没有在表环境中注册;所以我们还需要将这个中间结果表注册到环境中,才能在 SQL 中使用: - -```Java -tableEnv.createTemporaryView("NewTable", newTable); -``` - -这里的注册其实是创建了一个“虚拟表”(Virtual Table)。这个概念与 SQL 语法中的视图(View)非常类似,所以调用的方法也叫作创建“虚拟视图” (createTemporaryView)。 - -#### 表流互转 - -```Java -// 将表转换成数据流,并打印 -tableEnv.toDataStream(aliceVisitTable).print(); -// 将数据流转换成表。 -// 另外,我们还可以在 fromDataStream()方法中增加参数,用来指定提取哪些属性作为表中的字段名,并可以任意指定位置: -Table eventTable2 = tableEnv.fromDataStream(eventStream, $("timestamp").as("ts"),$("url")); -``` - -#### 动态表和持续查询 - -在Flink中,动态表(Dynamic Tables)是一种特殊的表,它可以随时间变化。它们通常用于表示无限流数据,例如事件流或服务器日志。与静态表不同,动态表可以在运行时插入、更新和删除行。 - -动态表可以像静态的批处理表一样进行查询操作。由于数据在不断变化,因此基于它定义的 SQL 查询也不可能执行一次就得到最终结果。这样一来,我们对动态表的查询也就永远不会停止,一直在随着新数据的到来而继续执行。这样的查询就被称作持续查询(Continuous Query)。 - -下面是一个简单的例子,它使用Java编写了一个Flink程序,该程序使用Table API从Kafka主题中读取数据,然后执行持续查询并将结果写入到另一个Kafka主题中。 - -```Java -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.table.api.EnvironmentSettings; -import org.apache.flink.table.api.Table; -import org.apache.flink.table.api.bridge.java.StreamTableEnvironment; - -public class DynamicTableExample { - public static void main(String[] args) throws Exception { - StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); - EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inStreamingMode().build(); - StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings); - - tableEnv.executeSql("CREATE TABLE input (" + - " name STRING," + - " age INT" + - ") WITH (" + - " 'connector' = 'kafka'," + - " 'topic' = 'input-topic'," + - " 'properties.bootstrap.servers' = 'localhost:9092'," + - " 'format' = 'json'" + - ")"); - - tableEnv.executeSql("CREATE TABLE output (" + - " name STRING," + - " age INT" + - ") WITH (" + - " 'connector' = 'kafka'," + - " 'topic' = 'output-topic'," + - " 'properties.bootstrap.servers' = 'localhost:9092'," + - " 'format' = 'json'" + - ")"); - - Table result = tableEnv.sqlQuery("SELECT name, age FROM input WHERE age > 30"); - tableEnv.toAppendStream(result, Row.class).print(); - - result.executeInsert("output"); - - env.execute(); - } -} -``` - -在这个例子中,首先创建了一个`StreamExecutionEnvironment`来设置执行环境,并使用`StreamTableEnvironment.create`方法创建了一个`StreamTableEnvironment`。然后,使用`executeSql`方法创建了两个Kafka表:一个用于读取输入数据,另一个用于写入输出数据。接下来,使用`sqlQuery`方法执行持续查询,并使用`toAppendStream`方法将结果转换为数据流。最后,使用`executeInsert`方法将结果写入到输出表中,并使用`execute`方法启动执行。 - -#### 连接到外部系统 - -在 Table API编写的 Flink 程序中,可以在创建表的时候用 WITH 子句指定连接器(connector),这样就可以连接到外部系统进行数据交互了。 - -其中最简单的当然就是连接到控制台打印输出: - -```Java -CREATE TABLE ResultTable ( - user STRING, - cnt BIGINT -WITH ( - 'connector' = 'print' -); -``` - -##### Kafka - -需要导入maven依赖: - -```XML - - org.apache.flink - flink-connector-kafka_${scala.binary.version} - ${flink.version} - -``` - -创建一个连接到 Kafka 表,需要在 CREATE TABLE 的 DDL 中在 WITH 子句里指定连接器为 Kafka,并定义必要的配置参数。 - -```sql -CREATE TABLE KafkaTable ( - `user` STRING, - `url` STRING, - `ts` TIMESTAMP(3) METADATA FROM 'timestamp' -) WITH ( - 'connector' = 'kafka', - 'topic' = 'events', - 'properties.bootstrap.servers' = 'localhost:9092', - 'properties.group.id' = 'testGroup', - 'scan.startup.mode' = 'earliest-offset', - 'format' = 'csv' -) -``` - -##### MySQL - -```XML - - org.apache.flink - flink-connector-jdbc_${scala.binary.version} - ${flink.version} - -``` - -创建 JDBC 表的方法与前面Kafka 大同小异: - -```sql --- 创建一张连接到 MySQL 的 表 -CREATE TABLE MyTable ( - id BIGINT, - name STRING, - age INT, - status BOOLEAN, - PRIMARY KEY (id) NOT ENFORCED -) WITH ( - 'connector' = 'jdbc', - 'url' = 'jdbc:mysql://localhost:3306/mydatabase', - 'table-name' = 'users' -); --- 将另一张表 T 的数据写入到 MyTable 表中 -INSERT INTO MyTable -SELECT id, name, age, status FROM T; -``` - -### Table API实战 - -在Flink中创建一张表有两种方法: - -- 从一个文件中导入表结构(Structure)(常用于批计算)(静态) -- 从DataStream或者DataSet转换成Table (动态) - -#### 1.创建Table - -Table API中已经提供了TableSource从外部系统获取数据,例如常见的数据库、文件系统和Kafka消息队列等外部系统。 - -1. 从文件中创建Table(静态表) - - Flink允许用户从本地或者分布式文件系统中读取和写入数据,在Table API中可以通过CsvTableSource类来创建,只需指定相应的参数即可。但是文件格式必须是CSV格式的。其他文件格式也支持(在Flink还有Connector的来支持其他格式或者自定义TableSource) - - ```scala - //创建流式计算的上下文环境 - val env = StreamExecutionEnvironment.getExecutionEnvironment - //创建Table API的上下文环境 - val tableEvn = StreamTableEnvironment.create(env) - - - val source = new CsvTableSource("D:\\code\\StudyFlink\\data\\tableexamples" - , Array[String]("id", "name", "score") - , Array(Types.INT, Types.STRING, Types.DOUBLE) - ) - //将source注册成一张表 别名:exampleTab - tableEvn.registerTableSource("exampleTab",source) - tableEvn.scan("exampleTab").printSchema() - ``` - - 代码最后不需要env.execute(),这并不是一个流式计算任务 - -2. 从DataStream中创建Table(动态表) - - 前面已经知道Table API是构建在DataStream API和DataSet API之上的一层更高级的抽象,因此用户可以灵活地使用Table API将Table转换成DataStream或DataSet数据集,也可以将DataSteam或DataSet数据集转换成Table,这和Spark中的DataFrame和RDD的关系类似 - -#### 2.修改Table中字段名 - -​ Flink支持把自定义POJOs类的所有case类的属性名字变成字段名,也可以通过基于字段偏移位置和字段名称两种方式重新修改: - -```scala - //导入table库中的隐式转换 - import org.apache.flink.table.api.scala._ - // 基于位置重新指定字段名称为"field1", "field2", "field3" - val table = tStreamEnv.fromDataStream(stream, 'field1, 'field2, 'field3) - // 将DataStream转换成Table,并且将字段名称重新成别名 - val table: Table = tStreamEnv.fromDataStream(stream, 'rowtime as 'newTime, 'id as 'newId,'variable as 'newVariable) -``` - -**注意:要导入隐式转换。如果使用as 修改字段,必须修改表中所有的字段。** - -#### 3.查询和过滤 - -在Table对象上使用select操作符查询需要获取的指定字段,也可以使用filter或where方法过滤字段和检索条件,将需要的数据检索出来。 - -```scala -object TableAPITest { - - def main(args: Array[String]): Unit = { - val streamEnv: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment - streamEnv.setParallelism(1) - //初始化Table API的上下文环境 - val tableEvn =StreamTableEnvironment.create(streamEnv) - //导入隐式转换,建议写在这里,可以防止IDEA代码提示出错的问题 - import org.apache.flink.streaming.api.scala._ - import org.apache.flink.table.api.scala._ - val data = streamEnv.socketTextStream("hadoop101",8888) - .map(line=>{ - var arr =line.split(",") - new StationLog(arr(0).trim,arr(1).trim,arr(2).trim,arr(3).trim,arr(4).trim.toLong,arr(5).trim.toLong) - }) - - val table: Table = tableEvn.fromDataStream(data) - //查询 - tableEvn.toAppendStream[Row]( - table.select('sid,'callType as 'type,'callTime,'callOut)) - .print() - //过滤查询 - tableEvn.toAppendStream[Row]( - table.filter('callType==="success") //filter - .where('callType==="success")) //where - .print() - tableEvn.execute("sql") - } -``` - -其中toAppendStream函数是吧Table对象转换成DataStream对象。 - -#### 4.分组聚合 - -​ 举例:我们统计每个基站的日志数量。 - -```scala -val table: Table = tableEvn.fromDataStream(data) - tableEvn.toRetractStream[Row]( - table.groupBy('sid).select('sid, 'sid.count as 'logCount)) - .filter(_._1==true) //返回的如果是true才是Insert的数据 - .print() -``` - -在代码中可以看出,使用toAppendStream和toRetractStream方法将Table转换为DataStream[T]数据集,T可以是Flink自定义的数据格式类型Row,也可以是用户指定的数据格式类型。在使用toRetractStream方法时,返回的数据类型结果为DataStream[(Boolean,T)],Boolean类型代表数据更新类型,True对应INSERT操作更新的数据,False对应DELETE操作更新的数据。 - -#### 5.UDF自定义的函数 - -用户可以在Table API中自定义函数类,常见的抽象类和接口是: - -- ScalarFunction -- TableFunction -- AggregateFunction -- TableAggregateFunction - -案例:使用Table完成基于流的WordCount - -```scala -object TableAPITest2 { - - def main(args: Array[String]): Unit = { - val streamEnv: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment - streamEnv.setParallelism(1) - //初始化Table API的上下文环境 - val tableEvn =StreamTableEnvironment.create(streamEnv) - //导入隐式转换,建议写在这里,可以防止IDEA代码提示出错的问题 - import org.apache.flink.streaming.api.scala._ - import org.apache.flink.table.api.scala._ - - val stream: DataStream[String] = streamEnv.socketTextStream("hadoop101",8888) - val table: Table = tableEvn.fromDataStream(stream,'words) - var my_func =new MyFlatMapFunction()//自定义UDF - val result: Table = table.flatMap(my_func('words)).as('word, 'count) - .groupBy('word) //分组 - .select('word, 'count.sum as 'c) //聚合 - tableEvn.toRetractStream[Row](result) - .filter(_._1==true) - .print() - - tableEvn.execute("table_api") - - } - //自定义UDF - class MyFlatMapFunction extends TableFunction[Row]{ - //定义类型 - override def getResultType: TypeInformation[Row] = { - Types.ROW(Types.STRING, Types.INT) - } - //函数主体 - def eval(str:String):Unit ={ - str.trim.split(" ") - .foreach({word=>{ - var row =new Row(2) - row.setField(0,word) - row.setField(1,1) - collect(row) - }}) - } - } -} -``` - -#### 6.Window - -​ Flink支持ProcessTime、EventTime和IngestionTime三种时间概念,针对每种时间概念,Flink Table API中使用Schema中单独的字段来表示时间属性,当时间字段被指定后,就可以在基于时间的操作算子中使用相应的时间属性。 - -在Table API中通过使用.rowtime来定义EventTime字段,在ProcessTime时间字段名后使用.proctime后缀来指定ProcessTime时间属性. - -案例:统计最近5秒钟,每个基站的呼叫数量 - -```scala -object TableAPITest { - - def main(args: Array[String]): Unit = { - val streamEnv: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment - //指定EventTime为时间语义 - streamEnv.setStreamTimeCharacteristic(TimeCharacteristic.EventTime) - streamEnv.setParallelism(1) - //初始化Table API的上下文环境 - val tableEvn =StreamTableEnvironment.create(streamEnv) - //导入隐式转换,建议写在这里,可以防止IDEA代码提示出错的问题 - import org.apache.flink.streaming.api.scala._ - import org.apache.flink.table.api.scala._ - - val data = streamEnv.socketTextStream("hadoop101",8888) - .map(line=>{ - var arr =line.split(",") - new StationLog(arr(0).trim,arr(1).trim,arr(2).trim,arr(3).trim,arr(4).trim.toLong,arr(5).trim.toLong) - }) - .assignTimestampsAndWatermarks( //引入Watermark - new BoundedOutOfOrdernessTimestampExtractor[StationLog](Time.seconds(2)){//延迟2秒 - override def extractTimestamp(element: StationLog) = { - element.callTime - } - }) - - //设置时间属性 - val table: Table = tableEvn.fromDataStream(data,'sid,'callOut,'callIn,'callType,'callTime.rowtime) - //滚动Window ,第一种写法 - val result: Table = table.window(Tumble over 5.second on 'callTime as 'window) - //第二种写法 - val result: Table = table.window(Tumble.over("5.second").on("callTime").as("window")) - .groupBy('window, 'sid) - .select('sid, 'window.start, 'window.end, 'window.rowtime, 'sid.count) - //打印结果 - tableEvn.toRetractStream[Row](result) - .filter(_._1==true) - .print() - - - tableEvn.execute("sql") - } -} -``` - -上面的案例是滚动窗口,如果是滑动窗口也是一样,代码如下: - -```scala -//滑动窗口,窗口大小为:10秒,滑动步长为5秒 :第一种写法 -table.window(Slide over 10.second every 5.second on 'callTime as 'window) -//滑动窗口第二种写法 table.window(Slide.over("10.second").every("5.second").on("callTime").as("window")) -``` - -### Flink SQL - -**企业中Flink SQL比Table API用的多**。 - - -Flink SQL 是 Apache Flink 提供的一种使用 SQL 查询和处理数据的方式。它允许用户通过 SQL 语句对数据流或批处理数据进行查询、转换和分析,无需编写复杂的代码。Flink SQL 提供了一种更直观、易于理解和使用的方式来处理数据,同时也可以与 Flink 的其他功能无缝集成。 - -Flink SQL 支持 ANSI SQL 标准,并提供了许多扩展和优化来适应流式处理和批处理场景。它能够处理无界数据流,具备事件时间和处理时间的语义,支持窗口、聚合、连接等常见的数据操作,还提供了丰富的内置函数和扩展插件机制。 - -下面是一个简单的 Flink SQL 代码示例,展示了如何使用 Flink SQL 对流式数据进行查询和转换。 - -```java -import org.apache.flink.api.common.serialization.SimpleStringSchema; -import org.apache.flink.streaming.api.datastream.DataStream; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.functions.sink.SinkFunction; -import org.apache.flink.streaming.api.functions.source.SourceFunction; -import org.apache.flink.streaming.api.functions.source.SourceFunction.SourceContext; -import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer; - -import java.util.Properties; - -public class FlinkSqlExample { - - public static void main(String[] args) throws Exception { - StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); - env.setParallelism(1); // 设置并行度为1,方便观察输出结果 - - // 创建 Kafka 数据源 - Properties properties = new Properties(); - properties.setProperty("bootstrap.servers", "localhost:9092"); - properties.setProperty("group.id", "flink-consumer"); - - FlinkKafkaConsumer kafkaConsumer = new FlinkKafkaConsumer<>("input-topic", new SimpleStringSchema(), properties); - DataStream sourceStream = env.addSource(kafkaConsumer); - - // 注册数据源表 - env.createTemporaryView("source_table", sourceStream, "message"); - - // 执行 SQL 查询和转换 - String query = "SELECT message, COUNT(*) AS count FROM source_table GROUP BY message"; - DataStream resultStream = env.sqlQuery(query).map(value -> new Result(value.getField(0), value.getField(1))); - - // 打印结果 - resultStream.print(); - - env.execute("Flink SQL Example"); - } - - // 自定义结果类 - public static class Result { - public String message; - public Long count; - - public Result() {} - - public Result(String message, Long count) { - this.message = message; - this.count = count; - } - - @Override - public String toString() { - return "Result{" + - "message='" + message + '\'' + - ", count=" + count + - '}'; - } - } -} -``` - -在上述示例中,我们使用 Apache Kafka 作为数据源,并创建了一个消费者从名为 "input-topic" 的 Kafka 主题中读取数据。然后,我们将数据流注册为名为 "source_table" 的临时表。 - -接下来,我们使用 Flink SQL 执行 SQL 查询和转换。在这个例子中,我们查询 "source_table" 表,对 "message" 字段进行分组并计算每个消息出现的次数。查询结果会映射到自定义的 `Result` 类,并最终通过 `print()` 方法打印到标准输出。 - -最后,我们通过调用 `env.execute()` 方法来启动 Flink 作业的执行。 - -## Flink的复杂事件处理CEP - -复杂事件处理(CEP)是一种基于流处理的技术,将系统数据看作不同类型的事件,通过分析事件之间的关系,建立不同的事件关系序列库,并利用过滤、关联、聚合等技术,最终由简单事件产生高级事件,并通过模式规则的方式对重要信息进行跟踪和分析,从实时数据中发掘有价值的信息。复杂事件处理主要应用于防范网络欺诈、设备故障检测、风险规避和智能营销等领域。Flink基于DataStrem API提供了FlinkCEP组件栈,专门用于对复杂事件的处理,帮助用户从流式数据中发掘有价值的信息。 - -CEP(Complex Event Processing)就是在无界事件流中检测事件模式,让我们掌握数据中重要的部分。flink CEP是在flink中实现的复杂事件处理库。 - -### CEP相关概念 - -#### 配置依赖 - -在使用FlinkCEP组件之前,需要将FlinkCEP的依赖库引入项目工程中。 - -``` xml - - org.apache.flink - flink-cep-scala_2.11 - 1.9.1 - - - - org.apache.flink - flink-cep-scala_2.11 - 1.9.1 - -``` - -#### 事件定义 - -- 简单事件:简单事件存在于现实场景中,主要的特点为处理单一事件,事件的定义可以直接观察出来,处理过程中无须关注多个事件之间的关系,能够通过简单的数据处理手段将结果计算出来。 -- 复杂事件:相对于简单事件,复杂事件处理的不仅是单一的事件,也处理由多个事件组成的复合事件。复杂事件处理监测分析事件流(Event Streaming),当特定事件发生时来触发某些动作。 - -​ 复杂事件中事件与事件之间包含多种类型关系,常见的有时序关系、聚合关系、层次关系、依赖关系及因果关系等。 - -### Pattern API - -Flink CEP中提供了Pattern API用于对输入流数据的复杂事件规则定义,并从事件流中抽取事件结果。包含四个步骤: - -1. 输入事件流的创建 -2. Pattern的定义 -3. Pattern应用在事件流上检测 -4. 选取结果 - -#### 模式定义 - -定义Pattern可以是单次执行模式,也可以是循环执行模式。单词执行模式一次只接受一个事件,循环执行模式可以接收一个或者多个事件。通常情况下,可以通过指定循环次数将单次执行模式变为循环执行模式。每种模式能够将多个条件组合应用到同一事件之上,条件组合可以通过where方法进行叠加。每个Pattern都是通过begin方法定义的 - -```scala -val start = Pattern.begin[Event]("start_pattern") -``` - -下一步通过Pattern.where()方法在Pattern上指定Condition,只有当Condition满足之后,当前的Pattern才会接受事件。 - -```scala -start.where(_.getCallType == "success") -``` - -#### 设置循环次数 - -对于已经创建好的Pattern,可以指定循环次数,形成循环执行的Pattern。 - -- times:可以通过times指定固定的循环执行次数。 - - -```scala -//指定循环触发4次 -start.times(4); -//可以执行触发次数范围,让循环执行次数在该范围之内 -start.times(2, 4); -``` - - -- optional:也可以通过optional关键字指定要么不触发要么触发指定的次数。 - -```scala - start.times(4).optional(); - start.times(2, 4).optional(); -``` - -- greedy:可以通过greedy将Pattern标记为贪婪模式,在Pattern匹配成功的前提下,会尽可能多地触发。 - -```scala - //触发2、3、4次,尽可能重复执行 - start.times(2, 4).greedy(); - //触发0、2、3、4次,尽可能重复执行 - start.times(2, 4).optional().greedy(); -``` - - -- oneOrMore:可以通过oneOrMore方法指定触发一次或多次。 - -``` - // 触发一次或者多次 - start.oneOrMore(); - //触发一次或者多次,尽可能重复执行 - start.oneOrMore().greedy(); - // 触发0次或者多次 - start.oneOrMore().optional(); - // 触发0次或者多次,尽可能重复执行 - start.oneOrMore().optional().greedy(); -``` - - -- timesOrMore:通过timesOrMore方法可以指定触发固定次数以上,例如执行两次以上。 - -``` -// 触发两次或者多次 - start.timesOrMore(2); - // 触发两次或者多次,尽可能重复执行 - start.timesOrMore(2).greedy(); - // 不触发或者触发两次以上,尽可能重复执行 - start.timesOrMore(2).optional().greedy(); -``` - -#### 定义条件 - -每个模式都需要指定触发条件,作为事件进入到该模式是否接受的判断依据,当事件中的数值满足了条件时,便进行下一步操作。在FlinkCFP中通过pattern.where()、pattern.or()及pattern.until()方法来为Pattern指定条件,且Pattern条件有Simple Conditions及Combining Conditions等类型。 - -- 简单条件:Simple Condition继承于Iterative Condition类,其主要根据事件中的字段信息进行判断,决定是否接受该事件。 - -``` - // 把通话成功的事件挑选出来 - start.where(_.getCallType == "success") -``` - - -- 组合条件:组合条件是将简单条件进行合并,通常情况下也可以使用where方法进行条件的组合,默认每个条件通过AND逻辑相连。如果需要使用OR逻辑,直接使用or方法连接条件即可。 - -```scala - // 把通话成功,或者通话时长大于10秒的事件挑选出来 - val start = Pattern.begin[StationLog]("start_pattern") - .where(_.callType=="success") - .or(_.duration>10) -``` - - -- 终止条件:如果程序中使用了oneOrMore或者oneOrMore().optional()方法,则必须指定终止条件,否则模式中的规则会一直循环下去,如下终止条件通过until()方法指定。 - -``` - pattern.oneOrMore.until(_.callOut.startsWith("186")) -``` - -#### 模式序列 - -将相互独立的模式进行组合然后形成模式序列。模式序列基本的编写方式和独立模式一致,各个模式之间通过邻近条件进行连接即可,其中有严格邻近、宽松邻近、非确定宽松邻近三种邻近连接条件。 - -- 严格邻近:严格邻近条件中,需要所有的事件都按照顺序满足模式条件,不允许忽略任意不满足的模式。 - -`val strict: Pattern[Event] = start.next("middle").where(...)` - -- 宽松邻近:在宽松邻近条件下,会忽略没有成功匹配模式条件,并不会像严格邻近要求得那么高,可以简单理解为OR的逻辑关系。 - -`val relaxed: Pattern[Event, _] = start.followedBy("middle").where(...)` - -- 非确定宽松邻近:和宽松邻近条件相比,非确定宽松邻近条件指在模式匹配过程中可以忽略已经匹配的条件。 - -`val nonDetermin: Pattern[Event, _] = start.followedByAny("middle").where(...)` - -- 除以上模式序列外,还可以定义“不希望出现某种近邻关系”: - - -​ .notNext() —— 不想让某个事件严格紧邻前一个事件发生。 - -​ .notFollowedBy() —— 不想让某个事件在两个事件之间发生。 - -**注意**: - -1. 所有模式序列必须以 .begin() 开始 - -2. 模式序列不能以 .notFollowedBy() 结束 - -3. “not” 类型的模式不能被 optional 所修饰 - -4. 此外,还可以为模式指定时间约束,用来要求在多长时间内匹配有效 - - ```java - //指定模式在10秒内有效 - pattern.within(Time.seconds(10)); - ``` - -#### 模式检测 - -调用 CEP.pattern(),给定输入流和模式,就能得到一个 PatternStream - -```scala -//cep 做模式检测 -val patternStream = CEP.pattern[EventLog](dataStream.keyBy(_.id),pattern) -``` - -#### 选择结果 - -得到PatternStream类型的数据集后,接下来数据获取都基于PatternStream进行。该数据集中包含了所有的匹配事件。目前在FlinkCEP中提供select和flatSelect两种方法从PatternStream提取事件结果事件。 - -**通过Select Funciton抽取正常事件** - -可以通过在PatternStream的Select方法中传入自定义Select Funciton完成对匹配事件的转换与输出。其中Select Funciton的输入参数为Map[String, Iterable[IN]],Map中的key为模式序列中的Pattern名称,Value为对应Pattern所接受的事件集合,格式为输入事件的数据类型。 - -```scala -def selectFunction(pattern : Map[String, Iterable[IN]]): OUT = { -//获取pattern中的startEvent -val startEvent = pattern.get("start_pattern").get.next -//获取Pattern中middleEvent -val middleEvent = pattern.get("middle").get.next -//返回结果 -OUT(startEvent, middleEvent)} -``` - -**通过Flat Select Funciton抽取正常事件** - -​ Flat Select Funciton和Select Function相似,不过Flat Select Funciton在每次调用可以返回任意数量的结果。因为Flat Select Funciton使用Collector作为返回结果的容器,可以将需要输出的事件都放置在Collector中返回。 - -```scala -def flatSelectFn(pattern : Map[String, Iterable[IN]], collector : Collector[OUT]) = { //获取pattern中startEvent -val startEvent = pattern.get("start_pattern").get.next -//获取Pattern中middleEvent -val middleEvent = pattern.get("middle").get.next -//并根据startEvent的Value数量进行返回 -for (i <- 0 to startEvent.getValue) { - collector.collect(OUT(startEvent, middleEvent)) -}} -``` - -**通过Select Funciton抽取超时事件** - -如果模式中有within(time),那么就很有可能有超时的数据存在,通过PatternStream. Select方法分别获取超时事件和正常事件。首先需要创建OutputTag来标记超时事件,然后在PatternStream.select方法中使用OutputTag,就可以将超时事件从PatternStream中抽取出来。 - -```scala -// 通过CEP.pattern方法创建 -PatternStream val patternStream: PatternStream[Event] = CEP.pattern(input, pattern) //创建OutputTag,并命名为timeout-output -val timeoutTag = OutputTag[String]("timeout-output") -//调用PatternStream select()并指定timeoutTag val result: SingleOutputStreamOperator[NormalEvent] = patternStream.select(timeoutTag){ -//超时事件获取 -(pattern: Map[String, Iterable[Event]], timestamp: Long) => -TimeoutEvent()//返回异常事件 -} { -//正常事件获取 -pattern: Map[String, Iterable[Event]] => -NormalEvent() -//返回正常事件 -} -//调用getSideOutput方法,并指定timeoutTag将超时事件输出val timeoutResult: DataStream[TimeoutEvent] = result.getSideOutput(timeoutTag) -``` - -## Flink内存优化 - -在大数据领域,大多数开源框架(Hadoop、Spark、Flink)都是基于JVM运行,但是JVM的内存管理机制往往存在着诸多类似OutOfMemoryError的问题,主要是因为创建过多的对象实例而超过JVM的最大堆内存限制,却没有被有效回收掉,这在很大程度上影响了系统的稳定性,尤其对于大数据应用,面对大量的数据对象产生,仅仅靠JVM所提供的各种垃圾回收机制很难解决内存溢出的问题。在开源框架中有很多框架都实现了自己的内存管理,例如Apache Spark的Tungsten项目,在一定程度上减轻了框架对JVM垃圾回收机制的依赖,从而更好地使用JVM来处理大规模数据集。 - -**Flink也基于JVM实现了自己的内存管理,将JVM根据内存区分为Unmanned Heap、Flink Managed Heap、Network Buffers三个区域**。在Flink内部对Flink Managed Heap进行管理,在启动集群的过程中直接将堆内存初始化成Memory Pages Pool,也就是将内存全部以二进制数组的方式占用,形成虚拟内存使用空间。新创建的对象都是以序列化成二进制数据的方式存储在内存页面池中,当完成计算后数据对象Flink就会将Page置空,而不是通过JVM进行垃圾回收,保证数据对象的创建永远不会超过JVM堆内存大小,也有效地避免了因为频繁GC导致的系统稳定性问题。 - -### JobManager配置 - -JobManager在Flink系统中主要承担管理集群资源、接收任务、调度Task、收集任务状态以及管理TaskManager的功能,JobManager本身并不直接参与数据的计算过程中,因此JobManager的内存配置项不是特别多,只要指定JobManager堆内存大小即可。 - -`jobmanager.heap.size:设定JobManager堆内存大小,默认为1024MB。` - -### TaskManager配置 - -TaskManager作为Flink集群中的工作节点,所有任务的计算逻辑均执行在TaskManager之上,因此对TaskManager内存配置显得尤为重要,可以通过以下参数配置对TaskManager进行优化和调整。 - -- **taskmanager.heap.size**:设定TaskManager堆内存大小,默认值为1024M,如果在Yarn的集群中,TaskManager取决于Yarn分配给TaskManager Container的内存大小,且Yarn环境下一般会减掉一部分内存用于Container的容错。 - -- **taskmanager.jvm-exit-on-oom**:设定TaskManager是否会因为JVM发生内存溢出而停止,默认为false,当TaskManager发生内存溢出时,也不会导致TaskManager停止。 - -- **taskmanager.memory.size**:设定TaskManager内存大小,默认为0,如果不设定该值将会使用taskmanager.memory.fraction作为内存分配依据。 - -- **taskmanager.memory.fraction**:设定TaskManager堆中去除Network Buffers内存后的内存分配比例。该内存主要用于TaskManager任务排序、缓存中间结果等操作。例如,如果设定为0.8,则代表TaskManager保留80%内存用于中间结果数据的缓存,剩下20%的内存用于创建用户定义函数中的数据对象存储。注意,该参数只有在taskmanager.memory.size不设定的情况下才生效。 - -- **taskmanager.memory.off-heap**:设置是否开启堆外内存供Managed Memory或者Network Buffers使用。 - -- **taskmanager.memory.preallocate**:设置是否在启动TaskManager过程中直接分配TaskManager管理内存。 - -- **taskmanager.numberOfTaskSlots**:每个TaskManager分配的slot数量。 - -### Flink的网络缓存优化 - -Flink将JVM堆内存切分为三个部分,其中一部分为Network Buffers内存。Network Buffers内存是Flink数据交互层的关键内存资源,主要目的是缓存分布式数据处理过程中的输入数据。。通常情况下,比较大的Network Buffers意味着更高的吞吐量。如果系统出现“Insufficient number of network buffers”的错误,一般是因为Network Buffers配置过低导致,因此,在这种情况下需要适当调整TaskManager上Network Buffers的内存大小,以使得系统能够达到相对较高的吞吐量。 - -目前Flink能够调整Network Buffer内存大小的方式有两种:一种是通过直接指定Network Buffers内存数量的方式,另外一种是通过配置内存比例的方式。 - -#### 设定Network Buffer内存数量(过时了) - -直接设定Nework Buffer数量需要通过如下公式计算得出: - -`NetworkBuffersNum = total-degree-of-parallelism \* intra-node-parallelism * n` - -其中total-degree-of-parallelism表示每个TaskManager的总并发数量,intra-node-parallelism表示每个TaskManager输入数据源的并发数量,n表示在预估计算过程中Repar-titioning或Broadcasting操作并行的数量。intra-node-parallelism通常情况下与Task-Manager的所占有的CPU数一致,且Repartitioning和Broadcating一般下不会超过4个并发。可以将计算公式转化如下: - -`NetworkBuffersNum = ^2 \* < TMs>* 4` - -其中slots-per-TM是每个TaskManager上分配的slots数量,TMs是TaskManager的总数量。对于一个含有20个TaskManager,每个TaskManager含有8个Slot的集群来说,总共需要的Network Buffer数量为8^2**20*4=5120个,因此集群中配置Network Buffer内存的大小约为160M较为合适。 - -计算完Network Buffer数量后,可以通过添加如下两个参数对Network Buffer内存进行配置。其中segment-size为每个Network Buffer的内存大小,默认为32KB,一般不需要修改,通过设定numberOfBuffers参数以达到计算出的内存大小要求。 - -- **taskmanager.network.numberOfBuffers**:指定Network堆栈Buffer内存块的数量。 - -- **taskmanager.memory.segment-size**:内存管理器和Network栈使用的内存Buffer大小,默认为32KB。 - -#### 设定Network内存比例(推荐) - -从1.3版本开始,Flink就提供了通过指定内存比例的方式设置Network Buffer内存大小。 - -- **taskmanager.network.memory.fraction**:JVM中用于Network Buffers的内存比例。 - -- **taskmanager.network.memory.min**:最小的Network Buffers内存大小,默认为64MB。 - -- **taskmanager.network.memory.max**:最大的Network Buffers内存大小,默认1GB。 - -- **taskmanager.memory.segment-size**:内存管理器和Network栈使用的Buffer大小,默认为32KB。 diff --git "a/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2064W\345\255\227Flink\345\205\250\351\235\242\350\247\243\346\236\220\344\270\216\345\256\236\350\267\265(\344\270\212).md" "b/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2064W\345\255\227Flink\345\205\250\351\235\242\350\247\243\346\236\220\344\270\216\345\256\236\350\267\265(\344\270\212).md" new file mode 100644 index 0000000..23d8552 --- /dev/null +++ "b/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2064W\345\255\227Flink\345\205\250\351\235\242\350\247\243\346\236\220\344\270\216\345\256\236\350\267\265(\344\270\212).md" @@ -0,0 +1,1521 @@ +**注:原文字数过多,单篇阅读时间过长,故将文章拆分为上下两篇** + +在大数据技术栈的探索中,我们曾讨论了离线计算的Spark,而当谈到实时计算,就不得不提Flink。本文将集中讨论Flink,旨在详尽展示其核心概念,从而助力你在大数据旅程中向前迈进。 + +值得注意的是,Flink和Spark有许多相似的概念。因此,在深入学习Flink之前,建议先浏览我之前关于Spark的文章,这将为你提供扎实的基础,并帮助在学习Flink时能更好地举一反三,加深对其理解。 + +话不多说,开启我们的Flink学习之旅。 + + +## 流处理 & 批处理 + +在我们深入探讨Flink之前,首先要掌握一些流计算的基础概念。 + +- **流处理**:流处理主要针对的是数据流,特点是无界、实时,对系统传输的每个数据依次执行操作,一般用于实时统计。在流处理中,数据被视为无限连续的流,并且会尽快地进行处理。Flink在此模型下可以提供秒级甚至毫秒级的延迟,使其成为需要快速反应和决策的场景(例如实时推荐、欺诈检测等)的理想选择。 +- **批处理**:批处理,也叫作离线处理,一般用于离线统计。这是一种处理存储在系统中的静态数据集的模型。在批处理中,所有数据都被看作是一个有限集合,处理过程通常在非交互式模式下进行,即作业开始时所有数据都已经可用,作业结束时给出所有计算结果。由于批处理允许对整个数据集进行全面分析,因此它适合于需要长期深度分析的场景(如历史数据分析、大规模ETL任务等)。 + +事实上 Flink 本身是流批统一的处理架构,批量的数据集本质上也是流。 + +**在 Flink 的视角里,一切数据都可以认为是流,流数据是无界流,而批数据则是有界流** + +### 无界流Unbounded Streams + +无界流有定义流的开始,但没有定义流的结束。它们会无休止地产生数据。无界流的数据必须持续处理,即数据被摄取后需要立刻处理。 + +我们不能等到所有数据都到达再处理,因为输入是无限的,在任何时候输入都不会完成。 + +处理无界数据通常要求以特定顺序摄取事件,例如事件发生的顺序,以便能够推断结果的完整性。 + +### 有界流Bounded Streams + +有界流有定义流的开始,也有定义流的结束。有界流可以在摄取所有数据后再进行计算,有界流所有数据可以被排序,所以并不需要有序摄取。 + +有界流处理通常被称为批处理。所以在Flink里批计算其实指的就是有界流。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnjPQcmW39R8wibMMpqpQGLoiaMFiakpuFUajrXE8dL0yHwib8icmg6Y1fib5Tv06EnMMtH7jtXJJQVAMQ/640) + +## Flink的特点和优势 + +Flink具有如下特点和优势: + +- 同时支持高吞吐、低延迟、高性能。 +- 支持事件时间(Event Time)概念,结合Watermark处理乱序数据。 +- 支持有状态计算,并且支持多种状态内存、 文件、RocksDB。 +- 支持高度灵活的窗口(Window) 操作 time、 count、 session等。 +- 基于轻量级分布式快照(CheckPoint) 实现的容错保证Exactly- Once语义。 +- 基于JVM实现独立的内存管理。 + +## Flink VS Spark + +Spark 和 Flink 在不同的应用领域上表现会有差别。 + +一般来说,Spark 基于微批处理的方式做同步总有一个“攒批”的过程,所以会有额外开销,因此无法在流处理的低延迟上做到极致。 + +**在低延迟流处理场景,Flink 已经有明显的优势。而在海量数据的批处理领域,Spark 能够处理的吞吐量更大** + +另外,Spark Streaming中的流计算其实是微批计算,实时性不如Flink,还有一点很重要的是Spark Streaming不适合有状态的计算,得借助一些存储如:Redis,才能实现。而Flink天然支持有状态的计算。 + +## Flink API + +Flink 本身提供了多层 API: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLp9EtLdWwjR2aiaWXxBr5q1JOc9Q1k8P7OibcCvlYxib3HzE9VQKzx3ktAg/640) + +- **Stateful Stream Processing** :最低级的抽象接口是状态化的数据流接口(stateful streaming)。这个接口是通过 ProcessFunction 集成到 DataStream API 中的。该接口允许用户自由的处理来自一个或多个流中的事件,并使用一致的容错状态。另外,用户也可以通过注册 event time 和 processing time 处理回调函数的方法来实现复杂的计算。 +- **DataStream/DataSet API**: DataStream / DataSet API 是 Flink 提供的核心 API ,DataSet 处理有界的数据集,DataStream 处理有界或者无界的数据流。用户可以通过各种方法(map / flatmap / window / keyby / sum / max / min / avg / join 等)将数据进行转换 / 计算。 +- **Table API**: Table API 提供了例如 select、project、join、group-by、aggregate 等操作,使用起来却更加简洁,可以在表与 DataStream/DataSet 之间无缝切换,也允许程序将 Table API 与 DataStream 以及 DataSet 混合使用。 +- **SQL**: Flink 提供的最高层级的抽象是 Flink SQL,这一层抽象在语法与表达能力上与 Table API 类似,SQL 抽象与 Table API 交互密切,同时 SQL 查询可以直接在 Table API 定义的表上执行。 + +## Dataflows数据流图 + +所有的 Flink 程序都可以归纳为由三部分构成:`Source`、`Transformation` 和 `Sink`。 + +- Source 表示“源算子”,负责读取数据源。 + +- Transformation 表示“转换算子”,利用各种算子进行处理加工。 + +- Sink 表示“下沉算子”,负责数据的输出。 + +Source数据源会源源不断的产生数据,Transformation将产生的数据进行各种业务逻辑的数据处理,最终由Sink输出到外部(console、kafka、redis、DB......)。 + +所有基于Flink开发的程序都能够映射成一个Dataflows(数据流图): + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLptqzt0pibVTiazpib2DbAt6DJKrqnSX5iaJdgt2ShOAF8ibGz7MBVScwiaxCQ/640) + +当Source数据源的数量比较大或计算逻辑相对比较复杂的情况下,需要提高并行度来处理数据,采用并行数据流。 + +通过设置不同算子的并行度,比如 Source并行度设置为2 ,map也是2。代表会启动2个并行的线程来处理数据: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpCaXHUMG7RLn3A4FX2Pq4SqcvwdAFhN74T5lcquFZEA37untZibCObsw/640) + +## Job Manager & Task Manager + +Flink是一个典型的Master-Slave架构,架构中包含了两个重要角色,分别是「**JobManager**」和「**TaskManager**」。 + +JobManager相当于是Master,TaskManager相当于是Slave。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpvf34vfkbzvdq19zWJoAMNNMVJkWI5tgc1r2Xwcc0w07sswUricnibNQA/640) + + + +在Flink中,JobManager负责整个Flink集群任务的调度以及资源的管理。它从客户端中获取提交的应用,然后根据集群中TaskManager上TaskSlot的使用情况,为提交的应用分配相应的TaskSlot资源并命令TaskManager启动从客户端中获取的应用。 + +TaskManager则负责执行作业流的Task,并且缓存和交换数据流。 + +在TaskManager中资源调度的最小单位是Task slot。TaskManager中Task slot的数量表示并发处理Task的数量。 + +**一台机器节点可以运行多个TaskManager,TaskManager工作期间会向JobManager发送心跳保持连接** + +## 部署 & 运行 + +### 部署模式 + +Flink支持多种部署模式,包括本地模式、Standalone模式、YARN模式、Mesos模式和Kubernetes模式。 + +- **本地模式**:本地模式是在单个JVM中启动Flink,主要用于开发和测试。它不需要任何集群管理器,但也不能跨多台机器运行。本地模式的优点是部署简单,缺点是不能利用分布式计算的优势。 +- **Standalone模式**:Standalone模式是在一个独立的集群中运行Flink。它需要手动启动Flink集群,并且需要手动管理资源。Standalone模式的优点是部署简单,可以跨多台机器运行,缺点是需要手动管理资源。 +- **YARN模式**:在这个模式下,Flink作为YARN的一个应用程序运行在YARN集群中。Flink会从YARN获取所需的资源来运行JobManager和TaskManager。如果你已经有了一个运行Hadoop/YARN的大数据平台,选择这个模式可以方便地利用已有的资源,这是企业中用的比较多的方式。 +- **Mesos模式**:Mesos是一个更通用的集群管理框架,可以运行非Java应用程序,并具有良好的容错性和伸缩性。Flink在Mesos上的运行方式与在YARN上类似,也是从Mesos请求资源来运行JobManager和TaskManager。 +- **Kubernetes模式**:Kubernetes是一个开源的容器编排平台。Flink可以作为一组分布式的Docker容器在Kubernetes集群上运行。Kubernetes提供了自动化、扩展和管理应用程序容器的功能,对于云原生应用程序部署非常合适。 + +每种部署模式都有其优缺点,选择哪种部署模式取决于具体的应用场景和需求。 + +### 运行模式 + +Flink 有三种不同的运行模式:Session、Per-Job 和 Application。 + +- **Session**:在这种模式下,一个 Flink 集群会被启动且会一直运行,直到明确地被终止。用户可以在这个集群中提交多个作业。这个模式适合多个短作业的场景。 +- **Per-Job**:在这种模式下,对于每个提交的作业,都会启动一个新的 Flink 集群,然后再执行该作业。作业完成后,相应的 Flink 集群也会被终止。这种模式适合长时间运行的作业。 +- **Application**:这种模式是一种特殊的 Per-Job 模式,它允许用户以反应式的方式与作业进行交互(比如,使用 DataStream API)。这是 Flink 1.11 版本引入的新模式,它结合了Session模式和Per-Job模式的优点。在Application模式下,每个作业都会启动一个独立的Flink集群,但是作业提交快。 + +以上所述的部署环境可以与任何一种运行模式结合使用。例如,你可以在本地模式、Standalone 模式或 YARN 模式下运行 Session、Per-Job 或 Application 模式的 Flink 作业。 + +### 提交和执行作业流程 + +Flink在不同运行模型下的作业提交和执行流程大致如下: + +- **Session 模式**: + + - 启动Flink集群:在Session模式下,首先需要启动一个运行中的Flink集群。这个集群可以是Standalone Session Cluster,也可以是在Yarn或Kubernetes等资源管理器上的Session Cluster。 + - 作业提交:然后,用户通过Flink客户端(例如CLI、REST API或Web UI)将作业提交给Flink Dispatcher服务。Dispatcher服务是Flink集群的主要入口点,负责接收和协调作业请求。 + - 作业解析与优化:一旦Flink Dispatcher接收到作业,它会对作业执行图(JobGraph)进行解析,并使用Flink的优化器对执行图进行优化。 + - 创建作业执行环境:Dispatcher会为新的作业创建一个JobManager,这个JobManager就是一次作业的执行环境。并且,每个Job都有属于自己的JobManager。 + - 作业执行:JobManager将优化后的执行图发送到TaskManager节点来执行具体的任务。TaskManager节点包含若干个slot,每个slot可以运行作业图中的一个并行操作。 + - 结果和状态管理:作业执行过程中,输出结果被发送回JobManager,并提供给用户。同时,作业的状态也由JobManager管理,以支持故障恢复。 + + 当你的作业完成运行后,该作业的JobManager会被停止,但是Flink集群(包括Dispatcher和其余的TaskManager)仍然处于运行状态,等待新的作业提交。这就是所谓的Session模式,它允许在同一个Flink集群上连续运行多个作业。 +- **Per-Job 模式**: + + - 用户通过命令行或者UI将程序包含所有依赖提交到Flink集群。 + - Flink Master节点接收到用户提交的作业后,会启动一个新的JobManager来负责这个作业的资源管理与任务调度。 + - JobManager通过ResourceManager向Flink集群请求所需的TaskManager资源。 + - ResourceManager分配TaskManager给JobManager,并启动TaskManager进程。 + - TaskManager向JobManager注册并提供自己的状态及可用的slot信息。 + - JobManager根据程序的DAG图计算出ExecutionGraph,然后按照stages将相应的tasks分配到TaskManager的Slots中去执行。 + - 如果作业执行完毕或执行失败,JobManager会释放所有资源,并将结果返回给用户。 + + 在Per-Job模式下,每个作业都有自己的资源隔离,互不干扰,资源利用率较高,但是如果作业数量大,则可能会因为每个作业都需要单独申请、释放资源导致效率较低。 +- **Application 模式**: + + - 构建Flink Job:客户端或者用户在本地环境上构建Flink作业。 + - 提交Flink Job:通过Flink命令行工具或者Web UI,将序列化后的JobGraph提交到Flink集群。也可以通过REST API直接提交作业。 + - JobManager接收Job:JobManager是Flink中负责任务调度和协调的组件。它会接收到提交的JobGraph,并将其封装成ExecutionGraph。 + - 任务调度:JobManager会根据ExecutionGraph对任务进行调度,决定何时启动任务,以及哪个TaskManager上启动任务。 + - 任务执行:TaskManager接收到JobManager分配的任务后开始执行。每个TaskManager包含一到多个Slot,这些Slot用于运行任务。 + - 状态反馈:TaskManager在执行任务过程中会将状态信息(如进度、日志等)反馈给JobManager。 + - 结果返回:当所有任务执行完成后,JobManager会将执行结果返回给客户端。 + +## 配置开发环境 + +每个 Flink 应用都需要依赖一组 Flink 类库。Flink 应用至少需要依赖 Flink APIs。许多应用还会额外依赖连接器类库(比如 Kafka、Cassandra 等)。 当用户运行 Flink 应用时(无论是在 IDEA 环境下进行测试,还是部署在分布式环境下),运行时类库都必须可用。 + +开发工具:IntelliJ IDEA + +以Java语言为例,配置开发Maven依赖: + +```xml + + 1.13.6 + 2.12 + 1.8 + 1.8 + + + + + org.apache.flink + flink-java + ${flink.version} + + + org.apache.flink + flink-streaming-java_${scala.binary.version} + ${flink.version} + + + org.apache.flink + flink-clients_${scala.binary.version} + ${flink.version} + + +``` + +**注意点**: + +- 如果要将程序打包提交到集群运行,打包的时候不需要包含这些依赖,因为集群环境已经包含了这些依赖,此时依赖的作用域应该设置为`provided`。 +- Flink 应用在 IntelliJ IDEA 中运行,这些 Flink 核心依赖的作用域需要设置为 `compile` 而不是 `provided` 。 否则 IntelliJ 不会添加这些依赖到 classpath,会导致应用运行时抛出 `NoClassDefFountError` 异常。 + +添加打包插件: + +```xml + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + + + org.apache.flink:force-shading + com.google.code.findbugs:jsr305 + org.slf4j:* + log4j:* + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + 1.8 + 1.8 + + + + +``` + +### WordCount程序 + +配置好开发环境之后我们来写一个简单的Flink程序。 + +需求:统计单词出现的次数 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 设置并行度为 1 + env.setParallelism(1); + // 构建输入数据流 + DataStream text = env.fromElements( + "Hello World", + "Hello Flink", + "Hello Java"); + // 对输入数据进行操作,包括分割、映射和聚合 + DataStream> counts = text + .flatMap(new Tokenizer()) + //keyBy(0) 操作按照元组的第一个字段(索引为0)进行分组。在这个例子中,这就表示根据每个单词(字符串)进行分组。 + .keyBy(0) + //sum(1) 是一个聚合操作,它对每个分组内的元素进行求和。在这个例子中,对元组的第二个字段(索引为1)进行求和,表示每个单词的出现次数。 + .sum(1); + // 输出结果 + counts.print(); + // 执行任务 + env.execute("Flink Streaming Java WordCount"); + } + + public static final class Tokenizer implements FlatMapFunction> { + @Override + public void flatMap(String value, Collector> out) { + // 规范化并分割行 + String[] words = value.toLowerCase().split("\\W+"); + // 为每个单词发出 Tuple2(word, 1) + for (String word : words) { + if (word.length() > 0) { + out.collect(new Tuple2<>(word, 1)); + } + } + } + } +``` + +输出如下: + +``` +(hello,1) +(world,1) +(hello,2) +(flink,1) +(hello,3) +(java,1) +``` + +对代码简要解析一下: + +这是一个基本的单词计数程序,它使用Apache Flink的流处理环境。这个程序读入一系列的字符串,然后把每个字符串分割成单词,对每个单词进行计数,并且输出计数结果。 + +在提供的例子中,有三个输入字符串:"Hello World", "Hello Flink", "Hello Java",'Hello'这个单词出现了三次,其余单词 ('World', 'Flink', 'Java') 各出现了一次。 + +由于Flink是一个流处理框架,它实时地处理数据,所以它会在每一次遇到一个新的单词时就更新和输出计数。因此,每当 'Hello' 出现时,都会更新和输出其计数。 + +对于这个例子: + +- 首先遇到 'Hello' 和 'World',所以输出 (hello,1) 和 (world,1)。 +- 然后再次遇到 'Hello' 和第一次遇到 'Flink',所以输出 (hello,2) 和 (flink,1)。 +- 最后再次遇到 'Hello' 和第一次遇到 'Java',所以输出 (hello,3) 和 (java,1)。 + + + +这段代码已在本地运行和测试过,且相关部分已添加注释,大家可以实际运行感受一下。 + +## 并行度 + +**特定算子的子任务(subtask)的个数称之为并行度(parallel),并行度是几,这个task内部就有几个subtask** + +怎样实现算子并行呢?其实也很简单,我们把一个算子操作,“复制”多份到多个节点,数据来了之后就可以到其中任意一个执行。这样一来,一个算子任务就被拆分成了多个并行的“子任务”(subtasks),再将它们分发到不同节点,就真正实现了并行计算。 + +**整个流处理程序的并行度,理论上是所有算子并行度中最大的那个,这代表了运行程序需要的 slot 数量** + +如果我们将上面WordCount程序的并行度设置为3 + +``` +env.setParallelism(3); +``` + +就会看到如下输出: + +``` +2> (world,1) +3> (flink,1) +1> (hello,1) +1> (hello,2) +1> (java,1) +1> (hello,3) +``` + +前面的数字代表线程,Flink会将相同的 key 分配到不同的 slot 进行处理。 + +### 并行度设置 + +在 Flink 中,可以用不同的方法来设置并行度,它们的有效范围和优先级别也是不同的。 + +**代码中设置** + +- 我们在代码中,可以很简单地在算子后跟着调用 `setParallelism()`方法,来设置当前算子的并行度: `stream.map(word -> Tuple2.of(word, 1L)).setParallelism(2);`这种方式设置的并行度,只针对当前算子有效。 +- 我们也可以直接调用执行环境的 `setParallelism()`方法,全局设定并行度:`env.setParallelism(2);`这样代码中所有算子,默认的并行度就都为2了。 + +**提交应用时设置** + +在使用 flink run 命令提交应用时,可以增加 `-p` 参数来指定当前应用程序执行的并行度,它的作用类似于执行环境的全局设置。如果我们直接在 Web UI 上提交作业,也可以在对应输入框中直接添加并行度。 + +**配置文件中设置** + +我们还可以直接在集群的配置文件 flink-conf.yaml 中直接更改默认并行度:`parallelism.default: 2`(初始值为 1) + +这个设置对于整个集群上提交的所有作业有效。 + +### 并行度生效优先级 + +1. 对于一个算子,首先看在代码中是否单独指定了它的并行度,这个特定的设置优先级最高,会覆盖后面所有的设置。 +2. 如果没有单独设置,那么采用当前代码中执行环境全局设置的并行度。 +3. 如果代码中完全没有设置,那么采用提交时`-p` 参数指定的并行度。 +4. 如果提交时也未指定`-p` 参数,那么采用集群配置文件中的默认并行度。 + +**这里需要说明的是,算子的并行度有时会受到自身具体实现的影响。比如读取 socket 文本流的算子 socketTextStream,它本身就是非并行的 Source 算子,所以无论怎么设置,它在运行时的并行度都是 1** + +## Task + +在 Flink 中,Task 是一个阶段多个功能相同 subTask 的集合,Flink 会尽可能地将 operator 的 subtask 链接(chain)在一起形成 task。每个 task 在一个线程中执行。将 operators 链接成 task 是非常有效的优化:它能减少线程之间的切换,减少消息的序列化/反序列化,减少数据在缓冲区的交换,减少了延迟的同时提高整体的吞吐量。 + +**要是之前学过Spark,这里可以用Spark的思想来看,Flink的Task就好比Spark中的Stage,而我们知道Spark的Stage是根据宽依赖来拆分的。所以我们也可以认为Flink的Task也是根据宽依赖拆分的(尽管Flink中并没有宽依赖的概念),这样会更好理解** + +如下图: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOiaicvO7VztvmFhl4p3h3icdPLDnH9XhJ7ojiaxpbII45iabaDJico8S9WsHo0qnCMRMJYQjfic2M54CWkw/640) + +## Operator Chain(算子链) + +在Flink中,为了分布式执行,Flink会将算子子任务链接在一起形成任务。每个任务由一个线程执行。将算子链接在一起形成任务是一种有用的优化:**它减少了线程间切换和缓冲的开销,并增加了整体吞吐量,同时降低了延迟** + +举个例子,假设我们有一个简单的Flink流处理程序,它从一个源读取数据,然后应用`map`和`filter`操作,最后将结果写入到一个接收器。这个程序可能看起来像这样: + +```Java +DataStream data = env.addSource(new CustomSource()); +data.map(new MapFunction() { + @Override + public String map(String value) throws Exception { + return value.toUpperCase(); + } +}) +.filter(new FilterFunction() { + @Override + public boolean filter(String value) throws Exception { + return value.startsWith("A"); + } +}) +.addSink(new CustomSink()); +``` + +**在这个例子中,`map`和`filter`操作可以被链接在一起形成一个任务,被优化为算子链,这意味着它们将在同一个线程中执行,而不是在不同的线程中执行并通过网络进行数据传输** + +## Task Slots + +Task Slots即是任务槽,slot 在 Flink 里面可以认为是资源组,Flink 将每个任务分成子任务并且将这些子任务分配到 slot 来并行执行程序,我们可以通过集群的配置文件来设定 TaskManager 的 slot 数量: `taskmanager.numberOfTaskSlots : 8`。 + +例如,如果 Task Manager 有2个 slot,那么它将为每个 slot 分配 50% 的内存。 可以在一个 slot 中运行一个或多个线程。 同一 slot 中的线程共享相同的 JVM。 + +**需要注意的是,slot 目前仅仅用来隔离内存,不会涉及 CPU 的隔离。在具体应用时,可以将 slot 数量配置为机器的 CPU 核心数,尽量避免不同任务之间对 CPU 的竞争。这也是开发环境默认并行度设为机器 CPU 数量的原因** + +### 分发规则 + +- **不同的Task下的subtask要分发到同一个TaskSlot中,降低数据传输、提高执行效率** +- **相同的Task下的subtask要分发到不同的TaskSlot** + +### Slot共享组 + +如果希望某个算子对应的任务完全独占一个 slot,或者只有某一部分算子共享 slot,在Flink中,可以通过在代码中使用`slotSharingGroup`方法来设置slot共享组。 + +Flink会将具有相同slot共享组的操作放入同一个slot中,同时保持不具有slot共享组的操作在其他slot中。这可以用来隔离slot。 + +例如,你可以这样设置: + +```Java +dataStream.map(...).slotSharingGroup("group1"); +``` + +默认情况下,所有操作都被分配相同的SlotSharingGroup。 + +这样,只有属于同一个 slot 共享组的子任务,才会开启 slot 共享,不同组之间的任务是完全隔离的,必须分配到不同的 slot 上。 + +### 并行度和Slots解释 + +听了上面并行度和Slots的理论,可能还是有点疑惑,下面通过例子解释说明下: + +假设一共有3个TaskManager,每一个TaskManager中的slot数量设置为3个,那么一共有9个task slot,表示最多能并行执行9个任务。 + +假设我们写了一个WordCount程序,有四个转换算子:**source —> flatMap —> reduce —> sink** + +当所有算子并行度相同时,很容易看出source和flatMap可以优化合并算子链,于是最终有三个任务节点:source & flatMap,reduce 和sink。 +如果我们没有任何并行度设置,而配置文件中默认`parallelism.default:1`,那么默认并行度为1,总共有3个任务。**由于不同算子的任务可以共享任务槽,所以最终占用的slot只有1个。9个slot只用了1个,有8个空闲** + +如图所示: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnjPQcmW39R8wibMMpqpQGLbsA32ngsNbhXic9yRxkicMDt9joDuuGeeT3dBtyp0jibQ6Ewib5wFx5Yeg/640) + +我们可以直接把并行度设置为 9,这样所有 3*9=27 个任务就会完全占用 9 个 slot。这是当前集群资源下能执行的最大并行度,计算资源得到了充分的利用。 + +另外再考虑对于某个算子单独设置并行度的场景。例如,如果我们考虑到输出可能是写入文件,那会希望不要并行写入多个文件,就需要设置 sink 算子的并行度为 1。这时其他的算子并行度依然为 9,所以总共会有 19 个子任务。 + +根据 slot 共享的原则,它们最终还是会占用全部的 9 个 slot,而 sink 任务只在其中一个 slot 上执行,通过这个例子也可以明确地看到,**整个流处理程序的并行度,就应该是所有算子并行度中最大的那个,这代表了运行程序需要的 slot 数量** + +## DataSource数据源 + +Flink内嵌支持的数据源非常多,比如HDFS、Socket、Kafka、Collections。Flink也提供了addSource方式,可以自定义数据源,下面介绍一些常用的数据源。 + +### File Source + +- 通过读取本地、HDFS文件创建一个数据源。 + +如果读取的是HDFS上的文件,那么需要导入Hadoop依赖 + +```xml + + org.apache.hadoop + hadoop-client + 3.3.1 + +``` + +代码示例:每隔10s去读取HDFS指定目录下的新增文件内容,并且进行WordCount。 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + String filePath = "hdfs://node01:9000/flink/data/"; + FileInputFormat textInputFormat = new TextInputFormat(new Path(filePath)); + //PROCESS_CONTINUOUSLY模式时,Flink会持续监视给定的路径,并在发现新数据时将其引入流中进行处理。 + DataStream textStream = env.readFile(textInputFormat, filePath, FileProcessingMode.PROCESS_CONTINUOUSLY, 10000); + DataStream> result = textStream + .flatMap(new WordSplitter()) + .map(new WordMapper()) + .keyBy(0) + .sum(1); + result.print(); + env.execute(); + } + + public static class WordSplitter implements FlatMapFunction> { + @Override + public void flatMap(String value, Collector> out) { + String[] words = value.split(" "); + for (String word : words) { + out.collect(new Tuple2<>(word, 1)); + } + } + } + public static class WordMapper implements MapFunction, Tuple2> { + @Override + public Tuple2 map(Tuple2 wordCountTuple) { + //f0, f1 等是用来访问元组中的元素的字段。Tuple2 表示这是一个大小为 2 的元组,其中 f0 是 String 类型,f1 是 Integer 类型。 + // 在代码中,wordCountTuple.f0 表示的就是单词(即String类型的值),wordCountTuple.f1 则表示的是这个单词的计数(即 Integer 类型的值)。 + return new Tuple2<>(wordCountTuple.f0, wordCountTuple.f1); + } + } +``` + +### Collection Source + +基于本地集合的数据源,一般用于测试场景,对于线上环境没有太大意义。 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + List data = Arrays.asList("hello word flink","hello java flink"); + DataStream text = env.fromCollection(data); + DataStream> counts = text + .flatMap(new Tokenizer()) + .keyBy(0) + .sum(1); + counts.print(); + env.execute("WordCount Example"); + } +``` + +### Socket Source + +接受Socket Server中的数据: + +```java +public static void main(String[] args) throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 连接 socket 获取输入数据 + DataStream text = env.socketTextStream("localhost", 9999); + // 解析数据、对数据进行分组、窗口处理和聚合计算 + DataStream> wordCount = text.flatMap(new Tokenizer()) + .keyBy(0) + .sum(1); + wordCount.print(); + env.execute("WordCount from Socket TextStream Example"); + } +``` + +### Kafka Source + +Flink想要接受Kafka中的数据,首先要配置flink与kafka的连接器依赖。 + +Maven依赖: + +```xml + + + org.apache.flink + flink-connector-kafka_2.12 + 1.13.6 + +``` + +示例代码: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 设置Kafka的相关参数并从Kafka中读取数据 + Properties properties = new Properties(); + properties.setProperty("bootstrap.servers", "localhost:9092"); + properties.setProperty("group.id", "test"); + FlinkKafkaConsumer flinkKafkaConsumer = new FlinkKafkaConsumer<>("topic_name", new SimpleStringSchema(), properties); + DataStream stream = env.addSource(flinkKafkaConsumer); + // 对接收的每一行数据进行处理,分割出每个单词并初始化其数量为1 + DataStream> words = stream.flatMap(new Tokenizer()); + DataStream> wordCounts = words.keyBy(0).sum(1); + wordCounts.print().setParallelism(1); + env.execute("WordCountFromKafka"); + } +``` + +## Transformations + +Transformations算子可以将一个或者多个算子转换成一个新的数据流,使用Transformations算子组合可以处理复杂的业务处理。 + +### Map + +DataStream → DataStream + +遍历数据流中的每一个元素,产生一个新的元素。 + +### FlatMap + +DataStream → DataStream + +遍历数据流中的每一个元素,产生N个元素 N=0,1,2......。 + +### Filter + +DataStream → DataStream + +过滤算子,根据数据流的元素计算出一个boolean类型的值,true代表保留,false代表过滤掉。 + +### KeyBy + +DataStream → KeyedStream + +根据数据流中指定的字段来分区,相同指定字段值的数据一定是在同一个分区中,内部分区使用的是HashPartitioner。 + +指定分区字段的方式有三种: + +- 根据索引号指定。 + +- 通过匿名函数来指定。 +- 通过实现KeySelector接口 指定分区字段。 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStreamSource stream = env.fromSequence(1, 100); + stream.map((MapFunction>) (Long x) -> new Tuple2<>(x % 3, 1), TypeInformation.of(new TypeHint>() {})) + //根据索引号来指定分区字段:.keyBy(0) + //通过传入匿名函数 指定分区字段:.keyBy(x=>x._1) + //通过实现KeySelector接口 指定分区字段 + .keyBy((KeySelector, Long>) (Tuple2 value) -> value.f0, BasicTypeInfo.LONG_TYPE_INFO) + .sum(1).print(); + env.execute("Flink Job"); + } +``` + +### Reduce + +适用于KeyedStream + +KeyedStream:根据key分组 → DataStream + +**注意,reduce是基于分区后的流对象进行聚合,也就是说,DataStream类型的对象无法调用reduce方法** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream> dataStream = env.fromElements( + new Tuple2<>("apple", 3), + new Tuple2<>("banana", 1), + new Tuple2<>("apple", 5), + new Tuple2<>("banana", 2), + new Tuple2<>("apple", 4) + ); + // 使用reduce操作,将input中的所有元素合并到一起 + DataStream> result = dataStream + .keyBy(0) + .reduce((ReduceFunction>) (value1, value2) -> new Tuple2<>(value1.f0, value1.f1 + value2.f1)); + result.print(); + env.execute(); + } +``` + +### Aggregations + +KeyedStream → DataStream + +Aggregations代表的是一类聚合算子,上面说的reduce就属于Aggregations,以下是一些常用的: + +- `sum()`: 计算数字类型字段的总和。 +- `min()`: 计算最小值。 +- `max()`: 计算最大值。 +- `count()`: 计数元素个数。 +- `avg()`: 计算平均值。 + +另外,Flink 还支持自定义聚合函数,即使用 `AggregateFunction` 接口实现更复杂的聚合逻辑。 + +### Union 真合并 + +DataStream → DataStream + +> Union of two or more data streams creating a new stream containing all the elements from all the streams +> + +**合并两个或者更多的数据流产生一个新的数据流,这个新的数据流中包含了所合并的数据流的元素** + +注意:需要保证数据流中元素类型一致 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream> ds1 = env.fromCollection(Arrays.asList(Tuple2.of("a",1),Tuple2.of("b",2),Tuple2.of("c",3))); + DataStream> ds2 = env.fromCollection(Arrays.asList(Tuple2.of("d",4),Tuple2.of("e",5),Tuple2.of("f",6))); + DataStream> ds3 = env.fromCollection(Arrays.asList(Tuple2.of("g",7),Tuple2.of("h",8))); + DataStream> unionStream = ds1.union(ds2,ds3); + unionStream.print(); + env.execute(); + } +``` + +在 Flink 中,Union 操作被称为 "真合并" 是因为它将两个或多个数据流完全融合在一起,没有特定的顺序,并且不会去除重复项。这种操作方式类似于在数学概念中的集合联合(Union)操作,所以被称为 "真合并"。 + +请注意,与其他一些数据处理框架中的 Union 操作相比,例如 Spark 中的 Union 会根据某些条件去除重复的元素,Flink 的 Union 行为更接近于数学上的集合联合理论。 + +### Connect 假合并 + +DataStream,DataStream → ConnectedStreams + +合并两个数据流并且保留两个数据流的数据类型,能够共享两个流的状态 + +```java +public static void main(String[] args) throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + + DataStream ds1 = env.socketTextStream("localhost", 8888); + DataStream ds2 = env.socketTextStream("localhost", 9999); + + DataStream> wcStream1 = ds1 + .flatMap(new Tokenizer()) + .keyBy(value -> value.f0) + .sum(1); + + DataStream> wcStream2 = ds2 + .flatMap(new Tokenizer()) + .keyBy(value -> value.f0) + .sum(1); + + ConnectedStreams, Tuple2> connectedStreams = wcStream1.connect(wcStream2); + } +``` + +与`union`不同,`connect`只能连接两个流,并且这两个流的类型可以不同。`connect`后的两个流会被看作是两个不同的流,可以使用`CoMap`或者`CoFlatMap`函数分别处理这两个流。 + +### CoMap, CoFlatMap + +ConnectedStreams → DataStream + +**CoMap, CoFlatMap并不是具体算子名字,而是一类操作的名称** + +- 凡是基于ConnectedStreams数据流做map遍历,这类操作叫做CoMap。 +- 凡是基于ConnectedStreams数据流做flatMap遍历,这类操作叫做CoFlatMap。 + +CoMap实现: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 创建两个不同的数据流 + DataStream nums = env.fromElements(1, 2, 3, 4, 5); + DataStream text = env.fromElements("a", "b", "c"); + // 连接两个数据流 + ConnectedStreams connected = nums.connect(text); + // 使用 CoMap 处理连接的流 + DataStream result = connected.map(new CoMapFunction() { + @Override + public String map1(Integer value) { + return String.valueOf(value*2); + } + @Override + public String map2(String value) { + return "hello " + value; + } + }); + result.print(); + env.execute("CoMap example"); + } +``` + +CoFlatMap实现方式: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream nums = env.fromElements(1, 2, 3, 4, 5); + DataStream text = env.fromElements("a", "b", "c"); + ConnectedStreams connected = nums.connect(text); + DataStream result = connected.flatMap(new CoFlatMapFunction() { + @Override + public void flatMap1(Integer value, Collector out) { + out.collect(String.valueOf(value*2)); + out.collect(String.valueOf(value*3)); + } + @Override + public void flatMap2(String value, Collector out) { + out.collect("hello " + value); + out.collect("hi " + value); + } + }); + result.print(); + env.execute("CoFlatMap example"); + } +``` + +### Split/Select + +DataStream → SplitStream + +根据条件将一个流分成多个流,示例代码如下: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStreamSource data = env.generateSequence(0, 10); + SplitStream split = data.split((OutputSelector) value -> { + List output = new ArrayList<>(); + if (value % 2 == 0) { + output.add("even"); + } else { + output.add("odd"); + } + return output; + }); + split.select("odd").print(); + env.execute("Flink SplitStream Example"); + } +``` + +`select()`用于从SplitStream中选择一个或者多个数据流。 + +```scala +split.select("odd").print(); +``` + +### SideOutput + +**注意:在Flink 1.12 及之后的版本中,SplitStream 已经被弃用并移除,一般推荐使用 Side Outputs(侧输出流)来替代 Split和Select** + +示例代码如下: + +```java +private static final OutputTag evenOutput = new OutputTag("even"){}; +private static final OutputTag oddOutput = new OutputTag("odd"){}; + +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream input = env.fromElements("1", "2", "3", "4", "5"); + SingleOutputStreamOperator processed = input.process(new ProcessFunction() { + @Override + public void processElement(String value, Context ctx, Collector out){ + int i = Integer.parseInt(value); + if (i % 2 == 0) { + ctx.output(evenOutput, value); + } else { + ctx.output(oddOutput, value); + } + } + }); + DataStream evenStream = processed.getSideOutput(evenOutput); + DataStream oddStream = processed.getSideOutput(oddOutput); + evenStream.print("even"); + oddStream.print("odd"); + env.execute("Side Output Example"); + } +``` + +### Iterate + +DataStream → IterativeStream → DataStream + +Iterate算子提供了对数据流迭代的支持 + +一个数据集通过迭代运算符被划分为两部分:“反馈”部分(feedback)和“输出”部分(output)。反馈部分被反馈到迭代头(iteration head),从而形成下一次迭代。输出部分则构成该迭代的结果: + +```java +public static void main(String[] args) throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream input = env.fromElements(10L); + // 定义迭代流,最大迭代10次 + IterativeStream iteration = input.iterate(10000L); + // 定义迭代逻辑 + DataStream minusOne = iteration.map((MapFunction) value -> value - 1); + // 定义反馈流(满足条件继续迭代)和输出流(不满足条件的结果) + DataStream stillGreaterThanZero = minusOne.filter(value -> value > 0).setParallelism(1);; + DataStream lessThanZero = minusOne.filter(value -> value <= 0); + // 关闭迭代,定义反馈流 + iteration.closeWith(stillGreaterThanZero); + // 打印结果 + lessThanZero.print(); + env.execute("Iterative Stream Example"); + } +``` + +### 普通函数 & 富函数 + +Apache Flink 中有两种类型的函数: 「**普通函数(Regular Functions)**」和 「**富函数(Rich Functions)**」。主要区别在于富函数相比普通函数提供了更多生命周期方法和上下文信息。 + +- **普通函数**:这些函数只需要覆盖一个或几个特定方法,如 `MapFunction` 需要实现 `map()` 方法。它们没有生命周期方法,也不能访问执行环境的上下文。 +- **富函数**:除了覆盖特定函数外,富函数还提供了对 Flink API 更多的控制和操作,包括: + - 生命周期管理:可以覆盖 `open()` 和 `close()` 方法以便在函数启动前和关闭后做一些设置或清理工作。 + - 获取运行时上下文信息:例如,通过 `getRuntimeContext()` 方法获取并行任务的信息,如当前子任务的索引等。 + - 状态管理和容错:可以定义和使用托管状态(Managed State),这在构建容错系统时非常重要。 + +简而言之,如果你需要在函数中使用 Flink 的高级功能,如状态管理或访问运行时上下文,则需要使用富函数。如果不需要这些功能,使用普通函数即可。 + +| 普通函数类 | 富函数类 | +| :-------------- | ------------------- | +| MapFunction | RichMapFunction | +| FlatMapFunction | RichFlatMapFunction | +| FilterFunction | RichFilterFunction | +| ...... | ...... | + +普通函数: + +```java +public static void main(String[] args) throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + List words = Arrays.asList("hello", "world", "flink", "hello", "world"); + env.fromCollection(words) + .map(new MapFunction>() { + @Override + public Tuple2 map(String value) { + return new Tuple2<>(value, 1); + } + }) + .keyBy(0) + .sum(1) + .print(); + + env.execute("Word Count Example"); + } +``` + +富函数: + +```java +public static void main(String[] args) throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + List words = Arrays.asList("hello", "world", "flink", "hello", "world"); + env.fromCollection(words) + .map(new RichMapFunction>() { + @Override + public void open(Configuration parameters) throws Exception { + super.open(parameters); + // 可以在这里设置相关的配置或者资源,如数据库连接等 + } + @Override + public Tuple2 map(String value) throws Exception { + return new Tuple2<>(value, 1); + } + @Override + public void close() throws Exception { + super.close(); + // 可以在这里完成资源的清理工作 + } + }) + .keyBy(0) + .sum(1) + .print(); + env.execute("Word Count Example"); + } +``` + +### ProcessFunction(处理函数) + +ProcessFunction属于低层次的API,在类继承关系上属于富函数。 + +我们前面讲的`map`、`filter`、`flatMap`等算子都是基于这层封装出来的。 + +越低层次的API,功能越强大,用户能够获取的信息越多,比如可以拿到元素状态信息、事件时间、设置定时器等 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream dataStream = env.socketTextStream("localhost", 9999) + .map(new MapFunction>() { + @Override + public Tuple2 map(String value) { + return new Tuple2<>(value, 1); + } + }) + .keyBy(0) + .process(new AlertFunction()); + dataStream.print(); + env.execute("Process Function Example"); + } + + public static class AlertFunction extends KeyedProcessFunction, String> { + private transient ValueState countState; + @Override + public void open(Configuration config) { + ValueStateDescriptor descriptor = + new ValueStateDescriptor<>( + "countState", // state name + TypeInformation.of(new TypeHint() {}), // type information + 0); // default value + countState = getRuntimeContext().getState(descriptor); + } + @Override + public void processElement(Tuple2 value, Context ctx, Collector out) throws Exception { + Integer currentCount = countState.value(); + currentCount += 1; + countState.update(currentCount); + if (currentCount >= 3) { + out.collect("Warning! The key '" + value.f0 + "' has been seen " + currentCount + " times."); + } + } + } +``` + +这里,我们创建一个名为`AlertFunction`的处理函数类,并继承`KeyedProcessFunction`。其中,`ValueState`用于保存状态信息,每个键会有其自己的状态实例。当计数达到或超过三次时,该系统将发出警告。这个例子主要展示了处理函数与其他运算符相比的两个优点:访问键控状态和生命周期管理方法(例如`open()`)。 + +注意:上述示例假设你已经在本地的9999端口上设置了一个socket服务器,用于流式传输文本数据。如果没有,你需要替换这部分以适应你的输入源。 + +## Sink + +在Flink中,"Sink"是数据流计算的最后一步。它代表了一个输出端点,在那里计算结果被发送或存储。换句话说,Sink是数据流处理过程中的结束节点,负责将处理后的数据输出到外部系统,如数据库、文件、消息队列等。 + +Flink内置了大量Sink,可以将Flink处理后的数据输出到HDFS、kafka、Redis、ES、MySQL等。 + +### Redis Sink + +Flink处理的数据可以存储到Redis中,以便实时查询。 + +首先,需要导入Flink和Redis的连接器依赖: + +```xml + + + org.apache.bahir + flink-connector-redis_${scala.binary.version} + 1.1.0 + +``` + +下面的代码展示了"Word Count"(词频统计)操作,并将结果存储到Redis数据库中: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.fromElements( + "Hello World", + "Hello Flink", + "Hello Java"); + DataStream> counts = + text.flatMap(new Tokenizer()) + .keyBy(value -> value.f0) + .sum(1); + FlinkJedisPoolConfig conf = new FlinkJedisPoolConfig.Builder().setHost("localhost").build(); + counts.addSink(new RedisSink<>(conf, new RedisExampleMapper())); + env.execute("Word Count Example"); + } + + public static final class Tokenizer implements FlatMapFunction> { + @Override + public void flatMap(String value, Collector> out) { + String[] words = value.toLowerCase().split("\\W+"); + + for (String word : words) { + if (word.length() > 0) { + out.collect(new Tuple2<>(word, 1)); + } + } + } + } + + public static final class RedisExampleMapper implements RedisMapper> { + @Override + public RedisCommandDescription getCommandDescription() { + return new RedisCommandDescription(RedisCommand.HSET); + } + @Override + public String getKeyFromData(Tuple2 data) { + return data.f0; + } + @Override + public String getValueFromData(Tuple2 data) { + return data.f1.toString(); + } + } +``` + +### Kafka Sink + +处理结果写入到kafka topic中,Flink也是支持的,需要添加连接器依赖,跟读取kafka数据用的连接器依赖相同,之前添加过就不需要再添加了。 + +```xml + + org.apache.flink + flink-connector-kafka_2.12 + 1.13.6 + +``` + +还是用上面词频统计的例子: +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.fromElements( + "Hello World", + "Hello Flink", + "Hello Java"); + DataStream> counts = + text.flatMap(new Tokenizer()) + .keyBy(value -> value.f0) + .sum(1); + // Define Kafka properties + Properties properties = new Properties(); + properties.setProperty("bootstrap.servers", "localhost:9092"); + // Write the data stream to Kafka + counts.map(new MapFunction, String>() { + @Override + public String map(Tuple2 value) throws Exception { + return value.f0 + "," + value.f1.toString(); + } + }) + .addSink(new FlinkKafkaProducer<>("my-topic", new SimpleStringSchema(), properties)); + env.execute("Word Count Example"); + } +``` + +### MySQL Sink + +Flink处理结果写入到MySQL中,这并不是Flink默认支持的,需要添加MySQL的驱动依赖: + +```xml + + + mysql + mysql-connector-java + 8.0.28 + +``` + +因为不是内嵌支持的,所以需要基于SinkFunction自定义Sink。 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.fromElements( + "Hello World", + "Hello Flink", + "Hello Java"); + DataStream> counts = + text.flatMap(new Tokenizer()) + .keyBy(value -> value.f0) + .sum(1); + // Transform the Tuple2 to a format acceptable by MySQL + DataStream mysqlData = counts.map(new MapFunction, String>() { + @Override + public String map(Tuple2 value) throws Exception { + return "'" + value.f0 + "'," + value.f1.toString(); + } + }); + // Write the data stream to MySQL + mysqlData.addSink(new MySqlSink()); + env.execute("Word Count Example"); + } + + public static class MySqlSink implements SinkFunction { + private Connection connection; + private PreparedStatement preparedStatement; + + @Override + public void invoke(String value, Context context) throws Exception { + if(connection == null) { + connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "username", "password"); + preparedStatement = connection.prepareStatement("INSERT INTO my_table(word, count) VALUES("+ value +")"); + } + preparedStatement.executeUpdate(); + } + } +} +``` + +### HBase Sink + +需要导入HBase的依赖: + +```xml + + + org.apache.hbase + hbase-client + 2.5.2 + +``` + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.fromElements( + "Hello World", + "Hello Flink", + "Hello Java"); + DataStream> counts = + text.flatMap(new Tokenizer()) + .keyBy(value -> value.f0) + .sum(1); + counts.addSink(new HBaseSink()); + env.execute("Word Count Example"); + } + + public static final class Tokenizer implements FlatMapFunction> { + @Override + public void flatMap(String value, Collector> out) { + String[] words = value.toLowerCase().split("\\W+"); + for (String word : words) { + if (word.length() > 0) { + out.collect(new Tuple2<>(word, 1)); + } + } + } + } + + public static class HBaseSink extends RichSinkFunction> { + private org.apache.hadoop.conf.Configuration config; + private org.apache.hadoop.hbase.client.Connection connection; + private Table table; + @Override + public void invoke(Tuple2 value, Context context) throws IOException { + Put put = new Put(Bytes.toBytes(value.f0)); + put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("count"), Bytes.toBytes(value.f1)); + table.put(put); + } + + @Override + public void open(Configuration parameters) throws Exception { + config = HBaseConfiguration.create(); + config.set("hbase.zookeeper.quorum", "localhost"); + config.set("hbase.zookeeper.property.clientPort", "2181"); + connection = ConnectionFactory.createConnection(config); + table = connection.getTable(TableName.valueOf("my-table")); + } + + @Override + public void close() throws Exception { + table.close(); + connection.close(); + } + } +``` + +`HBaseSink`类是`RichSinkFunction`的实现,用于将结果写入HBase数据库。在`invoke`方法中,它将接收到的每个二元组(单词和计数)写入HBase。在`open`方法中,它创建了与HBase的连接,并指定了要写入的表。在`close`方法中,它关闭了与HBase的连接和表。 + +## 分区策略 + +在 Apache Flink 中,分区(Partitioning)是将数据流按照一定的规则划分成多个子数据流或分片,以便在不同的并行任务或算子中并行处理数据。分区是实现并行计算和数据流处理的基础机制。Flink 的分区决定了数据在作业中的流动方式,以及在并行任务之间如何分配和处理数据。 + +在 Flink 中,数据流可以看作是一个有向图,图中的节点代表算子(Operators),边代表数据流(Data Streams)。数据从源算子流向下游算子,这些算子可能并行地处理输入数据,而分区就是决定数据如何从一个算子传递到另一个算子的机制。 + +下面介绍Flink中常用的几种分区策略。 + +### shuffle + +场景:增大分区、提高并行度,解决数据倾斜。 + +DataStream → DataStream + +**分区元素随机均匀分发到下游分区,网络开销比较大** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream stream = env.fromSequence(1, 10).setParallelism(1); + System.out.println(stream.getParallelism()); + stream.shuffle().print(); + env.execute(); + } +``` + +输出结果:上游数据比较随意地分发到下游 + +```scala +1> 7 +7> 1 +2> 8 +4> 5 +8> 3 +1> 9 +8> 4 +8> 10 +6> 2 +6> 6 +``` + +### rebalance + +场景:增大分区、提高并行度,解决数据倾斜 + +DataStream → DataStream + +**轮询分区元素,均匀的将元素分发到下游分区,下游每个分区的数据比较均匀,在发生数据倾斜时非常有用,网络开销比较大** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream stream = env.fromSequence(1, 10).setParallelism(1); + System.out.println(stream.getParallelism()); + stream.rebalance().print(); + env.execute(); + } +``` + +输出:上游数据比较均匀的分发到下游 + +```scala +2> 2 +1> 1 +8> 8 +5> 5 +7> 7 +4> 4 +3> 3 +6> 6 +1> 9 +2> 10 +``` + +### rescale + +场景:减少分区,防止发生大量的网络传输,不会发生全量的重分区 + +DataStream → DataStream + +通过轮询分区元素,将一个元素集合从上游分区发送给下游分区,发送单位是集合,而不是一个个元素 + +**和其他重分区策略(如 rebalance、forward、broadcast 等)不同的是,rescale 在运行时不会改变并行度,而且它只在本地(同一个 TaskManager 内)进行数据交换,所以它比其他重分区策略更加高效** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + + DataStream dataStream = env.fromElements("1", "2", "3", "4", "5"); + + // 使用MapFunction将元素转换为整数类型 + DataStream intStream = dataStream.map(new MapFunction() { + @Override + public Integer map(String value) { + return Integer.parseInt(value); + } + }); + // 使用rescale()进行重分区 + DataStream rescaledStream = intStream.rescale(); + rescaledStream.print(); + env.execute("Rescale Example"); + } +``` + +在这个例子中,我们创建了一个字符串类型的DataStream然后通过`map()`将每一个元素转换为整数。然后,我们对结果DataStream应用`rescale()`操作来重分区数据。 + +值得注意的是,`rescale()`的实际影响取决于你的并行度和集群环境,如果不同的并行实例都在同一台机器上,或者并行度只有1,那么可能不会看到`rescale()`的效果。而在大规模并行处理的情况下,使用`rescale()`操作可以提高数据处理的效率。 + +此外,我们不能直接在打印结果中看到`rescale`的影响,因为它改变的是内部数据分布和处理方式,而不是输出的结果。如果想观察`rescale`的作用,需要通过Flink的Web UI或者日志来查看任务执行情况,如数据流的分布、各个子任务的运行状态等信息。 + +### broadcast + +场景:需要使用映射表、并且映射表会经常发生变动的场景 + +DataStream → DataStream + +上游中每一个元素内容广播到下游每一个分区中 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream dataStream = env.fromElements(1, 2, 3, 4, 5); + DataStream broadcastStream = env.fromElements("2", "4"); + MapStateDescriptor descriptor = new MapStateDescriptor<>( + "RulesBroadcastState", + BasicTypeInfo.STRING_TYPE_INFO, + BasicTypeInfo.STRING_TYPE_INFO); + BroadcastStream broadcastData = broadcastStream.broadcast(descriptor); + dataStream.connect(broadcastData) + .process(new BroadcastProcessFunction() { + @Override + public void processElement(Integer value, ReadOnlyContext ctx, Collector out) throws Exception { + if (ctx.getBroadcastState(descriptor).contains(String.valueOf(value))) { + out.collect("Value " + value + " matches with a broadcasted rule"); + } + } + @Override + public void processBroadcastElement(String rule, Context ctx, Collector out) throws Exception { + ctx.getBroadcastState(descriptor).put(rule, rule); + } + }).print(); + env.execute("Broadcast State Example"); + } +``` + +上述代码首先定义了一个主流和一个要广播的流。然后,我们创建了一个`MapStateDescriptor`,用于存储广播数据。接着,我们将广播流转换为`BroadcastStream`。 + +最后,我们使用`connect()`方法连接主流和广播流,并执行`process()`方法。在这个`process()`方法中,我们定义了两个处理函数:`processElement()`和`processBroadcastElement()`。`processElement()`用于处理主流中的每个元素,并检查该元素是否存在于广播状态中。如果是,则输出一个字符串,表明匹配成功。而`processBroadcastElement()`则用于处理广播流中的每个元素,并将其添加到广播状态中。 + +注意:在分布式计算环境中,每个并行实例都会接收广播流中的所有元素。因此,广播状态对于所有的并行实例都是一样的。不过,在Flink 1.13版本中,广播状态尚未在故障恢复中提供完全的保障。所以在事件出现故障时,广播状态可能会丢失数据。 + +### global + +场景:并行度降为1 + +DataStream → DataStream + +在 Apache Flink 中,Global 分区策略意味着所有数据都被发送到下游算子的同一个分区中。这种情况下,下游算子只有一个任务处理全部数据。这是一种特殊的分区策略,只有在下游算子能够很快地处理所有数据,或者需要全局排序或全局聚合时才会使用。 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 创建一个从1到100的数字流 + DataStream numberStream = env.fromSequence(1, 100); + // 对流应用 map function + DataStream result = numberStream.global() + .map(new MapFunction() { + @Override + public Long map(Long value) { + System.out.println("Processing " + value); + return value * 2; + } + }); + result.print(); + env.execute("Global Partition Example"); + } +``` + +以上代码创建了一个顺序生成 1-100 的数字流,并应用了 Global Partition,然后对每个数字进行乘2的操作。实际运行此代码时,你会观察到所有的数字都由同一任务处理,打印出来的处理顺序是连续的。这就是 Global Partition 的作用:所有数据都被发送到下游算子的同一实例进行处理。 + +需要注意的是,此示例只是为了演示 Global Partition 的工作原理,实际上并不推荐在负载均衡很重要的应用场景中使用这种分区策略,因为它可能导致严重的性能问题。 + +### forward + +场景:一对一的数据分发,默认的分区策略,数据在各个算子之间不会重新分配。map、flatMap、filter 等都是这种分区策略 + +DataStream → DataStream + +上游分区数据分发到下游对应分区中 + +partition1->partition1;partition2->partition2 + +注意:必须保证上下游分区数(并行度)一致,不然会有如下异常: + +```scala +Forward partitioning does not allow change of parallelism. Upstream operation: Source: Socket Stream-1 parallelism: 1, downstream operation: Map-3 parallelism: 8 You must use another partitioning strategy, such as broadcast, rebalance, shuffle or global. +``` + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream dataStream = env.fromElements(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).setParallelism(1); + DataStream forwardStream = dataStream.forward().map(new MapFunction() { + @Override + public Integer map(Integer value) throws Exception { + return value * value; + } + }).setParallelism(1); + forwardStream.print(); + env.execute("Flink Forward Example"); + } +``` + +此代码首先创建一个从1到10的数据流。然后,它使用 Forward 策略将这个数据流送入一个 MapFunction 中,该函数将每个数字平方。然后,它打印出结果。注意:以上代码中的forward调用实际上并没有改变任何分区策略,因为forward是默认分区策略。这里添加forward调用主要是为了说明其存在和使用方法。 + +### keyBy + +场景:与业务场景匹配 + +DataStream → DataStream + +根据上游分区元素的Hash值与下游分区数取模计算出,将当前元素分发到下游哪一个分区 + +```scala +MathUtils.murmurHash(keyHash)(每个元素的Hash值) % maxParallelism(下游分区数) +``` + +```java +public static void main(String[] args) throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream> dataStream = env.fromElements( + new Tuple2<>(1, 3), + new Tuple2<>(1, 5), + new Tuple2<>(2, 4), + new Tuple2<>(2, 6), + new Tuple2<>(3, 7) + ); + // 使用 keyBy 对流进行分区操作 + DataStream> keyedStream = dataStream + .keyBy(0) // 根据元组的第一个字段进行分区 + .sum(1); // 对每个键对应的第二个字段求和 + keyedStream.print(); + env.execute("KeyBy example"); + } +``` + +以上程序首先创建了一个包含五个元组的流,然后使用 `keyBy` 方法根据元组的第一个字段进行分区,并对每个键对应的第二个字段求和。执行结果中,每个键的值集合都被映射成了一个新的元组,其第一个字段是键,第二个字段是相应的和。 + +注意:在以上代码中,`keyBy(0)` 表示根据元组的第一个字段(索引从0开始)进行分区操作。另外,无论什么情况,都需要确保你的 Flink 集群是正常运行的,否则程序可能无法执行成功。 + +### PartitionCustom + +DataStream → DataStream + +通过自定义的分区器,来决定元素是如何从上游分区分发到下游分区 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream data = env.fromElements(1,2,3,4,5,6,7,8,9,10); + // 使用自定义分区器进行分区 + data.partitionCustom(new MyPartitioner(), i -> i).print(); + env.execute("Custom partition example"); + } + + public static class MyPartitioner implements Partitioner { + @Override + public int partition(Integer key, int numPartitions) { + return key % numPartitions; + } + } +``` + +这个程序将创建一个数据流,其中包含从1到10的整数。然后,它使用了一个自定义的分区器`MyPartitioner`来对这个数据流进行分区。这个分区器根据元素的值对`numPartitions`取模来决定数据去到哪个分区。 + +由于篇幅限制,我们将在此结束本篇内容。稍微整理一下,下篇马上发。 + +希望这篇文章能够给你带来收获和思考,如果有收获,希望能不吝点个赞或者再看,谢谢。 diff --git "a/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2064W\345\255\227Flink\345\205\250\351\235\242\350\247\243\346\236\220\344\270\216\345\256\236\350\267\265(\344\270\213).md" "b/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2064W\345\255\227Flink\345\205\250\351\235\242\350\247\243\346\236\220\344\270\216\345\256\236\350\267\265(\344\270\213).md" new file mode 100644 index 0000000..4a87726 --- /dev/null +++ "b/docs/md/\345\244\247\346\225\260\346\215\256/\345\205\250\347\275\221\346\234\200\350\257\246\347\273\2064W\345\255\227Flink\345\205\250\351\235\242\350\247\243\346\236\220\344\270\216\345\256\236\350\267\265(\344\270\213).md" @@ -0,0 +1,2092 @@ +**承接上篇未完待续的话题,我们一起继续Flink的深入探讨** + +## Flink State状态 + +Flink是一个有状态的流式计算引擎,所以会将中间计算结果(状态)进行保存,默认保存到TaskManager的堆内存中。 + +但是当Task挂掉,那么这个Task所对应的状态都会被清空,造成了数据丢失,无法保证结果的正确性,哪怕想要得到正确结果,所有数据都要重新计算一遍,效率很低。 + +想要保证 **At -least-once** 和 **Exactly-once**,则需要把数据状态持久化到更安全的存储介质中,Flink提供了堆内内存、堆外内存、HDFS、RocksDB等存储介质。 + +先来看下Flink提供的状态有哪些,Flink中状态可以分为两种类型: + +- **Keyed State** + + 基于KeyedStream上的状态,这个状态是跟特定的Key绑定,KeyedStream流上的每一个Key都对应一个State,每一个Operator可以启动多个Thread处理,但是相同Key的数据只能由同一个Thread处理,因此一个Keyed状态只能存在于某一个Thread中,一个Thread会有多个Keyed State。 + +- **Non-Keyed State(Operator State)** + + Operator State与Key无关,而是与Operator绑定,整个Operator只对应一个State。比如:Flink中的Kafka Connector就使用了Operator State,它会在每个Connector实例中,保存该实例消费Topic的所有(partition, offset)映射。 + +Flink针对Keyed State提供了以下可以保存State的数据结构: + +- **ValueState**:类型为T的单值状态,这个状态与对应的Key绑定,最简单的状态,通过update更新值,通过value获取状态值。 +- **ListState**:Key上的状态值为一个列表,这个列表可以通过`add()`方法往列表中添加值,也可以通过`get()`方法返回一个Iterable来遍历状态值。 +- **ReducingState**:每次调用`add()`方法添加值的时候,会调用用户传入的`reduceFunction`,最后合并到一个单一的状态值。 +- **MapState**:状态值为一个Map,用户通过`put()`或`putAll()`方法添加元素,get(key)通过指定的key获取value,使用`entries()`、`keys()`、`values()`检索。 +- **AggregatingState**:保留一个单值,表示添加到状态的所有值的聚合。和 `ReducingState` 相反的是, 聚合类型可能与添加到状态的元素的类型不同。使用 `add(IN)` 添加的元素会调用用户指定的 `AggregateFunction` 进行聚合。 +- **FoldingState**:已过时,建议使用AggregatingState 保留一个单值,表示添加到状态的所有值的聚合。 与 `ReducingState` 相反,聚合类型可能与添加到状态的元素类型不同。 使用`add(T)`添加的元素会调用用户指定的 `FoldFunction` 折叠成聚合值。 + + + +**案例1:使用ValueState统计每个键的当前计数** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.fromElements(Tuple2.of("user1", 1), Tuple2.of("user2", 1), Tuple2.of("user1", 1), Tuple2.of("user2", 1)) + .keyBy(0) + .flatMap(new CountWithKeyedState()) + .print(); + env.execute("Flink ValueState example"); + } + + public static class CountWithKeyedState extends RichFlatMapFunction, Tuple2> { + private transient ValueState countState; + @Override + public void open(Configuration parameters) throws Exception { + ValueStateDescriptor descriptor = + new ValueStateDescriptor<>("countState", Integer.class, 0); + countState = getRuntimeContext().getState(descriptor); + } + + @Override + public void flatMap(Tuple2 value, Collector> out) throws Exception { + Integer currentCount = countState.value(); + currentCount += value.f1; + countState.update(currentCount); + out.collect(Tuple2.of(value.f0, currentCount)); + } + } +``` + +在这段代码中,我们首先创建了一个 `StreamExecutionEnvironment`,然后产生一些元素,每个元素都是指定用户的一个事件。`keyBy(0)` 表示我们以元组的第一个字段(即用户ID)为键进行分组。 + +然后,我们使用 `flatMap` 算子应用了 `CountWithKeyedState` 函数。这个函数使用了 Flink 的 ValueState 来存储和更新每个键的当前计数。 + +在 `open` 方法中,我们定义了一个名为 "countState" 的 ValueState,并把它初始化为 0。在 `flatMap` 方法中,我们从 ValueState 中获取当前计数,增加输入元素的值,然后更新 ValueState,并发出带有当前总数的元组。 + +注意:在真实的生产环境中,你可能需要从数据源(如 Kafka, HDFS等)读取数据,而不是使用 `fromElements` 方法 + + + +**案例2:使用MapState 统计单词出现次数** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.socketTextStream("localhost", 9999) + .flatMap(new Tokenizer()) + .keyBy(value -> value.f0) + .flatMap(new RichFlatMapFunction, Tuple2>() { + private transient MapState wordState; + @Override + public void open(Configuration parameters){ + MapStateDescriptor descriptor = + new MapStateDescriptor<>("wordCount", String.class, Integer.class); + wordState = getRuntimeContext().getMapState(descriptor); + } + @Override + public void flatMap(Tuple2 value, Collector> out) throws Exception { + Integer count = wordState.get(value.f0); + if (count == null) { + count = 0; + } + count += value.f1; + wordState.put(value.f0, count); + out.collect(Tuple2.of(value.f0, count)); + } + }) + .print(); + env.execute("Word Count with MapState"); + } + + public static final class Tokenizer extends RichFlatMapFunction> { + @Override + public void flatMap(String value, Collector> out) { + String[] words = value.toLowerCase().split("\\W+"); + for (String word : words) { + if (word.length() > 0) { + out.collect(new Tuple2<>(word, 1)); + } + } + } + } +``` + +在这个例子中,我们首先通过 `socketTextStream` 方法从本地的 socket 获取输入数据流。然后我们用 `flatMap` 操作将每行输入分解为单个单词,并且为每个单词赋予基础计数值(基数)1。 + +我们创建一个使用 `RichFlatMapFunction` 的 operator,它可以访问 `MapState`。在 `open()` 方法中,我们定义了 `MapStateDescriptor`,然后用这个 `descriptor` 创建 `MapState`。 + +在 `flatMap()` 函数中,我们获取当前单词的计数值,如果不存在则设置为0。然后我们增加计数值,更新 MapState,并且输出当前单词和它的出现次数。 + + + +**案例3:使用ReducingState统计输入流中每个键的最大值** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream> dataStream = env.fromElements( + Tuple2.of("A", 6), + Tuple2.of("B", 5), + Tuple2.of("C", 4), + Tuple2.of("A", 3), + Tuple2.of("B", 2), + Tuple2.of("C", 1) + ); + dataStream.keyBy(0).flatMap(new MaxValueReducer()).print(); + env.execute("ReducingState Example"); + } + public static class MaxValueReducer extends RichFlatMapFunction, Tuple2> { + private transient ReducingState maxState; + @Override + public void open(Configuration config) { + ReducingStateDescriptor descriptor = new ReducingStateDescriptor<>( + "maxValue", // state的名字 + Math::max, // ReduceFunction,这里取两者的最大值 + TypeInformation.of(Integer.class)); // 类型信息 + maxState = getRuntimeContext().getReducingState(descriptor); + } + @Override + public void flatMap(Tuple2 input, Collector> out) throws Exception { + maxState.add(input.f1); // 更新state的值 + out.collect(Tuple2.of(input.f0, maxState.get())); // 输出当前key的最大值 + } + } +``` + +在上述代码中,我们首先创建了一个新的`MaxValueReducer`类,该类扩展了`RichFlatMapFunction`。然后定义了一个`ReducingState`变量,用于在每个key上维护最大值。在`open()`方法中,我们初始化了这个状态变量。在`flatMap()`方法中,我们简单地将新的值添加到状态中,并输出当前key的最大值。 + +输出如下: + +``` +7> (A,6) +7> (A,6) +2> (B,5) +2> (C,4) +2> (B,5) +2> (C,4) +``` + + + +**案例4:使用AggregatingState统计输入流中每个键的平均值** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream> input = env.fromElements( + Tuple2.of("A", 6), + Tuple2.of("B", 5), + Tuple2.of("C", 4), + Tuple2.of("A", 3), + Tuple2.of("B", 2), + Tuple2.of("C", 1) + ); + input.keyBy(x -> x.f0) + .process(new AggregatingProcessFunction()) + .print(); + env.execute(); + } + public static class AverageAggregate implements AggregateFunction, Double> { + @Override + public Tuple2 createAccumulator() { + return new Tuple2<>(0, 0); + } + @Override + public Tuple2 add(Integer value, Tuple2 accumulator) { + return new Tuple2<>(accumulator.f0 + value, accumulator.f1 + 1); + } + @Override + public Double getResult(Tuple2 accumulator) { + return ((double) accumulator.f0) / accumulator.f1; + } + @Override + public Tuple2 merge(Tuple2 a, Tuple2 b) { + return new Tuple2<>(a.f0 + b.f0, a.f1 + b.f1); + } + } + + public static class AggregatingProcessFunction extends KeyedProcessFunction, Tuple2> { + private AggregatingState avgState; + @Override + public void open(Configuration parameters) { + AggregatingStateDescriptor, Double> descriptor = + new AggregatingStateDescriptor<>("average", new AverageAggregate(), TypeInformation.of(new TypeHint>() { + })); + avgState = getRuntimeContext().getAggregatingState(descriptor); + } + @Override + public void processElement(Tuple2 value, Context ctx, + Collector> out) throws Exception { + avgState.add(value.f1); + out.collect(new Tuple2<>(value.f0, avgState.get())); + } + } +``` + +输入如下: + +``` +7> (A,6.0) +2> (B,5.0) +2> (C,4.0) +7> (A,4.5) +2> (B,3.5) +2> (C,2.5) +``` + +这段代码主要是计算每个键对应的值的平均数。代码中定义了:`AverageAggregate`和`AggregatingProcessFunction`。 + +`AverageAggregate`类实现了`AggregateFunction`接口,用于计算平均值: + +- `createAccumulator`方法返回一个新的累加器,这里是一个包含两个整数的元组,表示当前的总数和元素的数量。 +- `add`方法向累加器添加一个元素的值,将其添加到总数中,并增加元素数量。 +- `getResult`方法根据累加器计算平均值。 +- `merge`方法合并两个累加器,将他们的总数和元素数量相加。 + +`AggregatingProcessFunction`类扩展了`KeyedProcessFunction`,在接收到一个元素时添加到状态中的平均值,并输出当前的平均值: + +- 在`open`方法中,创建了一个`AggregatingStateDescriptor`,描述要保存的状态,这里保存的是平均值。 +- `processElement`方法在接收到一个新元素时,将其值添加到状态中的平均值,然后输出包含键和当前平均值的元组。 + + + +以上案例代码都经过本地运行和测试,建议大家自行运行以便更深入地理解。 + +### CheckPoint & SavePoint + +**有状态流应用中的检查点(CheckPoint),其实就是所有任务的状态在某个时间点的一个快照(一份拷贝)** + +简单来讲,就是一次「**存盘**」,让我们之前处理数据的进度不要丢掉。在一个流应用程序运行时,Flink 会定期保存检查点,在检查点中会记录每个算子的 id 和状态。 + +如果发生故障,Flink 就会用最近一次成功保存的检查点来恢复应用的状态,重新启动处理流程,就如同「**读档**」一样。 + +默认情况下,检查点是被禁用的,需要在代码中手动开启。直接调用执行环境的`enableCheckpointing()`方法就可以开启检查点。 + +```java +StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); +env.enableCheckpointing(1000); +``` + +这里传入的参数是检查点的间隔时间,单位为毫秒。 + +除了检查点之外,Flink 还提供了「**保存点(SavePoint)**」的功能。 + +保存点在原理和形式上跟检查点完全一样,也是状态持久化保存的一个快照。 + +保存点与检查点最大的区别,就是触发的时机。检查点是由 Flink 自动管理的,定期创建,发生故障之后自动读取进行恢复,这是一个「**自动存盘**」的功能。而保存点不会自动创建,必须由用户明确地手动触发保存操作,所以就是「**手动存盘**」。 + +因此两者尽管原理一致,但用途就有所差别了。 + +**检查点主要用来做故障恢复,是容错机制的核心;保存点则更加灵活,可以用来做有计划的手动备份和恢复** + +检查点具体的持久化存储位置,取决于「**检查点存储(CheckPointStorage)**」的设置。 + +默认情况下,检查点存储在 JobManager 的堆(heap)内存中。而对于大状态的持久化保存,Flink也提供了在其他存储位置进行保存的接口,这就是「 **CheckPointStorage**」。 + +具体可以通过调用检查点配置的 `setCheckpointStorage()`来配置,需要传入一个CheckPointStorage 的实现类。例如: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 设置检查点时间间隔为1000ms + env.enableCheckpointing(1000); + // 设置checkpoint存储路径, 注意路径需要是可访问且有写权限的HDFS或本地路径 + URI checkpointPath = URI.create("hdfs://localhost:9000/flink-checkpoints"); + FileSystemCheckpointStorage storage = new FileSystemCheckpointStorage(checkpointPath, 10000); + // 应用配置 + env.getCheckpointConfig().setCheckpointStorage(storage); + // 设置重启策略,这里我们设置为固定延时无限重启 + //Flink的重启策略是用来决定如何处理作业执行过程中出现的失败情况的。如果Flink作业在运行时出错,比如由于代码错误、硬件故障或 网络问题等,那么重启策略就会决定是否和如何重启作业。 + env.setRestartStrategy(RestartStrategies.fixedDelayRestart( + // 尝试重启次数 + 3, + //每次尝试重启的固定延迟时间为 10 秒 + org.apache.flink.api.common.time.Time.of(10, java.util.concurrent.TimeUnit.SECONDS) + )); + env.execute("Flink Checkpoint Example"); + } +``` + +Flink 主要提供了两种 CheckPointStorage: + +- 作业管理器的堆内存(JobManagerCheckpointStorage) +- 文件系统(FileSystemCheckpointStorage) + +对于实际生产应用,我们一般会将 CheckPointStorage 配置为高可用的分布式文件系统(HDFS,S3 等)。 + +#### CheckPoint原理 + +Flink会在输入的数据集上间隔性地生成**CheckPoint Barrier**,通过栅栏(Barrier)将间隔时间段内的数据划分到相应的CheckPoint中。 + +当程序出现异常时,Operator就能够从上一次快照中恢复所有算子之前的状态,从而保证数据的一致性。 + +例如在Kafka Consumer算子中维护offset状态,当系统出现问题无法从Kafka中消费数据时,可以将offset记录在状态中,当任务重新恢复时就能够从指定的偏移量开始消费数据。 + +默认情况Flink不开启检查点,用户需要在程序中通过调用方法配置来开启检查点,另外还可以调整其他相关参数 + +- CheckPoint 开启和时间间隔指定 + + 开启检查点并且指定检查点时间间隔为1000ms,根据实际情况自行选择,如果状态比较大,则建议适当增加该值 + + ```java + env.enableCheckpointing(1000) + ``` + +- Exactly-once 和 At-least-once语义选择 + + 选择Exactly-once语义保证整个应用内端到端的数据一致性,这种情况比较适合于数据要求比较高,不允许出现丢数据或者数据重复,与此同时,Flink的性能也相对较弱。 + + 而At-least-once语义更适合于时廷和吞吐量要求非常高但对数据的一致性要求不高的场景。如下通过`setCheckpointingMode()`方法来设定语义模式,**默认情况下使用的是Exactly-once模式**。 + + ```java + env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); + ``` + +- CheckPoint 超时时间 + + 超时时间指定了每次CheckPoint执行过程中的上限时间范围,一旦CheckPoint执行时间超过该阈值,Flink将会中断CheckPoint过程,并按照超时处理。该指标可以通过`setCheckpointTimeout()`方法设定,默认为10分钟 + + ```java + env.getCheckpointConfig().setCheckpointTimeout(5 * 60 * 1000); + ``` + +- CheckPoint 最小时间间隔 + + 该参数主要目的是设定两个CheckPoint之间的最小时间间隔,防止Flink应用密集地触发CheckPoint操作,会占用了大量计算资源而影响到整个应用的性能 + + ```java + env.getCheckpointConfig().setMinPauseBetweenCheckpoints(600) + ``` + +- CheckPoint 最大并行执行数量 + + 在默认情况下只有一个检查点可以运行,根据用户指定的数量可以同时触发多个CheckPoint,进而提升CheckPoint整体的效率 + + ```java + env.getCheckpointConfig().setMaxConcurrentCheckpoints(1) + ``` + +- 任务取消后,是否删除 CheckPoint 中保存的数据 + + `RETAIN_ON_CANCELLATION`:表示一旦Flink处理程序被cancel后,会保留CheckPoint数据,以便根据实际需要恢复到指定的CheckPoint。 + + `DELETE_ON_CANCELLATION`:表示一旦Flink处理程序被cancel后,会删除CheckPoint数据,只有Job执行失败的时候才会保存CheckPoint。 + + ```java + env.getCheckpointConfig().enableExternalizedCheckpoints(CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION) + ``` + +- 容忍的检查的失败数 + + 设置可以容忍的检查的失败数,超过这个数量则系统自动关闭和停止任务。 + + ```java + env.getCheckpointConfig().setTolerableCheckpointFailureNumber(1) + ``` + +#### SavePoint原理 + +SavePoint 底层实现其实也是使用CheckPoint的机制。 + +SavePoint是用户以手工命令的方式触发Checkpoint,并将结果持久化到指定的存储路径中,其主要目的是帮助用户在升级和维护集群过程中保存系统中的状态数据,避免因为停机运维或者升级应用等正常终止应用的操作而导致系统无法恢复到原有的计算状态的情况,从而无法实现从端到端的 Excatly-Once 语义保证。 + +要使用SavePoint,需要按照以下步骤进行: + +1. **配置状态后端**: 在Flink中,状态可以保存在不同的后端存储中,例如内存、文件系统或分布式存储系统(如HDFS)。要启用SavePoint,需要在Flink配置文件中配置合适的状态后端。 + + 通常,使用分布式存储系统作为状态后端是比较常见的做法,因为它可以提供更好的可靠性和容错性。 + +2. **生成SavePoint**: 在Flink应用程序运行时,可以通过以下方式手动触发生成SavePoint: + + ```bash + bin/flink savepoint [targetDirectory] + ``` + + 其中,``是要保存状态的Flink作业的Job ID,`[targetDirectory]`是可选的目标目录,用于保存SavePoint数据。如果没有提供`targetDirectory`,SavePoint将会保存到Flink配置中所配置的状态后端中。 + +3. **恢复SavePoint**: 要恢复到SavePoint状态,可以通过以下方式提交作业: + + ```bash + bin/flink run -s :savepointPath [:runArgs] + ``` + + 其中,`savepointPath`是之前生成的SavePoint的路径,`runArgs`是提交作业时的其他参数。 + +4. **确保应用程序状态的兼容性**: 在使用SavePoint时,应用程序的状态结构和代码必须与生成SavePoint的版本保持兼容。这意味着在更新应用程序代码后,可能需要做一些额外的工作来保证状态的向后兼容性,以便能够成功恢复到旧的SavePoint。 + +### StateBackend状态后端 + +在Flink中提供了StateBackend来存储和管理状态数据。 + +Flink一共实现了三种类型的状态管理器:`MemoryStateBackend`、`FsStateBackend`、`RocksDBStateBackend`。 + +#### MemoryStateBackend + +基于内存的状态管理器,将状态数据全部存储在JVM堆内存中。 + +基于内存的状态管理具有非常快速和高效的特点,但也具有非常多的限制,最主要的就是内存的容量限制,一旦存储的状态数据过多就会导致系统内存溢出等问题,从而影响整个应用的正常运行。 + +同时如果机器出现问题,整个主机内存中的状态数据都会丢失,进而无法恢复任务中的状态数据。因此从数据安全的角度建议用户尽可能地避免在生产环境中使用MemoryStateBackend。 + +**MemoryStateBackend是Flink的默认状态后端管理器** + +```java +env.setStateBackend(new MemoryStateBackend(100*1024*1024)); +``` + +注意:聚合类算子的状态会同步到 JobManager 内存中,因此对于聚合类算子比较多的应用会对 JobManager 的内存造成一定的压力,进而影响集群。 + +#### FsStateBackend + +和MemoryStateBackend有所不同的是,FsStateBackend是基于文件系统的一种状态管理器,这里的文件系统可以是本地文件系统,也可以是HDFS分布式文件系统。 + +```java +env.setStateBackend(new FsStateBackend("path",true)); +``` + +如果path是本地文件路径,格式为:`file:///`;如果path是HDFS文件路径,格式为:`hdfs://`。 + +第二个参数代表是否异步保存状态数据到HDFS,异步方式能够尽可能避免ChecPoint的过程中影响流式计算任务。 + +FsStateBackend更适合任务量比较大的应用,例如:包含了时间范围非常长的窗口计算,或者状态比较大的场景。 + +#### RocksDBStateBackend + +RocksDBStateBackend是Flink中内置的第三方状态管理器,和前面的状态管理器不同,RocksDBStateBackend需要单独引入相关的依赖包到工程中。 + +```xml + + org.apache.flink + flink-statebackend-rocksdb_2.12 + 1.14.4 + test + +``` + +```java +env.setStateBackend(new RocksDBStateBackend("file:///tmp/flink-backend")); +``` + +RocksDBStateBackend采用异步的方式进行状态数据的Snapshot,任务中的状态数据首先被写入本地RockDB中,这样在RockDB仅会存储正在进行计算的热数据,而需要进行CheckPoint的时候,会把本地的数据直接复制到远端的FileSystem中。 + +与FsStateBackend相比,RocksDBStateBackend在性能上要比FsStateBackend高一些,主要是因为借助于RocksDB在本地存储了最新热数据,然后通过异步的方式再同步到文件系统中,但RocksDBStateBackend和MemoryStateBackend相比性能就会较弱一些。 + +RocksDB克服了State受内存限制的缺点,同时又能够持久化到远端文件系统中,推荐在生产中使用。 + +#### 集群级配置StateBackend + +全局配置需要修改集群中的配置文件`flink-conf.yaml`。 + +- 配置FsStateBackend + +```yaml +state.backend: filesystem +state.checkpoints.dir: hdfs://namenode-host:port/flink-checkpoints +``` + +- 配置MemoryStateBackend + +```yaml +state.backend: jobmanager +``` + +- 配置RocksDBStateBackend + +```yaml +#同时操作RocksDB的线程数 +state.backend.rocksdb.checkpoint.transfer.thread.num: 1 +#RocksDB存储状态数据的本地文件路径 +state.backend.rocksdb.localdir: 本地path +``` + +## Window + +在流处理中,我们往往需要面对的是连续不断、无休无止的无界流,不可能等到所有数据都到齐了才开始处理。 + +所以聚合计算其实在实际应用中,我们往往更关心一段时间内数据的统计结果,比如在过去的 1 分钟内有多少用户点击了网页。在这种情况下,我们就可以定义一个窗口,收集最近一分钟内的所有用户点击数据,然后进行聚合统计,最终输出一个结果就可以了。 + +**窗口实质上是将无界流切割为一系列有界流,采用左开右闭的原则** + +**Flink中的窗口分为两类:基于时间的窗口(Time-based Window)和基于数量的窗口(Count-based Window)** + +- 时间窗口(Time Window):按照时间段去截取数据,这在实际应用中最常见。 +- 计数窗口(Count Window):由数据驱动,也就是说按照固定的个数,来截取一段数据集。 + +时间窗口中又包含了:**滚动时间窗口、滑动时间窗口、会话窗口** + +计数窗口包含了:**滚动计数窗口、滑动计数窗口** + +时间窗口、计数窗口只是对窗口的一个大致划分。在具体应用时,还需要定义更加精细的规则,来控制数据应该划分到哪个窗口中去。不同的分配数据的方式,就可以有不同的功能应用。 + +根据分配数据的规则,窗口的具体实现可以分为 4 类:**滚动窗口(Tumbling Window)、滑动窗口(Sliding Window)、会话窗口(Session Window)、全局窗口(Global Window)** + +### 滚动窗口 + +**滚动窗口每个窗口的大小固定,且相邻两个窗口之间没有重叠** + +滚动窗口可以基于时间定义,也可以基于数据个数定义,需要的参数只有窗口大小。 + +我们可以定义一个大小为1小时的滚动时间窗口,那么每个小时就会进行一次统计;或者定义一个大小为10的滚动计数窗口,就会每10个数进行一次统计。 + +基于时间的滚动窗口: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream> randomKeyedStream = env + .fromSequence(1, Long.MAX_VALUE) + // 将每个数映射为一个二元组,第一个元素是随机键,第二个元素是数本身 + .map(new MapFunction>() { + private final Random rnd = new Random(); + @Override + public Tuple2 map(Long value) { + return new Tuple2<>(rnd.nextInt(10), value.intValue()); + } + }); + // 对流进行滚动窗口操作,窗口大小为5秒 + // 应用窗口函数,求每个窗口的和 + DataStream sum = randomKeyedStream + .assignTimestampsAndWatermarks(WatermarkStrategy + .>forBoundedOutOfOrderness(Duration.ofSeconds(5)) + .withTimestampAssigner((event, timestamp) -> event.f1)) + .keyBy(0) + .timeWindow(Time.seconds(5)) + .apply(new WindowFunction, Integer, Tuple, TimeWindow>() { + @Override + public void apply(Tuple key, + TimeWindow window, + Iterable> values, + Collector out){ + int sum1 = 0; + for (Tuple2 val: values) { + sum1 += val.f1; + } + out.collect(sum1); + } + }); + sum.print(); + env.execute("Tumbling Window Example"); + } +``` + + 这个程序的主要功能是从1到`Long.MAX_VALUE`产生一个序列,并为每个生成的数字创建一个二元组(Tuple2),然后在5秒大小的窗口上对二元组进行操作并输出每个窗口中所有值的总和。 + +详细解释如下: + +1. `StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();`: 获取Flink的运行环境。 +2. 产生一个无限长的序列(从1开始到最大的Long型数),每个数字都映射成一个二元组,第一个元素(f0)是一个0-9的随机整数(作为键用于之后的keyBy操作),第二个元素(f1)是数字本身。 +3. 使用`assignTimestampsAndWatermarks`来定义事件时间和水位线。这里设定了最大延迟时间为5秒(`forBoundedOutOfOrderness`),并将二元组的第二个元素作为时间戳。 +4. 使用`keyBy(0)`按照二元组的第一个元素进行分区,这样保证了相同键的元素会被发送到同一个任务中。 +5. 定义了一个5秒的滚动窗口`timeWindow(Time.seconds(5))`。 +6. 使用`apply`函数应用在每个窗口上,计算每个窗口中所有二元组的第二个元素(f1)的总和,并收集结果。最终,每个窗口计算的总和都会被输出。 +7. `sum.print();`: 命令将处理后的数据打印出来。 +8. `env.execute("Tumbling Window Example");`: 启动Flink任务。 + + + +基于计数的滚动窗口: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.socketTextStream("localhost", 9999); + DataStream> counts = text + .flatMap(new Tokenizer()) + .keyBy(0) + .countWindow(5) // Count window of 5 elements + .sum(1); + counts.print().setParallelism(1); + env.execute("Window WordCount"); + } + + public static final class Tokenizer implements FlatMapFunction> { + @Override + public void flatMap(String value, Collector> out) { + String[] words = value.toLowerCase().split("\\W+"); + for (String word : words) { + if (word.length() > 0) { + out.collect(new Tuple2<>(word, 1)); + } + } + } + } +``` + +这段程序从本地9999端口读取数据流,对每一行的单词进行小写处理和分割,然后在滑动窗口中(大小为5个元素)计算出各个单词的出现次数。 + +### 滑动窗口 + +滑动窗口的大小固定,但窗口之间不是首尾相接,会有部分重合。同样,滑动窗口也可以基于时间和计数定义。 + +滑动窗口的参数有两个:**窗口大小和滑动步长** + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScOnIpzib7JiaDHLjvtsZnfQWtEeuYhwFF04QvTRmK6FcXaBshE5c8QBYBg7SaMfzTPmqFMQY8lXWuNQ/640) + +基于时间的滑动窗口: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream input = env.socketTextStream("localhost", 9999); + DataStream> processedInput = input.map(new MapFunction>() { + @Override + public Tuple2 map(String value){ + String[] words = value.split(","); + return new Tuple2<>(words[0], Integer.parseInt(words[1])); + } + }); + // 指定窗口类型为滑动窗口,窗口大小为10分钟,滑动步长为5分钟 + DataStream> windowCounts = processedInput + .keyBy(0) + .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5))) + .reduce(new ReduceFunction>() { + @Override + public Tuple2 reduce(Tuple2 value1, Tuple2 value2){ + return new Tuple2<>(value1.f0, value1.f1 + value2.f1); + } + }); + windowCounts.print().setParallelism(1); + env.execute("Time Window Example"); + } +``` + +这段程序从一个套接字端口读取输入数据,将每行输入按照“,”切分并映射为tuple(字符串,整数)。然后,它按照第一个元素(即字符串)进行分组,并使用滑动窗口(窗口大小为10秒,滑动步长为5秒)进行聚合 - 在每个窗口内,所有具有相同键的值的整数部分被相加。最终结果会在控制台上打印。 + + + +基于计数的滑动窗口: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.socketTextStream("localhost", 9999); + DataStream> counts = text + .flatMap(new Tokenizer()) + .keyBy(0) + .countWindow(5, 1) + .sum(1); + counts.print().setParallelism(1); + env.execute("Sliding Window WordCount"); + } + + public static final class Tokenizer implements FlatMapFunction> { + @Override + public void flatMap(String value, Collector> out) { + String[] words = value.toLowerCase().split("\\W+"); + for (String word : words) { + if (word.length() > 0) { + out.collect(new Tuple2<>(word, 1)); + } + } + } + } +``` + +这段代码是实时滑动窗口词频统计程序。它从本地9999端口读取数据流,将接收到的每行文本拆分为单词然后输出为(单词,1)的形式,接着按照单词分组,使用大小为5,步长为1的滑动窗口,并对每个窗口中的同一单词出现次数进行求和,最后打印结果。 + +### 会话窗口 + +**会话窗口是Flink中一种基于时间的窗口类型,每个窗口的大小不固定,且相邻两个窗口之间没有重叠,“会话”终止的标志就是隔一段时间没有数据进来** + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream> inputStream = env.fromElements( + new Tuple2<>("user1", 1617229200000L), + new Tuple2<>("user1", 1617229205000L), + new Tuple2<>("user2", 1617229210000L), + new Tuple2<>("user1", 1617229215000L), + new Tuple2<>("user2", 1617229220000L) + ); + SingleOutputStreamOperator> resultStream = inputStream + .keyBy(value -> value.f0) + .window(EventTimeSessionWindows.withGap(Time.minutes(5))) + .sum(1); + resultStream.print(); + env.execute("Session Window Example"); + } +``` + +这段代码从一个数据流中读取用户活动数据(包含用户ID和Unix时间戳),然后根据用户ID将数据进行分组,并应用了一个会话窗口(当用户五分钟内无活动则关闭该用户的窗口)。 + +然后,它对每个用户在各自窗口内的活动时间戳求和,并打印出结果。最后执行的名为"Session Window Example"的任务即完成了这一流式计算过程。 + +### 按键分区窗口和非按键分区窗口 + +在Flink中,数据流可以按键分区(keyed)和非按键分区(non-keyed)。 + +按键分区是指将数据流根据特定的键值进行分区,使得相同键值的元素被分配到同一个分区中。这样可以保证相同键值的元素由同一个worker实例处理。只有按键分区的数据流才能使用键分区状态和计时器。 + +非按键分区是指数据流没有根据特定的键值进行分区。这种情况下,数据流中的元素可以被任意分配到不同的分区中。 + +在定义窗口操作之前,首先需要确定,到底是基于按键分区(Keyed)来开窗,还是直接在没有按键分区的DataStream上开窗。也就是在调用窗口算子之前是否有keyBy操作。 + +按键分区窗口: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.socketTextStream("localhost", 9999); + DataStream> counts = + // 将输入字符串拆分为tuple类型,包含word和数量 + text.map(new MapFunction>() { + @Override + public Tuple2 map(String value) { + return new Tuple2<>(value, 1); + } + }) + // 根据元组的第一字段(word)进行分区键 + .keyBy(0) + // 定义一个滚动窗口,时间间隔为5秒 + .window(TumblingProcessingTimeWindows.of(Time.seconds(5))) + // 应用reduce函数,累加各个窗口中同一单词的数量 + .reduce(new ReduceFunction>() { + @Override + public Tuple2 reduce(Tuple2 value1, Tuple2 value2) { + return new Tuple2<>(value1.f0, value1.f1 + value2.f1); + } + }); + counts.print(); + env.execute("Window WordCount"); +``` + +这段代码从 localhost 的 9999 端口接收数据流,将输入的每个字符串作为一个单词和数字 1 的 tuple 对象,然后根据单词进行分区,创建一个滚动窗口(间隔为5秒),并在每个窗口中对同一单词的数量进行累加统计,最后打印出结果。 + + + +非按键分区窗口: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.socketTextStream("localhost", 9999); + DataStream parsed = text.map(new MapFunction() { + @Override + public Integer map(String value) { + return Integer.parseInt(value); + } + }); + DataStream windowCounts = parsed + .windowAll(TumblingProcessingTimeWindows.of(Time.seconds(5))) + .reduce(new ReduceFunction() { + @Override + public Integer reduce(Integer value1, Integer value2) { + return value1 + value2; + } + }); + windowCounts.print().setParallelism(1); + env.execute("Non keyed Window example"); + } +``` + +这段程序从localhost的9999端口读取数据流,把每条数据转化为整数,然后在5秒的滚动窗口内将所有的整数值进行累加,并打印出结果。 + +### 窗口函数(WindowFunction) + +**所谓的“窗口函数”(window functions),就是定义窗口如何进行计算操作的函数** + +窗口函数根据处理的方式可以分为两类:「**增量窗口聚合函数**」和「**全窗口聚合函数**」。 + +#### 增量窗口聚合函数 + +增量窗口聚合函数每来一条数据就立即进行计算,中间保持着聚合状态,但是不立即输出结果,等到窗口到了结束时间需要输出计算结果的时候,取出之前聚合的状态直接输出。 + +常见的增量聚合函数有:`reduce()`、`aggregate()`、`sum()`、`min()`、`max()`。 + +下面是一个使用增量聚合函数的代码示例: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream data = env.fromSequence(1,Long.MAX_VALUE); + DataStream result = data.keyBy(value -> value % 2) + .window(TumblingProcessingTimeWindows.of(Time.seconds(5))) + .aggregate(new SumAggregator()); + result.print(); + env.execute("Incremental Aggregation Job"); + } + + public static class SumAggregator implements AggregateFunction { + @Override + public Long createAccumulator() { + return 0L; + } + @Override + public Long add(Long value, Long accumulator) { + return value + accumulator; + } + @Override + public Long getResult(Long accumulator) { + return accumulator; + } + @Override + public Long merge(Long a, Long b) { + return a + b; + } + } +``` + +这段代码从1到`Long.MAX_VALUE`产生一个连续的数据流。接着,它将数据按照奇偶性进行分类,并在每个5秒的时间窗口内对相同类别的数值进行累加操作。最后打印出累加结果。 + +#### 全窗口函数 + +全窗口函数是指在整个窗口中的所有数据都准备好后才进行计算。 + +Flink中的全窗口函数有两种: `WindowFunction`和`ProcessWindowFunction` 。 + +与增量窗口函数不同,全窗口函数可以访问窗口中的所有数据,因此可以执行更复杂的计算。例如,可以计算窗口中数据的中位数,或者对窗口中的数据进行排序。 + +WindowFunction接收一个Iterable类型的输入,其中包含了窗口中所有的数据。ProcessWindowFunction则更加强大,它不仅可以访问窗口中的所有数据, 还可以获取到一个“上下文对象”(Context)。 + +这个上下文对象非常强大,不仅能够获取窗口信息,还可以访问当前的时间和状态信息。这里的时间就包括了处理时间(Processing Time)和事件时间水位线(Event Time Watermark)。 + +这就使得 ProcessWindowFunction 更加灵活、功能更加丰富,WindowFunction作用可以被 ProcessWindowFunction 全覆盖。 + +不过这种额外的功能可能会带来一些性能上的损失,因此只有当你确实需要这些额外功能时,才应该使用ProcessWindowFunction,如果你不需要这些功能,“简单”的WindowFunction可能会更有效率。 + +下面是使用 WindowFunction 计算窗口内数据总和的代码示例: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.fromElements("a", "b", "c", "a", "b", "b"); + DataStream withTimestampsAndWatermarks = text.assignTimestampsAndWatermarks( + WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofMillis(100)) + .withTimestampAssigner((event, timestamp) -> System.currentTimeMillis()) + ); + DataStream> mapped = withTimestampsAndWatermarks.map( + new MapFunction>() { + @Override + public Tuple2 map(String value) { + return new Tuple2<>(value, 1); + } + }); + mapped.keyBy(0) + .timeWindow(Time.seconds(5)) + .apply(new SumWindowFunction()) + .print(); + env.execute("Window Sum"); + } +``` + +下面是一个使用ProcessWindowFunction统计网站1天UV的代码示例: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream> data = env.fromElements( + new Tuple2<>("user1", 1), + new Tuple2<>("user2", 1), + new Tuple2<>("user1", 1)); + data = data.assignTimestampsAndWatermarks(WatermarkStrategy + .>forMonotonousTimestamps() + .withTimestampAssigner((event, timestamp) -> System.currentTimeMillis()) + ); + data.keyBy(0) + .window(TumblingEventTimeWindows.of(Time.days(1))) + .process(new UVProcessWindowFunction()) + .print(); + env.execute("Daily User View Count"); + } + + public static class UVProcessWindowFunction extends ProcessWindowFunction, Tuple2, Tuple, TimeWindow> { + @Override + public void process(Tuple key, Context context, Iterable> input, Collector> out){ + long count = 0; + BloomFilter bloomFilter = BloomFilter.create(Funnels.stringFunnel(), 100000, 0.01); + for (Tuple2 in: input) { + if (!bloomFilter.mightContain(in.f0)) { + count += 1; + bloomFilter.put(in.f0); + } + } + out.collect(new Tuple2<>(key.getField(0), count)); + } + } +``` + +这段代码从数据流中读取用户视图数据(数据为("user", view_count)),然后对每个用户的观看次数实现了基于时间窗口(一天)的统计。利用布隆过滤器并在窗口内去重,可以避免重复计数。最后,每个窗口结束时,它会输出每个用户的id和相应的不重复观看次数。 + +#### 增量窗口函数和全窗口函数结合使用 + +全窗口函数为处理提供了更多的背景信息,因为它需要等到收集完所有窗口内的数据才进行计算,但是全窗口函数可能会增加系统的复杂性和运行时间。 + +另一方面,增量窗口函数可以在数据进入窗口时进行部分聚合计算,从而提高效率,但是它可能不适用于所有类型的计算,例如中位数或者标准差这种需要全部数据的计算就无法使用增量聚合。 + +在实际应用中,我们往往希望兼具这两者的优点,把它们结合在一起使用。Flink 的**Window API** 就给我们实现了这样的用法。 + +之前在调用 WindowedStream 的`reduce()`和`aggregate()`方法时,只是简单地直接传入了一个 ReduceFunction 或 AggregateFunction 进行增量聚合。除此之外,其实还可以传入第二个参数:一个全窗口函数,可以是 `WindowFunction` 或者`ProcessWindowFunction`。 + +```java +// ReduceFunction 与 WindowFunction 结合 +public SingleOutputStreamOperator reduce(ReduceFunction reduceFunction, WindowFunction function) + +// ReduceFunction 与 ProcessWindowFunction 结合 +public SingleOutputStreamOperator reduce(ReduceFunction reduceFunction, ProcessWindowFunction function) + +// AggregateFunction 与 WindowFunction 结合 +public SingleOutputStreamOperator aggregate(AggregateFunction aggFunction, WindowFunction windowFunction) + +// AggregateFunction 与 ProcessWindowFunction 结合 +public SingleOutputStreamOperator aggregate(AggregateFunction aggFunction, ProcessWindowFunction windowFunction) +``` + +这样调用的处理机制是:基于第一个参数(增量聚合函数)来处理窗口数据,每来一个数据就做一次聚合;等到窗口需要触发计算时,则调用第二个参数(全窗口函数)的处理逻辑输出结果。 + +**需要注意的是,这里的全窗口函数就不再缓存所有数据了,而是直接将增量聚合函数的结果拿来当作了 Iterable 类型的输入。一般情况下,这时的可迭代集合中就只有一个元素了** + +下面我们举一个具体的实例来说明: + +在网站的各种统计指标中,一个很重要的统计指标就是热门的链接,想要得到热门的 url,前提是得到每个链接的“热门度”。一般情况下,可以用url 的浏览量(点击量)表示热门度。我们这里统计 10 秒钟的 url 浏览量,每 5 秒钟更新一次。 + +我们可以定义滑动窗口,并结合增量聚合函数和全窗口函数来得到统计结果,代码示例如下: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream text = env.socketTextStream("localhost", 9999); + DataStream> urlCounts = text + .flatMap(new Tokenizer()) + .keyBy(value -> value.f0) + .window(SlidingProcessingTimeWindows.of(Time.seconds(10), Time.seconds(5))) + .aggregate(new CountAgg(), new WindowResultFunction()); + urlCounts.print(); + env.execute("UrlCount Job"); + } + + public static class CountAgg implements AggregateFunction, Long, Long> { + @Override + public Long createAccumulator() { + return 0L; + } + + @Override + public Long add(Tuple2 value, Long accumulator) { + return accumulator + value.f1; + } + + @Override + public Long getResult(Long accumulator) { + return accumulator; + } + + @Override + public Long merge(Long a, Long b) { + return a + b; + } + } + + public static class WindowResultFunction implements WindowFunction, String, TimeWindow> { + @Override + public void apply(String key, TimeWindow window, Iterable input, Collector> out) { + Long count = input.iterator().next(); + out.collect(new Tuple2<>(key, count)); + } + } + + public static final class Tokenizer implements FlatMapFunction> { + @Override + public void flatMap(String value, Collector> out) { + String[] words = value.toLowerCase().split("\\W+"); + for (String word : words) { + if (word.length() > 0) { + out.collect(new Tuple2<>(word, 1)); + } + } + } + } +``` + +在这个示例中,我们首先把数据根据 URL 进行了分组 (keyBy),然后定义了一个滑动窗口,窗口长度是10秒,每5秒滑动一次。接着我们使用增量聚合函数 `CountAgg` 对每个窗口内的元素进行聚合,最后用全窗口函数 `WindowResultFunction` 输出结果。 + +### Window重叠优化 + +窗口重叠是指在使用滑动窗口时,多个窗口之间存在重叠部分。这意味着同一批数据可能会被多个窗口同时处理。 + +例如,假设我们有一个数据流,它包含了0到9的整数。我们定义了一个大小为5的滑动窗口,滑动距离为2。那么,我们将会得到以下三个窗口: + +- 窗口1:包含0, 1, 2, 3, 4 +- 窗口2:包含2, 3, 4, 5, 6 +- 窗口3:包含4, 5, 6, 7, 8 + +在这个例子中,窗口1和窗口2之间存在重叠部分,即2, 3, 4。同样,窗口2和窗口3之间也存在重叠部分,即4, 5, 6。 + +`enableOptimizeWindowOverlap()`方法是用来启用Flink的窗口重叠优化功能的。它可以减少计算重叠窗口时的计算量。 + +在我之前给出的代码示例中,我没有使用`enableOptimizeWindowOverlap()`方法来启用窗口重叠优化功能。这意味着Flink不会尝试优化计算重叠窗口时的计算量。 + +如果你想使用窗口重叠优化功能,你可以在你的代码中添加以下行: + +```Java +env.getConfig().enableOptimizeWindowOverlap(); +``` + +这将启用窗口重叠优化功能,Flink将尝试优化计算重叠窗口时的计算量。 + +## 触发器(Trigger) + +触发器主要是用来控制窗口什么时候触发计算。所谓的“触发计算”,本质上就是执行窗口函数,所以可以认为是计算得到结果并输出的过程。 + +基于 WindowedStream 调用`trigger()`方法,就可以传入一个自定义的窗口触发器(Trigger)。 + +```css +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + DataStream dataStream = env.socketTextStream("localhost", 9999); + dataStream.map(new MapFunction>() { + @Override + public Tuple2 map(String value) { + return new Tuple2<>(value, 1); + } + }) + .keyBy(value -> value.f0) + .window(TumblingProcessingTimeWindows.of(Time.seconds(5))) + .trigger(new MyTrigger()) + .sum(1) + .print(); + env.execute("Flink Trigger Example"); + } + + public static class MyTrigger extends Trigger, TimeWindow> { + @Override + public TriggerResult onElement(Tuple2 stringIntegerTuple2, long l, TimeWindow timeWindow, TriggerContext triggerContext) throws Exception { + return TriggerResult.FIRE_AND_PURGE; + } + @Override + public TriggerResult onProcessingTime(long time, TimeWindow window, TriggerContext ctx) { + return TriggerResult.CONTINUE; + } + @Override + public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) { + return TriggerResult.CONTINUE; + } + @Override + public void clear(TimeWindow window, TriggerContext ctx) { + } + } +``` + +这段代码主要从localhost的9999端口读取数据流,每条数据映射为一个包含该数据和整数1的元组。然后按照元组的第一个元素进行分组,并在每5秒的滚动窗口中对元组的第二个元素求和。最后使用用户自定义触发器,当新元素到达时立即触发计算并清空窗口,但在处理时间或事件时间上不做任何操作。 + +Trigger 是窗口算子的内部属性,每个窗口分配器(WindowAssigner)都会对应一个默认的触发器。 + +对于 Flink 内置的窗口类型,它们的触发器都已经做了实现。例如,所有事件时间窗口,默认的触发器都是EventTimeTrigger,类似还有 ProcessingTimeTrigger 和 CountTrigger。所以一般情况下是不需要自定义触发器的,这块了解一下即可。 + +## 移除器(Evictor) + +移除器(Evictor)是用于在滚动窗口或会话窗口中控制数据保留和清理的组件。它可以根据特定的策略从窗口中删除一些数据,以确保窗口中保留的数据量不超过指定的限制。 + +移除器通常与窗口分配器一起使用,窗口分配器负责确定数据属于哪个窗口,而移除器则负责清理窗口中的数据。 + +以下是一个使用移除器的代码示例,演示如何在滚动窗口中使用基于计数的移除器: + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 创建一个包含整数和时间戳的流 + DataStream> dataStream = env.fromElements( + Tuple2.of(1, System.currentTimeMillis()), + Tuple2.of(2, System.currentTimeMillis() + 1000), + Tuple2.of(3, System.currentTimeMillis() + 2000), + Tuple2.of(4, System.currentTimeMillis() + 3000), + Tuple2.of(5, System.currentTimeMillis() + 4000), + Tuple2.of(6, System.currentTimeMillis() + 5000) + ); + // 添加以下代码设置水印和事件时间戳 + dataStream = dataStream.assignTimestampsAndWatermarks(WatermarkStrategy.>forBoundedOutOfOrderness(Duration.ofSeconds(1)) + .withTimestampAssigner((event, timestamp) -> event.f1)); + // 在滚动窗口中使用基于计数的移除器,保留最近3个元素 + dataStream + .keyBy(value -> value.f0) + .window(TumblingEventTimeWindows.of(Time.seconds(5))) + .trigger(CountTrigger.of(3)) + .evictor(CountEvictor.of(3)) + .aggregate(new MyAggregateFunction(), new MyProcessWindowFunction()) + .print(); + + env.execute("Flink Evictor Example"); + } + + // 自定义聚合函数 + private static class MyAggregateFunction implements AggregateFunction, Integer, Integer> { + @Override + public Integer createAccumulator() { + return 0; + } + + @Override + public Integer add(Tuple2 value, Integer accumulator) { + return accumulator + 1; + } + + @Override + public Integer getResult(Integer accumulator) { + return accumulator; + } + + @Override + public Integer merge(Integer a, Integer b) { + return a + b; + } + } + + // 自定义处理窗口函数 + private static class MyProcessWindowFunction extends ProcessWindowFunction { + private transient ListState countState; + + @Override + public void open(Configuration parameters) throws Exception { + super.open(parameters); + ListStateDescriptor descriptor = new ListStateDescriptor<>("countState", Integer.class); + countState = getRuntimeContext().getListState(descriptor); + } + + @Override + public void process(Integer key, Context context, Iterable elements, Collector out) throws Exception { + int count = elements.iterator().next(); + countState.add(count); + long windowStart = context.window().getStart(); + long windowEnd = context.window().getEnd(); + String result = "Window: " + windowStart + " to " + windowEnd + ", Count: " + countState.get().iterator().next(); + out.collect(result); + } + } +``` + +这段代码主要用于对一串包含整数和时间戳的元素进行处理。首先,它创建了一个流并赋予了水印和时间戳。然后在滚动窗口中使用基于计数的触发器和驱逐器,只保留最近的三个元素。之后,通过自定义聚合和窗口函数,来处理窗口内的数据,聚合函数计算每个窗口内元素的数量,窗口函数将结果与窗口的开始和结束时间一起输出。 + +## Flink Time 时间语义 + +Flink定义了三类时间 + +- **事件时间(Event Time)**:数据在数据源产生的时间,一般由事件中的时间戳描述,比如用户日志中的TimeStamp。 +- **摄取时间(Ingestion Time)**:数据进入Flink的时间,记录被Source节点观察到的系统时间。 +- **处理时间(Process Time)**:数据进入Flink被处理的系统时间(Operator处理数据的系统时间)。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpBZRCkBIDh9K3y5YzxXux7GLYLpqhPy6DpDwb0geTjibkv5JPnDoEtXQ/640) + +Flink 流式计算的时候需要显示定义时间语义,根据不同的时间语义来处理数据,比如指定的时间语义是事件时间,那么我们就要切换到事件时间的世界观中,窗口的起始与终止时间都是以事件时间为依据。 + +在Flink中默认使用的是Process Time,如果要使用其他的时间语义,在执行环境中可以进行设置。 + +```java +//设置时间语义为Ingestion Time +env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime); +//设置时间语义为Event Time 我们还需要指定一下数据中哪个字段是事件时间(下文会讲) +env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime); +``` + +### Watermark(水印) + +**Watermark的本质实质上是时间戳,简单而言,它是用来处理迟到数据的** + +在使用Flink处理数据的时候,数据通常都是按照事件产生的时间(事件时间)的顺序进入到Flink,但是在遇到特殊情况下,比如遇到网络延迟或者使用Kafka(多分区) 很难保证数据都是按照事件时间的顺序进入Flink,很有可能是乱序进入。 + +如果数据一旦是乱序进入,那么在使用Window处理数据的时候,就会出现延迟数据不会被计算的问题。 + +- 举例: 滚动窗口长度10s。 + + 2020-04-25 10:00:01 + + 2020-04-25 10:00:02 + + 2020-04-25 10:00:03 + + 2020-04-25 10:00:11 窗口触发执行 + + 2020-04-25 10:00:05 延迟数据,不会被上个窗口所计算,导致计算结果不正确 + +如果有延迟数据,那么窗口需要等待全部的数据到来之后,再触发窗口执行。 + +需要等待多久?不可能无限期等待,我们用户可以自己来设置延迟时间,这样就可以尽可能保证延迟数据被处理。 + +使用Watermark就可以很好的解决延迟数据的问题。 + +根据用户指定的延迟时间生成水印(Watermak = 最大事件时间-指定延迟时间),当 Watermak 大于等于窗口的停止时间,这个窗口就会被触发执行。 + +- 举例:滚动窗口长度10s,指定延迟时间3s + + 2020-04-25 10:00:01 wm:2020-04-25 09:59:58 + + 2020-04-25 10:00:02 wm:2020-04-25 09:59:59 + + 2020-04-25 10:00:03 wm:2020-04-25 10:00:00 + + 2020-04-25 10:00:09 wm:2020-04-25 10:00:06 + + 2020-04-25 10:00:12 wm:2020-04-25 10:00:09 + + 2020-04-25 10:00:08 wm:2020-04-25 10:00:05 延迟数据 + + 2020-04-25 10:00:13 wm:2020-04-25 10:00:10 + +**如果没有 Watermark ,那么在倒数第三条数据来的时候,就会触发执行,倒数第二条的延迟数据就不会被计算,有了水印之后就可以处理延迟3s内的数据** + +#### 生成水印策略 + +- **周期性水印(Periodic Watermark)**:根据事件或者处理时间周期性的触发水印生成器(Assigner),默认100ms,每隔100毫秒自动向流里注入一个Watermark。 + + ```java + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime); + env.getConfig().setAutoWatermarkInterval(100); + DataStream stream = env.socketTextStream("node01", 8888) + .assignTimestampsAndWatermarks(WatermarkStrategy + .forBoundedOutOfOrderness(Duration.ofSeconds(3)) + .withTimestampAssigner((event, timestamp) -> { + return Long.parseLong(event.split(" ")[0]); + })); + ``` + +- **间歇性水印**:间歇性水印(Punctuated Watermark)在观察到事件后,会依据用户指定的条件来决定是否发射水印。 + + ```java + public class PunctuatedAssigner implements AssignerWithPunctuatedWatermarks> { + @Override + public long extractTimestamp(Tuple2 element, long previousElementTimestamp) { + return element.f1; + } + @Override + public Watermark checkAndGetNextWatermark(Tuple2 lastElement, long extractedTimestamp) { + return lastElement.f0.equals("watermark") ? new Watermark(extractedTimestamp) : null; + } + public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.addSource(new SourceFunction>() { + private boolean running = true; + @Overrid + public void run(SourceContext> ctx) throws Exception { + while (running) { + long currentTimestamp = System.currentTimeMillis(); + ctx.collect(new Tuple2<>("key", currentTimestamp)); + if (currentTimestamp % 10 == 0) { + // 每隔一段时间发出一个含有"watermark"的特殊事件 + ctx.collect(new Tuple2<>("watermark", currentTimestamp)); + } + Thread.sleep(1000); + } + } + @Override + public void cancel() { + running = false; + } + }).assignTimestampsAndWatermarks(new PunctuatedAssigner()) + .print(); + env.execute("Punctuated Watermark Example"); + } + } + ``` + +这段代码定义了一个名为PunctuatedAssigner的时间戳和watermark分配器类,用于从接收到的元素中提取出时间戳,并根据特定条件(在本例中,元素的key是否为"watermark")生成并发送watermark。 + +在main方法中,创建了一个源函数,此函数每秒生成一个新的事件,并且每隔10毫秒就发出一个包含"watermark"的特殊事件。这些事件被收集,分配时间戳和watermark,然后打印出来。 + +### 允许延迟(Allowed Lateness) + +Flink 还提供了另外一种方式处理迟到数据。我们可以将未收入窗口的迟到数据,放入“侧输出流”(side output)进行另外的处理。所谓的侧输出流,相当于是数据流的一个“分支”,**这个流中单独放置那些错过了、本该被丢弃的数据**。 + +此方法需要传入一个“输出标签”(OutputTag),用来标记分支的迟到数据流。因为保存的就是流中的原始数据,所以 OutputTag 的类型与流中数据类型相同: + +```dart +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 定义 OutputTag 来标识侧输出流 + final OutputTag lateDataTag = new OutputTag("late-data"){}; + DataStream dataStream = env.socketTextStream("localhost", 9000); + SingleOutputStreamOperator> resultStream = dataStream + .map(new MapFunction>() { + @Override + public Tuple2 map(String value) throws Exception { + return new Tuple2<>(value, 1); + } + }) + .keyBy(value -> value.f0) + .process(new ProcessFunction, Tuple2>() { + @Override + public void processElement(Tuple2 value, + Context ctx, + Collector> out) throws Exception { + if (value.f1 == 1) { + out.collect(value); + } else { + // 将迟到的数据发送到侧输出流 + ctx.output(lateDataTag, "Late data detected: " + value); + } + } + }); + // 获取侧输出流 + DataStream lateDataStream = resultStream.getSideOutput(lateDataTag); + resultStream.print(); + lateDataStream.print(); + env.execute("SideOutput Example"); + } +``` + +这段代码首先建立一个从本地 9000 端口读取数据的流,然后将每一行数据映射为一个二元组 (value, 1)。接着按照第一个字段进行分组,并进行处理:如果二元组的第二个元素等于 1,则直接输出;否则,该条数据会被视为“迟到数据”并输出至侧输出流。最后,主流和侧输出流的结果都会打印出来。 + +## Flink关联维度表 + +在Flink实际开发过程中,可能会遇到 source 进来的数据,需要连接数据库里面的字段,再做后面的处理,比如,想要通过id获取对应的地区名字,这时候需要通过id查询地区维度表,获取具体的地区名。 + +对于不同的应用场景,关联维度表的方式不同 + +- 场景1:维度表信息基本不发生改变,或者发生改变的频率很低。 + + 实现方案:采用Flink提供的CachedFile。 + + Flink提供了一个分布式缓存(CachedFile),可以使用户在并行函数中很方便的读取本地文件,并把它放在TaskManager节点中,防止Task重复拉取。 + + 此缓存的工作机制如下:程序注册一个文件或者目录(本地或者远程文件系统,例如hdfs或者s3),通过ExecutionEnvironment注册缓存文件并为它起一个名称。 + + 当程序执行,Flink自动将文件或者目录复制到所有TaskManager节点的本地文件系统,仅会执行一次。用户可以通过这个指定的名称查找文件或者目录,然后从TaskManager节点的本地文件系统访问它。 + + ```java + public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.registerCachedFile("/root/id2city", "id2city"); + DataStream socketStream = env.socketTextStream("node01", 8888); + DataStream stream = socketStream.map(Integer::valueOf); + DataStream result = stream.map(new RichMapFunction() { + private Map id2CityMap; + @Override + public void open(Configuration parameters) throws Exception { + super.open(parameters); + id2CityMap = new HashMap<>(); + BufferedReader reader = new BufferedReader(new FileReader(getRuntimeContext().getDistributedCache().getFile("id2city"))); + String line; + while ((line = reader.readLine()) != null) { + String[] splits = line.split(" "); + Integer id = Integer.parseInt(splits[0]); + String city = splits[1]; + id2CityMap.put(id, city); + } + reader.close(); + } + @Override + public String map(Integer value) throws IOException { + return id2CityMap.getOrDefault(value, "not found city"); + } + }); + result.print(); + env.execute(); + } + ``` + + 这段程序首先从"node01"主机的8888端口读取数据,然后将其转换为整数流。接着,它用一个富映射函数(RichMapFunction)将每个整数ID映射到城市名。这个映射是从在"/root/id2city"路径下注册的缓存文件中读取的。如果无法找到某个ID对应的城市,就会返回"not found city"。 + + 在集群中查看对应TaskManager的log日志,发现注册的file会被拉取到各个TaskManager的工作目录区。 + + + +- 场景2:对于维度表更新频率比较高并且对于查询维度表的实时性要求比较高。 + + 实现方案:使用定时器,定时加载外部配置文件或者数据库 + + ```java + public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + DataStream stream = env.socketTextStream("node01", 8888); + stream.map(new RichMapFunction() { + private HashMap map = new HashMap<>(); + @Override + public void open(Configuration parameters) throws Exception { + System.out.println("init data ..."); + query(); + Timer timer = new Timer(true); + timer.schedule(new TimerTask() { + @Override + public void run() { + try { + query(); + } catch (IOException e) { + e.printStackTrace(); + } + } + },1000,2000); + } + void query() throws IOException { + Path path = Paths.get("D:\\code\\StudyFlink\\data\\id2city"); + Stream lines = Files.lines(path); + lines.forEach(line -> { + String[] parts = line.split(" "); + map.put(parts[0], parts[1]); + }); + + lines.close(); + } + @Override + public String map(String key) throws Exception { + return map.getOrDefault(key, "not found city"); + } + }).print(); + env.execute(); + } + ``` + + 这段代码从名为"node01"的服务器的8888端口读取数据流,然后通过映射函数将每个接收到的数据键值(假设是城市ID)转换为对应的城市名称。此映射来自一个定期更新的文件"D:\code\StudyFlink\data\id2city",如果没有找到匹配的城市ID,则返回"not found city"。 + + + +- 场景3:对于维度表更新频率高并且对于查询维度表的实时性要求较高。 + + 实现方案:将更改的信息同步至Kafka配置Topic中,然后将kafka的配置流信息变成广播流,广播到业务流的各个线程中。 + + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + Properties props = new Properties(); + props.setProperty("bootstrap.servers", "node01:9092,node02:9092,node03:9092"); + props.setProperty("group.id", "flink-kafka-001"); + props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); + props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); + FlinkKafkaConsumer consumer = new FlinkKafkaConsumer<>( + "configure", + new SimpleStringSchema(), + props + ); + consumer.setStartFromLatest(); + DataStream configureStream = env.addSource(consumer); + DataStream busStream = env.socketTextStream("node01", 8888); + MapStateDescriptor descriptor = new MapStateDescriptor<>( + "dynamicConfig", + BasicTypeInfo.STRING_TYPE_INFO, + BasicTypeInfo.STRING_TYPE_INFO + ); + BroadcastStream broadcastStream = configureStream.broadcast(descriptor); + busStream.connect(broadcastStream).process( + new BroadcastProcessFunction() { + @Override + public void processElement(String line, ReadOnlyContext ctx, Collector out) throws Exception { + String city = ctx.getBroadcastState(descriptor).get(line); + if (city == null) { + out.collect("not found city"); + } else { + out.collect(city); + } + } + + @Override + public void processBroadcastElement(String line, Context ctx, Collector out) throws Exception { + String[] elems = line.split(" "); + ctx.getBroadcastState(descriptor).put(elems[0], elems[1]); + } + } + ).print(); + env.execute(); + } +``` + +这段代码将从Kafka中获取的数据作为广播流,然后与从socket中获取的数据处理。在处理过程中,根据socket中的数据(作为key)查找广播状态中的城市名称(作为value),如果找到,则输出城市名,否则输出"not found city"。其中,Kafka中的数据以空格分隔,第一个元素作为key,第二个元素作为value存入BroadcastState。 + +## Table API & Flink SQL + +在Spark中有DataFrame这样的关系型编程接口,因其强大且灵活的表达能力,能够让用户通过非常丰富的接口对数据进行处理,有效降低了用户的使用成本。 + +Flink也提供了关系型编程接口Table API以及基于Table API的SQL API,让用户能够通过使用结构化编程接口高效地构建Flink应用。同时Table API以及SQL能够统一处理批量和实时计算业务,无须切换修改任何应用代码就能够基于同一套API编写流式应用和批量应用,从而达到真正意义的流批统一。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMFdkqmnnJ526HR8ktibGOLpJCicXKXH7TiaetzpicQGVxxmjkCalOFRRDkabzHia16WjeDZOicMYrlnmcA/640) + +在 Flink 1.8 架构里,如果用户需要同时流计算、批处理的场景下,用户需要维护两套业务代码,开发人员也要维护两套技术栈,非常不方便。 Flink 社区很早就设想过将批数据看作一个有界流数据,将批处理看作流计算的一个特例,从而实现流批统一。 + +阿里巴巴的 Blink 团队在这方面做了大量的工作,已经实现了 Table API & SQL 层的流批统一。阿里巴巴已经将 Blink 开源回馈给 Flink 社区。 + +### 开发环境构建 + +在 Flink 1.9 中,Table 模块迎来了核心架构的升级,引入了阿里巴巴Blink团队贡献的诸多功能,取名叫: **Blink Planner**。 + +在使用Table API和SQL开发Flink应用之前,通过添加Maven的依赖配置到项目中,在本地工程中引入相应的依赖库,库中包含了Table API和SQL接口。 + +```xml + + org.apache.flink + flink-table-planner_2.12 + 1.13.6 + + + org.apache.flink + flink-table-api-scala-bridge_2.12 + 1.13.6 + +``` + +### Table Environment + +和DataStream API一样,Table API和SQL具有相同的基本编程模型。首先需要构建对应的 TableEnviroment 创建关系型编程环境,才能够在程序中使用Table API和SQL来编写应用程序,另外Table API和SQL接口可以在应用中同时使用,Flink SQL基于Apache Calcite框架实现了SQL标准协议,是构建在Table API之上的更高级接口。 + +首先需要在环境中创建 TableEnvironment 对象,TableEnvironment 中提供了注册内部表、执行Flink SQL语句、注册自定义函数等功能。根据应用类型的不同,TableEnvironment 创建方式也有所不同,但是都是通过调用`create()`方法创建。 + +流计算环境下创建 TableEnviroment : + +```java +//创建流式计算的上下文环境 +final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); +//创建Table API的上下文环境 +StreamTableEnvironment streamTableEnvironment = StreamTableEnvironment.create(env); +``` + +### Table API + +**Table API 顾名思义,就是基于“表”(Table)的一套 API,专门为处理表而设计的** + +它提供了关系型编程模型,可以用来处理结构化数据,支持表和视图的概念。在此基础上,Flink 还基于 Apache Calcite 实现了对 SQL 的支持。这样一来,我们就可以在 Flink 程序中直接写 SQL 来实现需求了,非常实用。 + +下面是一个简单的例子,它使用Java编写了一个Flink程序,该程序使用 Table API 从CSV文件中读取数据,然后执行简单的查询并将结果写入到自定义的Sink中。 + +首先我们需要导入maven依赖: + +```xml + + org.apache.flink + flink-table-api-java-bridge_2.12 + 1.13.6 + +``` + +代码示例如下: + +```Java +public static void main(String[] args) throws Exception { + // 创建流处理环境 + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 创建表环境 + EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inBatchMode().build(); + StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings); + // 从CSV文件中读取数据 + DataStream> data = env.readTextFile("input.csv") + .map(line -> { + String[] parts = line.split(","); + return new Tuple2<>(parts[0], Integer.parseInt(parts[1])); + }) + .returns(Types.TUPLE(Types.STRING, Types.INT)); + // 使用Table API将数据转换为表并注册为视图 + String name = "people"; + Schema schema = Schema.newBuilder() + .column("name", DataTypes.STRING()) + .column("age", DataTypes.INT()) + .build(); + tableEnv.createTemporaryView(name, data, schema); + // 使用SQL查询年龄大于30的人 + Table result = tableEnv.sqlQuery("SELECT name, age FROM people WHERE age > 30"); + // 将结果转换为DataStream + DataStream output = tableEnv.toDataStream(result); + output.addSink(new SinkFunction() { + @Override + public void invoke(Row value, Context context) throws Exception { + // implement the sink here, e.g., write into a file, send to Kafka, etc. + } + }); + env.execute(); + } +``` + +这段代码是在流处理环境中实现的一个简单的ETL(提取-转换-加载)过程:它从CSV文件中读取数据,对数据进行映射和转化,然后使用SQL查询在一个临时视图上查找年龄大于30的人,最后将结果输出到某个自定义的Sink上。 + +#### Virtual Tables(虚拟表) + +在环境中注册之后,我们就可以在 SQL 中直接使用这张表进行查询转换了。 + +```Java +Table newTable = tableEnv.sqlQuery("SELECT name, age FROM people WHERE age > 30"); +``` + +得到的 newTable 是一个中间转换结果,如果之后又希望直接使用这个表执行 SQL,又该怎么做呢?由于 newTable 是一个 Table 对象,并没有在表环境中注册,所以我们还需要将这个中间结果表注册到环境中,才能在 SQL 中使用: + +```Java +tableEnv.createTemporaryView("NewTable", newTable); +``` + +这里的注册其实是创建了一个“虚拟表”(Virtual Table)。这个概念与 SQL 语法中的视图(View)非常类似,所以调用的方法也叫作创建“虚拟视图” (createTemporaryView)。 + +#### 表流互转 + +```Java +// 将表转换成数据流,并打印 +tableEnv.toDataStream(result).print(); +// 将数据流转换成表 +// 我们还可以在 fromDataStream()方法中增加参数,用来指定提取哪些属性作为表中的字段名,并可以任意指定位置 +Table table = tableEnv.fromDataStream(eventStream, $("timestamp").as("ts"),$("url")); +``` + +#### 动态表和持续查询 + +在Flink中,动态表(Dynamic Tables)是一种特殊的表,它可以随时间变化。它们通常用于表示无限流数据,例如事件流或服务器日志。与静态表不同,动态表可以在运行时插入、更新和删除行。 + +动态表可以像静态的批处理表一样进行查询操作。由于数据在不断变化,因此基于它定义的 SQL 查询也不可能执行一次就得到最终结果。这样一来,我们对动态表的查询也就永远不会停止,一直在随着新数据的到来而继续执行。这样的查询就被称作持续查询(Continuous Query)。 + +下面是一个简单的例子,它使用Java编写了一个Flink程序,该程序从名为"input-topic"的Kafka主题中读取JSON格式的数据(属性包括"name"和"age"),过滤出所有年龄大于30岁的记录,并将结果输出到另一个名为"output-topic"的Kafka主题中。同时,处理的结果也会在控制台上打印出来。 + +```Java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inStreamingMode().build(); + StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env, settings); + tableEnv.executeSql("CREATE TABLE input (" + + " name STRING," + + " age INT" + + ") WITH (" + + " 'connector' = 'kafka'," + + " 'topic' = 'input-topic'," + + " 'properties.bootstrap.servers' = 'localhost:9092'," + + " 'format' = 'json'" + + ")"); + + tableEnv.executeSql("CREATE TABLE output (" + + " name STRING," + + " age INT" + + ") WITH (" + + " 'connector' = 'kafka'," + + " 'topic' = 'output-topic'," + + " 'properties.bootstrap.servers' = 'localhost:9092'," + + " 'format' = 'json'" + + ")"); + + Table result = tableEnv.sqlQuery("SELECT name, age FROM input WHERE age > 30"); + tableEnv.toAppendStream(result, Row.class).print(); + result.executeInsert("output"); + env.execute(); + } +``` + +#### 连接到外部系统 + +在 Table API编写的 Flink 程序中,可以在创建表的时候用 WITH 子句指定连接器(connector),这样就可以连接到外部系统进行数据交互。 + +其中最简单的当然就是连接到控制台打印输出: + +```Java +CREATE TABLE ResultTable ( + user STRING, + cnt BIGINT +WITH ( + 'connector' = 'print' +); +``` + +##### Kafka + +需要导入maven依赖: + +```XML + + org.apache.flink + flink-connector-kafka_2.12 + 1.13.6 + +``` + +创建一个连接到 Kafka 的表,需要在 CREATE TABLE 的 DDL 中在 WITH 子句里指定连接器为 Kafka,并定义必要的配置参数: + +```sql +CREATE TABLE KafkaTable ( + `user` STRING, + `url` STRING, + `ts` TIMESTAMP(3) METADATA FROM 'timestamp' +) WITH ( + 'connector' = 'kafka', + 'topic' = 'events', + 'properties.bootstrap.servers' = 'localhost:9092', + 'properties.group.id' = 'testGroup', + 'scan.startup.mode' = 'earliest-offset', + 'format' = 'csv' +) +``` + +##### MySQL + +```xml + + org.apache.flink + flink-connector-jdbc_2.12 + 1.13.6 + +``` + +创建 JDBC 表的方法与前面 Kafka 大同小异: + +```sql +-- 创建一张连接到 MySQL 的 表 +CREATE TABLE MyTable ( + id BIGINT, + name STRING, + age INT, + status BOOLEAN, + PRIMARY KEY (id) NOT ENFORCED +) WITH ( + 'connector' = 'jdbc', + 'url' = 'jdbc:mysql://localhost:3306/mydatabase', + 'table-name' = 'users' +); +-- 将另一张表 T 的数据写入到 MyTable 表中 +INSERT INTO MyTable +SELECT id, name, age, status FROM T; +``` + +### Table API实战 + +#### 1.创建Table + +Table API中已经提供了TableSource从外部系统获取数据,例如常见的数据库、文件系统和Kafka消息队列等外部系统。 + +1. 从文件中创建Table(静态表) + + Flink允许用户从本地或者分布式文件系统中读取和写入数据,只需指定相应的参数即可。但是文件格式必须是CSV格式的。其他文件格式也支持(在Flink中还有Connector等来支持其他格式或者自定义TableSource) + + ```java + public static void main(String[] args) throws Exception { + // 创建流式计算的上下文环境 + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + // 创建Table API的上下文环境 + StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env); + // 创建CSV表源 + String sourceDDL = "CREATE TABLE exampleTab (" + + "`id` INT, " + + "`name` STRING, " + + "`score` DOUBLE" + + ") WITH (" + + "'connector' = 'filesystem'," + + "'path' = 'D:\\code\\StudyFlink\\data\\tableexamples'," + + "'format' = 'csv'" + + ")"; + tableEnv.executeSql(sourceDDL); + // 打印表结构 + ResolvedSchema schema = tableEnv.from("exampleTab").getResolvedSchema(); + System.out.println(schema.toString()); + } + ``` + +2. 从DataStream中创建 Table(动态表) + + 前面已经知道Table API是构建在DataStream API和DataSet API之上的一层更高级的抽象,因此用户可以灵活地使用Table API将Table转换成DataStream或DataSet数据集,也可以将DataSteam或DataSet数据集转换成Table,这和Spark中的DataFrame和RDD的关系类似。 + + ```java + public static void main(String[] args) throws Exception { + // 先创建StreamExecutionEnvironment + final StreamExecutionEnvironment bsEnv = StreamExecutionEnvironment.getExecutionEnvironment(); + EnvironmentSettings bsSettings = EnvironmentSettings.newInstance().useBlinkPlanner().inStreamingMode().build(); + StreamTableEnvironment bsTableEnv = StreamTableEnvironment.create(bsEnv, bsSettings); + // 创建一个DataStream + DataStream> stream = bsEnv.fromElements(Tuple2.of("Alice", 3), Tuple2.of("Bob", 4)); + // 将DataStream转化为Table + Table table1 = bsTableEnv.fromDataStream(stream); + // 再把Table转回DataStream + DataStream streamAgain = bsTableEnv.toDataStream(table1); + } + ``` + +#### 2.查询和过滤 + +在Table对象上使用`select`操作符查询需要获取的指定字段,也可以使用`filter`或`where`方法过滤字段和检索条件,将需要的数据检索出来。 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment(); + streamEnv.setParallelism(1); + // Create the Table API execution environment. + StreamTableEnvironment tableEnv = StreamTableEnvironment.create(streamEnv); + SingleOutputStreamOperator> data = streamEnv.socketTextStream("hadoop101", 8888) + .map(new MapFunction>() { + @Override + public Tuple5 map(String line) throws Exception { + String[] arr = line.split(","); + return new Tuple5<>(arr[0].trim(), arr[1].trim(), arr[2].trim(), Long.parseLong(arr[4].trim()), Long.parseLong(arr[5].trim())); + } + }); + Table table = tableEnv.fromDataStream(data); + // Query + tableEnv.toAppendStream(table.select("f0 AS sid, f1 AS type, f3 AS callTime, f4 AS callOut"), Row.class) + .print(); + // Filter Query + tableEnv.toAppendStream(table.filter("f1 === 'success'").where("f1 === 'success'"), Row.class) + .print(); + tableEnv.execute("sql"); + } +``` + +这段代码从一个指定的socket中读取文本数据,将每一行数据映射为一个5元组(Tuple5),然后把这个数据流转换为表,并进行查询操作。首先,它进行简单的列选择查询并打印结果;然后,它进行筛选查询,选取第二字段"成功"的记录并打印出来。整个过程在一个名为"sql"的任务中执行。 + +#### 3.UDF自定义函数 + +用户可以在Table API中自定义函数类,常见的抽象类和接口是: + +- ScalarFunction +- TableFunction +- AggregateFunction +- TableAggregateFunction + +```java +public static void main(String[] args) { + EnvironmentSettings settings = EnvironmentSettings.newInstance().useBlinkPlanner().inBatchMode().build(); + TableEnvironment tableEnv = TableEnvironment.create(settings); + // 注册UDF + tableEnv.createTemporarySystemFunction("UpperCase", UpperCaseFunction.class); + // 使用UDF + tableEnv.executeSql( + "SELECT UpperCase(myField) FROM myTable" + ); + } + + public static class UpperCaseFunction extends ScalarFunction { + public String eval(String str) { + return str.toUpperCase(); + } + } +``` + +这段代码创建了自定义函数(UDF)并使用它。首先,它设置了 Flink 的环境,并通过 Blink Planner 以批处理模式运行。然后,它注册了一个名为 "UpperCase" 的 UDF,该函数将输入字符串转换为大写。最后,它在 SQL 查询中使用了这个 UDF,将 "myTable" 中的 "myField" 字段的值转换成大写形式。 + +#### 4.Window + +```java +public static void main(String[] args) throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env); + // 创建一个具有 Process Time 时间属性的表 + tableEnv.executeSql( + "CREATE TABLE Orders (" + + "orderId INT, " + + "price DOUBLE, " + + "buyer STRING, " + + "orderTime TIMESTAMP(3)," + + "pt AS PROCTIME()" + // 使用处理时间 + ") WITH ('connector' = '...', ...)" + ); + + Table orders = tableEnv.from("Orders"); + Table result1 = orders.window(Tumble.over(lit(10).minutes()).on($("pt")).as("w")) + .groupBy($("w"), $("buyer")) + .select($("buyer"), $("w").start().as("start"), $("w").end().as("end"), $("price").sum().as("totalPrice")); + + // 创建一个具有 Event Time 时间属性的表,使用Watermarks + tableEnv.executeSql( + "CREATE TABLE OrdersEventTime (" + + "orderId INT, " + + "price DOUBLE, " + + "buyer STRING, " + + "orderTime TIMESTAMP(3), " + + "WATERMARK FOR orderTime AS orderTime - INTERVAL '5' SECOND" + // 使用事件时间和水印 + ") WITH ('connector' = '...', ...)" + ); + + Table ordersEventTime = tableEnv.from("OrdersEventTime"); + Table result2 = ordersEventTime.window(Tumble.over(lit(10).minutes()).on($("orderTime")).as("w")) + .groupBy($("w"), $("buyer")) + .select($("buyer"), $("w").start().as("start"), $("w").end().as("end"), $("price").sum().as("totalPrice")); + // 对于 IngestionTime,Flink 1.12 中已经不推荐使用,因此在 Flink 1.13.6 版本中,你应该使用 ProcessTime 或 EventTime。 + } +``` + +这段代码创建了两个表:一个使用处理时间(Process Time),另一个使用事件时间(Event Time)并设置了水印。针对这两个表,分别在买家(buyer)和10分钟的时间窗口上进行分组,并计算了每个时间窗口中的总价(totalPrice)。 + +### 多类型数据流 + +在 Flink 中,`DataStream`,`ChangelogStream`,`AppendStream`和 `RetractStream` 用于表示不同类型的数据流。简单来说,它们之间的主要区别和联系如下: + +- **DataStream**:这是 Flink 的基础抽象,它表示一个无界的数据流,可以包含任何类型的元素。 +- **toChangelogStream**:这个方法将表转换为一个 ChangeLog 模式的 DataStream。每条记录都代表一个添加、修改或删除的事件。事件通常由可选的元数据标记(例如,'+'(添加)或'-'(撤销))、更新时间以及唯一的键和值组成。ChangelogStream 主要用于处理动态表,并且支持插入,更新和删除操作。 +- **toAppendStream**:这个方法将表转换为一个只包含添加操作的 DataStream。换句话说,结果表只包含插入(append)操作,不能执行更新或删除操作。如果查询的结果表支持删除或更新,则此方法会抛出异常。 +- **toRetractStream**:这个方法将表转换为一个包含添加和撤销消息的 DataStream。每一条添加消息表示在结果表中插入了一行,而每一条撤销消息表示在结果表中删除了一行。如果撤销消息后没有相应的添加消息,那么可能是因为输入数据发生了变化,导致之前发送的结果不再正确,需要被撤销。 + +### Flink SQL + +**企业中Flink SQL比Table API用的多** + + +Flink SQL 是 Apache Flink 提供的一种使用 SQL 查询和处理数据的方式。它允许用户通过 SQL 语句对数据流或批处理数据进行查询、转换和分析,无需编写复杂的代码。Flink SQL 提供了一种更直观、易于理解和使用的方式来处理数据,同时也可以与 Flink 的其他功能无缝集成。 + +Flink SQL 支持 ANSI SQL 标准,并提供了许多扩展和优化来适应流式处理和批处理场景。它能够处理无界数据流,具备事件时间和处理时间的语义,支持窗口、聚合、连接等常见的数据操作,还提供了丰富的内置函数和扩展插件机制。 + +下面是一个简单的 Flink SQL 代码示例,展示了如何使用 Flink SQL 对流式数据进行查询和转换。 + +```java +public static void main(String[] args) throws Exception { + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); // 设置并行度为1,方便观察输出结果 + // 创建 Kafka 数据源 + Properties properties = new Properties(); + properties.setProperty("bootstrap.servers", "localhost:9092"); + properties.setProperty("group.id", "flink-consumer"); + FlinkKafkaConsumer kafkaConsumer = new FlinkKafkaConsumer<>("input-topic", new SimpleStringSchema(), properties); + DataStream sourceStream = env.addSource(kafkaConsumer); + // 获取 StreamTableEnvironment + StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env); + // 注册数据源表 + tableEnv.createTemporaryView("source_table", sourceStream, "message"); + // 执行 SQL 查询和转换 + String query = "SELECT message, COUNT(*) AS count FROM source_table GROUP BY message"; + // 执行 SQL 查询和转换 + Table resultTable = tableEnv.sqlQuery(query); + DataStream resultStream = tableEnv.toDataStream(resultTable) + .map(row -> new Result(row.getField(0).toString(), (Long) row.getField(1))); + // 打印结果 + resultStream.print(); + env.execute("Flink SQL Example"); + } + + // 自定义结果类 + public static class Result { + public String message; + public Long count; + public Result() { + } + public Result(String message, Long count) { + this.message = message; + this.count = count; + } + @Override + public String toString() { + return "Result{" + + "message='" + message + '\'' + + ", count=" + count + + '}'; + } + } +``` + +在上述示例中,我们使用 Kafka 作为数据源,并创建了一个消费者从名为 "input-topic" 的 Kafka 主题中读取数据。然后,我们将数据流注册为名为 "source_table" 的临时表。 + +接下来,我们使用 Flink SQL 执行 SQL 查询和转换。在这个例子中,我们查询 "source_table" 表,对 "message" 字段进行分组并计算每个消息出现的次数。查询结果会映射到自定义的 `Result` 类,并最终通过 `print()` 方法打印到标准输出。 + +最后,我们通过调用 `env.execute()` 方法来启动 Flink 作业的执行。 + +#### Flink SQL中使用窗口函数 + +Flink SQL中使用滚动窗口,滑动窗口和会话窗口代码示例如下: + +```java +public static void main(String[] args) throws Exception { + // 初始化流处理执行环境 + final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + final StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env); + + // 对于实际应用程序,请替换为你的数据源 + String sourceDDL = + "CREATE TABLE MySourceTable (\n" + + " user_id STRING,\n" + + " event_time TIMESTAMP(3),\n" + + " price DOUBLE\n" + + ") WITH (\n" + + "'connector' = '...',\n" + + "...);\n"; + + tableEnv.executeSql(sourceDDL); + + // 滚动窗口 + String tumblingWindowQuery = + "SELECT user_id, SUM(price) as total_price\n" + + "FROM MySourceTable\n" + + "GROUP BY user_id, TUMBLE(event_time, INTERVAL '1' HOUR)"; + + Table tumblingWindowResult = tableEnv.sqlQuery(tumblingWindowQuery); + + // 滑动窗口 + String slidingWindowQuery = + "SELECT user_id, SUM(price) as total_price\n" + + "FROM MySourceTable\n" + + "GROUP BY user_id, HOP(event_time, INTERVAL '30' MINUTE, INTERVAL '1' HOUR)"; + + Table slidingWindowResult = tableEnv.sqlQuery(slidingWindowQuery); + + // 会话窗口 + String sessionWindowQuery = + "SELECT user_id, SUM(price) as total_price\n" + + "FROM MySourceTable\n" + + "GROUP BY user_id, SESSION(event_time, INTERVAL '1' HOUR)"; + + Table sessionWindowResult = tableEnv.sqlQuery(sessionWindowQuery); + } +``` + +程序定义了三种不同类型的窗口查询:滚动窗口(tumbling window),滑动窗口(sliding window),会话窗口(session window)。 + +- 滚动窗口:该查询对"MySourceTable"中的数据应用滚动窗口,窗口大小为1小时,并按user_id进行分组。每个窗口内,会计算每个用户的总价格(sum(price))。 +- 滑动窗口:与滚动窗口相似, 但是窗口可以重叠. 这个查询每半小时滑动一次, 并且每次滑动都会创建一个1小时大小的窗口, 再进行与滚动窗口查询相同的计算. +- 会话窗口:会话窗口是根据数据活跃度来划分的,当一个会话内一段时间(这里设定为1小时)没有新的数据到达时,就认为会话结束。该查询按user_id和event_time的会话窗口进行分组,然后在每个窗口中计算总价格。 + +每个查询调用`tableEnv.sqlQuery(query)`方法,并将结果存储在Table对象中。注意这些查询在调用sqlQuery时并没有立即执行,只有当你对结果做出动作(如print、collect或者写入外部系统)时,才会触发执行。 + +## Flink内存优化 + +在大数据领域,大多数开源框架(Hadoop、Spark、Flink)都是基于JVM运行,但是JVM的内存管理机制往往存在着诸多类似`OutOfMemoryError`的问题,主要是因为创建过多的对象实例而超过JVM的最大堆内存限制,却没有被有效回收掉。 + +这在很大程度上影响了系统的稳定性,尤其对于大数据应用,面对大量的数据对象产生,仅仅靠JVM所提供的各种垃圾回收机制很难解决内存溢出的问题。 + +在开源框架中有很多框架都实现了自己的内存管理,例如Apache Spark的Tungsten项目,在一定程度上减轻了框架对JVM垃圾回收机制的依赖,从而更好地使用JVM来处理大规模数据集。 + +**Flink也基于JVM实现了自己的内存管理,将JVM根据内存区分为Unmanned Heap、Flink Managed Heap、Network Buffers三个区域** + +在Flink内部对Flink Managed Heap进行管理,在启动集群的过程中直接将堆内存初始化成Memory Pages Pool,也就是将内存全部以二进制数组的方式占用,形成虚拟内存使用空间。 + +新创建的对象都是以序列化成二进制数据的方式存储在内存页面池中,当完成计算后数据对象Flink就会将Page置空,而不是通过JVM进行垃圾回收,保证数据对象的创建永远不会超过JVM堆内存大小,也有效地避免了因为频繁GC导致的系统稳定性问题。 + +### JobManager配置 + +JobManager在Flink系统中主要承担管理集群资源、接收任务、调度Task、收集任务状态以及管理TaskManager的功能,JobManager本身并不直接参与数据的计算过程,因此JobManager的内存配置项不是特别多,只要指定JobManager堆内存大小即可。 + +- **jobmanager.heap.size**:设定JobManager堆内存大小,默认为1024MB。 + +### TaskManager配置 + +TaskManager作为Flink集群中的工作节点,所有任务的计算逻辑均执行在TaskManager之上,因此对TaskManager内存配置显得尤为重要,可以通过以下参数配置对TaskManager进行优化和调整。 + +- **taskmanager.heap.size**:设定TaskManager堆内存大小,默认值为1024M,如果在Yarn的集群中,TaskManager取决于Yarn分配给TaskManager Container的内存大小,且Yarn环境下一般会减掉一部分内存用于Container的容错。 + +- **taskmanager.jvm-exit-on-oom**:设定TaskManager是否会因为JVM发生内存溢出而停止,默认为false,当TaskManager发生内存溢出时,也不会导致TaskManager停止。 + +- **taskmanager.memory.size**:设定TaskManager内存大小,默认为0,如果不设定该值将会使用`taskmanager.memory.fraction`作为内存分配依据。 + +- **taskmanager.memory.fraction**:设定TaskManager堆中去除Network Buffers内存后的内存分配比例。该内存主要用于TaskManager任务排序、缓存中间结果等操作。例如,如果设定为0.8,则代表TaskManager保留80%内存用于中间结果数据的缓存,剩下20%的内存用于创建用户定义函数中的数据对象存储。注意,该参数只有在`taskmanager.memory.size`不设定的情况下才生效。 + +- **taskmanager.memory.off-heap**:设置是否开启堆外内存供Managed Memory或者Network Buffers使用。 + +- **taskmanager.memory.preallocate**:设置是否在启动TaskManager过程中直接分配TaskManager管理内存。 + +- **taskmanager.numberOfTaskSlots**:每个TaskManager分配的slot数量。 + +### Flink的网络缓存优化 + +Flink将JVM堆内存切分为三个部分,其中一部分为Network Buffers内存。Network Buffers内存是Flink数据交互层的关键内存资源,主要目的是缓存分布式数据处理过程中的输入数据。 + +通常情况下,比较大的Network Buffers意味着更高的吞吐量。如果系统出现“Insufficient number of network buffers”的错误,一般是因为Network Buffers配置过低导致,因此,在这种情况下需要适当调整TaskManager上Network Buffers的内存大小,以使得系统能够达到相对较高的吞吐量。 + +目前Flink能够调整Network Buffer内存大小的方式有两种:一种是通过直接指定Network Buffers内存数量的方式,另外一种是通过配置内存比例的方式。 + +#### 设定Network Buffer内存数量(过时) + +直接设定Nework Buffer数量需要通过如下公式计算得出: + +`NetworkBuffersNum = total-degree-of-parallelism \* intra-node-parallelism * n` + +其中`total-degree-of-parallelism`表示每个TaskManager的总并发数量,`intra-node-parallelism`表示每个TaskManager输入数据源的并发数量,n表示在预估计算过程中Repar-titioning或Broadcasting操作并行的数量。`intra-node-parallelism`通常情况下与Task-Manager的所占有的CPU数一致,且Repartitioning和Broadcating一般下不会超过4个并发。可以将计算公式转化如下: + +`NetworkBuffersNum = ^2 \* < TMs>* 4` + +其中slots-per-TM是每个TaskManager上分配的slots数量,TMs是TaskManager的总数量。对于一个含有20个TaskManager,每个TaskManager含有8个Slot的集群来说,总共需要的Network Buffer数量为8^2*204=5120个,因此集群中配置Network Buffer内存的大小约为160M较为合适。 + +计算完Network Buffer数量后,可以通过添加如下两个参数对Network Buffer内存进行配置。其中segment-size为每个Network Buffer的内存大小,默认为32KB,一般不需要修改,通过设定numberOfBuffers参数以达到计算出的内存大小要求。 + +- **taskmanager.network.numberOfBuffers**:指定Network堆栈Buffer内存块的数量。 + +- **taskmanager.memory.segment-size**:内存管理器和Network栈使用的内存Buffer大小,默认为32KB。 + +#### 设定Network Buffer内存比例(推荐) + +从1.3版本开始,Flink就提供了通过指定内存比例的方式设置Network Buffer内存大小。 + +- **taskmanager.network.memory.fraction**:JVM中用于Network Buffers的内存比例。 + +- **taskmanager.network.memory.min**:最小的Network Buffers内存大小,默认为64MB。 + +- **taskmanager.network.memory.max**:最大的Network Buffers内存大小,默认1GB。 + +- **taskmanager.memory.segment-size**:内存管理器和Network栈使用的Buffer大小,默认为32KB。 + +## 结语 + +感谢你耐心地读到这里,在我们结束这篇博客的同时,我鼓励你继续探索和实践Flink的无尽可能性。无论你是初学者还是专业人士,Flink都有许多值得挖掘的深度和广度。这就像一场数据处理的冒险,充满了挑战与机遇。无论你走到哪一步,都记得享受过程,因为每一个问题的解决都代表着新的认知和成长。 + +再次感谢你的阅读,希望这篇文章能够带给你收获以及深入的思考,期待你在Flink的学习旅程中取得更大的进步。 diff --git "a/docs/md/\346\236\266\346\236\204\350\256\276\350\256\241/12306\346\212\200\346\234\257\345\206\205\345\271\225.md" "b/docs/md/\346\236\266\346\236\204\350\256\276\350\256\241/12306\346\212\200\346\234\257\345\206\205\345\271\225.md" new file mode 100644 index 0000000..dac611b --- /dev/null +++ "b/docs/md/\346\236\266\346\236\204\350\256\276\350\256\241/12306\346\212\200\346\234\257\345\206\205\345\271\225.md" @@ -0,0 +1,342 @@ +> 对于未公开的技术部分,只能结合已公开的信息,去做大胆的猜想。 +> +> 本文提到的一些解决方案,并不一定是标准的实现,一些观点旨在引发大家的思考。 + +# 12306的成就 + +- 创下全球最大实时票务交易系统世界记录,春运一个月抵欧洲一年。 +- 最高可达百万并发,承受了这个世界上能秒杀任何系统的QPS。 +- 网站浏览量一天最高超1500亿次,峰值是双11的三倍。 + +# 12306系统特点 + +- 跟淘宝天猫等相比,业务简单(卖票)。 +- 流量极大。 +- 动态库存。 + +# 12306系统难点 + +目前 12306 最大的难点,在于库存扣减。 + +它跟传统的电商网站,可能最大的不同在于它的库存, 它的库存是动态变化的,库存之间会互相影响。 + +> 比如现在有一个组合品的需求,A品是由B品和C品通过不同的比例混合而成,用户下单的时候传过来的是A品这个 SKU,但是库存扣减的时候是把它的组合品的单品(B和C),都去扣一遍的。 + +我们平时各种商品 sku 库存的话,它是表里面的一行行记录。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6PtvPPOs3N4zGibtGC4GAZQicsknxicddDOuVTb0yexzHavEuic6zFZgrJg/640?wx_fmt=png&from=appmsg) + +某个行程是:杭州 -> 武汉 -> 成都。 + +杭州 -> 成都 ,武汉 -> 成都。这两个是一个车次。那么每卖出一张 武汉 -> 成都 的票,杭州 -> 成都 的票也会少一张。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz686hmTqd9ybBQXl20yoI5ShGqEXJTPvicrvrciblt1HdIbVw8KqV9su6Q/640?wx_fmt=png&from=appmsg) + +> 举个极端的例子,火车上就一个座位,车次是从 A -> B -> C。 +> +> 如果卖出 B -> C 的车票,不去扣减 A-> C 库存的话,那么假设有两个用户分别买了 A-> C 和 B -> C 。那么当车行至站点B的时候,车上会有两个人,但是座位就一个。 +> +> 所以不同车次之间的库存是会互相影响的。 + +A -> B -> C -> D 共 4 个车站,假如乘客买了 B -> C 的车票,那么同时会影响到 A->C,A->D,B->C,B->D。 + +这里不会影响 A -> B 的行程,因为乘客买的是 B -> C 的车票,站点 B 才上车,不占用 A -> B 行程的座位,我下车你上车,不冲突。 + +**计算耗费性能** + +一些长途,中间会经过十几个站点,而且有些城市是没有直达的车次的,中间只能换乘。涉及了多个车站的排列组合,这里计算是比较耗费性能的。 + +**行锁竞争会非常激烈** + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6piaEkr51PTVJ4h4JSLcv1n8zLSoUibaRXptEpIEntfmCuT0HbHB4XnFw/640?wx_fmt=png&from=appmsg) + +购买一个行程会涉及多个站点的扣减库存,有可能这些多个站点的库存扣减是放在一个事务中的,如果是在一个事务中,那么一次下单行为,可能要涉及到几十次库存扣减。 + +锁范围膨胀,事务就会被拉大,线程数可能迅速被占满,导致数据库可能成为性能瓶颈,并且接口性能也会有所下降。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6XIHkgODNAzXvLhX8YxzSicvxzGfwHd8icWY7iaQ3TObRNibmXpV4niaCxRw/640?wx_fmt=png&from=appmsg) + +**热点问题** + +火车站不同的站之间都是一个具体库存,中间的库存扣了之后,那么远的这个站的库存也要扣减。 + +极端情况下,一些热门城市,中间一些站点可能比较火爆,那两端的人是不是永远买不到票。 + +所以说像 12306 这种库存,其实涉及到非常多的一个行锁竞争,而且事务是非常大的。一列火车之间的库存其实是互相影响的,动态变化的。 + +另外还会涉及到其他维度,比如 硬座、硬卧、软卧、无座 这种业务逻辑在里面。 + +业务逻辑加上库存之间相互影响,就导致库存扣减逻辑异常复杂。 + +# 解决思路 + +## 产品角度 + +- 早期的 12306 是通过整点去抢票,整点就会产生非常高的流量峰值,对系统造成非常大的压力,后面采取了分时段售票,比如今天开抢 15 天之后的车票,将抢票的压力按照时间区间分散开,大大减低了峰值。 +- 候补车票。 + +在2019年5月份,12306 新增了“候补购票”功能,在“候补购票”功能没出来之前,放票时间一到,千万人同时刷新抢票,这便是春运火车票秒光的原因。 + +候补车票堪称抢了一票第三方软件的“饭碗”。 + +**“黄牛”的秘密武器是外挂,用最快的服务器不断地刷新和监控12306,刷票速度往往是正常购票的几十倍。** + +实际上,市面上通行的“抢票软件”,原理与“黄牛”并无本质区别。 + +以前,合肥到上海的车票卖光了,有人退票或改签,车票会回到票池可供购买。抢票软件实时刷新监控,第一时间购买,这便是抢票软件比人快的原因。 + +现在,有了候补车票,合肥到上海的车票卖光了,**乘客可以候补登记,有人退票或改签,车票按顺序优先卖给候补登记的人,而不是回到票池公开出售。这便是抢票软件无效的原因。** + +现在很多抢票软件反而没有“候补购票”功能抢票来得快。 + +候补车票在整个系统上相当于是一个异步过程。先排队,后面抢没抢到票再通知你。只要异步了,就可以通过消息,或者定时任务慢慢去消费,大大降低系统的压力。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6uBTQEQl6SQwsfReeeIsIewQGBmRPuoYDLAIqjs6PNDBXYb0xNZkJIg/640?wx_fmt=png&from=appmsg) + +12306 是有非常多的灰色流量的,像是一些抢票软件或者脚本。因为这里面涉及到的利益非常巨大,滋生了很多灰色流量,给12306本身带来了很多额外的压力。 + +“候补购票”功能可以降低黄牛刷票行为,拦截部分灰色流量。 + +- 验证码机制。BT的验证码机制,可以过滤非常大的灰色流量。 + +12306 的验证码,是所有验证码中的一股绝对的“清流”。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6Kyb0swV9ExSnPREEy1Wibe6qPo52TkkbDHsiaXcAoJ6MloIxlDaErlRg/640?wx_fmt=png&from=appmsg) + +2013 年起,铁道部为了应对黄牛抢票,以及各类抢票软件和插件,升级了购票验证码系统。 + +在12306官方网站上,从购票到付款,都需要输入验证码。从最开始的字母数字验证码,再到后来升级后的图形验证码,成为了一道“难过”的关卡。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6AMIu8xgYq4xY52gYjVhD1iawc914KxEf3kMLcXIuvUaicu8HmvBCYqbw/640?wx_fmt=png&from=appmsg) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6I8jicW0el8hLwbHKY1wdVYRmEusWGNRtBZwpDJsoTOG2S2urESjZEdQ/640?wx_fmt=png&from=appmsg) + +各类奇葩验证码出现在了 12306 上。 + +到了2015年底,根据有关网站统计,12306上的图形验证码多达接近600种。再经过排列组合,总共有多达300000种。**一次性输入准确的比例仅仅是8%,两次输入准确比例27%,三次以上输入准确的比例才勉强超过60%,如果一次性输入成功的平均用时为5秒的话,按照热门车票“秒光”的情况计算,每输错一次验证码,就意味着当次购票成功率下降80%左右。** + +直到 2018 年,各类奇葩验证码才陆续开始“下岗”。 + +上面的候补抢票和验证码机制,主要是为了对抗黄牛。 + +道高一尺,魔高一丈。黄牛也在不断进步。 + +> 推荐阅读: [为什么车票刚出就没了?揭秘AI神器抢票内幕](https://m.jiemian.com/article/1890085.html) [进化的12306与杀不死的“黄牛”](https://m.huxiu.com/article/335461.html?type=text) + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6DcViavTDF5kvKibQyf3VyRPHRsDXczdGoHBfRibdsFA2SQFqk6KlBmmvg/640?wx_fmt=png&from=appmsg) + +比如我是一个搞黑灰产的人,我可以雇佣一批大学生,去做图形验证码识别。简单验证码还是机器去执行。 + +那这样的话其实还是没办法防止,但是这样增加了灰产的成本,毕竟人工比机器成本高,从一定程度上还是能够降低灰产的流量占比。 + +12306 之所以能够使用如此变态的验证码机制的大前提是:**没有把用户体验放在首位**。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6hX6fVRlvCHDUrROyPA3t54BWaRAFhia9XwyJLAyS3WibYGDCWeqDRpOw/640?wx_fmt=png&from=appmsg) + +**12306 比较特殊,市面上几乎不存在竞争对手,而且火车票对于逢年过节,旅行返乡的人来说,几乎可以等同于必需品。** + +**在这种情况下,12306 不必将用户体验放在首位,对于 12306 来说是可以牺牲部分用户体验来换取系统稳定性。** + +- 12306 是读多写少的情况,查询流量占大头。 + +天量的火车票查询是影响 12306 性能的重要原因之一,大概占了90%以上的访问流量。更棘手的是:峰谷的查询有天壤之别,平时工作日跟这种节假日,春运。流量相差是巨大的,时间区间是非常明显的。 + +如果说完全用机器去堆,可能就会造成一个资源浪费。 + +还有至关重要一点是,假如完全用机器去堆,在实际业务峰值超出了初始评估量时,服务将面临无法完全承载而瘫痪,**因为大规模服务器的采购、交付、部署到应用上线所耗费时间以月计,根本无法在业务量激增时"即插即用"**。 + +几乎没有办法在成本和并发能力之间做一个好的平衡。以往的一个做法是从几个关键入口流量控制,保障系统可用性,但是会影响用户体验。 + +淘宝/天猫大促的时候,也会增加服务器,但阿里的业务盘子大,这些新增的机器很快会被其他业务(包括阿里云)消化掉,可能还不够。但是对于 12306来说,就比较难做到这一点。 + +在 2015 年的时候,12306 跟阿里云达成合作,通过云的弹性和“按量付费”的计量方式,来支持巨量的查询业务,把架构中比较“重”(高消耗、低周转)的部分放在云上,将75%的余票查询业务切换到了阿里云上。 + +**将余票查询模块和12306现有系统做分离,在云上独立部署一套余票查询系统。** + +通过动态的云计算,在高峰时段动态去扩容,可以达到分钟级的扩容,这样就避免在平时浪费大量的机器。 + +合作后,提高了网站的负载能力。2019年的春运,12306挺过了流量的高峰 297 亿次的日访问量。 + +> 推荐阅读: [秘密合作半年 阿里同12306关系被曝光](https://developer.aliyun.com/article/219784) + +## 技术角度 + +历史背景:12306 是在 2010 年左右上线的,11年到12年之间,基本上一到节假日系统就崩溃。当时也是被喷的不行。12年之后在 7、8月份进行了大规模的重构,到了13年的春节,整个系统就比较稳定了,基本没有 down 机的情况发生。 + +面对 12306 这种读多写少的场景,可能我们平时会采用 Redis 这种缓存机制,12306 具体选择的解决方案,可能跟我们常见的解决方案有一些不一样。 + +12306 通过充分调研,并没有选择 Redis,而是选择了名叫 **Pivotal GemFire** 的产品。 + +> 没听过 Pivotal ,学 Java 的肯定都听过 Spring。 +> +> Spring 框架它归属于 Spring 团队。没错框架名和团队名是一样的,这个团队归属于 Pivotal 公司。 + +很多银行、投行,实时交易方面的系统都采用 Pivotal GemFire 作为解决方案。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6FibRlViaTZkpnrqbQNqInhj2w83An40ETuickmgQKYNH8iaQD9z7YGoV6g/640?wx_fmt=png&from=appmsg) + +GemFire 基于开源项目 Geode 进行研发的。Redis 是在 2010 年左右才发行的第一个版本,Geode 是更早的一个开源项目。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6XhZxAODXnK1C06f4Qw6pgCmzFrULLp1hcYAtj975AUpo6SEYbnT2ow/640?wx_fmt=png&from=appmsg) + +GemFire 本身是 Geode 的商用版本,可以理解为收费的 Redis(Redis的作者现在就在 GemFire 打工)。就像 Oracle 和 MySQL。 + +**为什么选择 Pivotal GemFire 而不是 Redis?** + +> https://redis.io/comparisons/redis-vs-gemfire/ + +Redis 是开源的缓存解决方案,而 GemFire 是商用的,我们在互联网项目中为什么使用 Redis 比较多呢,很大原因就是因为 Redis 是开源的,不要钱。 + +开源对应的也就是稳定性不是那么的强,并且开源社区也不会给你提供解决方案,毕竟你是白嫖的。 + +而在银行以及 12306 这些系统中,它们对可靠性要求非常的高,因此会选择商用的 GemFire,不仅性能强、高可用,而且 GemFire 还会提供一系列的解决方案。 + +12306 本身不缺钱,在资金预算充足的情况下,追求系统的稳定性和交易的绝对可靠,追求的是最好的解决方案。 + +而 GemFire 类似 Oracle 是一套完整的解决方案,不只是给你一套工具,让你私有化部署就不管了,而是需要后面持续去维护的。 + +> 据说 GemFire 同时做到了分布式系统里的CAP,违背了架构的一个常识。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz64pSAj3qbybibd9cz5rCKJwyCcSELx5VricXHfQPbVzDibYKfj48AKaPYQ/640?wx_fmt=png&from=appmsg) + +当时 12306 也尝试了许多其他的解决方案,都扛不住查询的流量,而使用 GemFire 之后扛住了流量,因此就使用了 GemFire。 + +Redis 主要用作缓存存储,而当时 (12年左右) 12306 最大的瓶颈主要是在 IO 上面。 + +为什么 12306 最大的瓶颈会在 IO 上面呢?这跟 12306 使用读扩散有关**。** + +读扩散和写扩散常见于 订阅/聊天/群聊 系统。 + +> 推荐阅读:[读扩散与写扩散分析](https://blog.csdn.net/u014630623/article/details/106276566/) +> +> 读扩散,一般指牺牲了读的性能,去提升写的性能。 +> +> 写扩散,一般指牺牲了写的性能,去提升读的性能。 + +对应 12306 来说: + +- 读扩散:扣减只需要关注列车站点之间的扣减,关注车次,查询的时候再去动态计算。 + +优点:扣减简单;缺点:计算余票复杂。 + +- 写扩散:扣减直接把各个站点之间票都扣减,关注站点。 + +优点:扣减复杂;缺点:查询余票简单。 + +> 个人猜测 12306 使用读扩散的原因是对数据实时性有要求,当扩散队列很长的时候,写入时间存在延时,可能导致不同行程的余票对不齐。 +> +> 而且涉及排列组合过多,使用写扩散,数据冗余会比较严重,浪费存储成本。 + +计算余票是一个数据密集型的运算,要关联很多的数据进行计算。一方面需要数据,一方面又要频繁的计算。计算跟数据本身是分开的(计算在CPU,数据存储在内存)。 + +GemFire 的定位是**实时存储网格**。 + +一般分布式缓存,比如Redis,查询数据就算再快,还是要从缓存里取出数据,再CPU进行计算。 + +GemFire 最大的特点是将存储和计算放在了一起,它的存储和实时计算的性能目前还没有其他中间件可以取代。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz6icp9kbWlzaic5h3FkiaN168NouSKPicXoqwh3Wq4YFnbApUibZko3iaaUcuw/640?wx_fmt=png&from=appmsg) + +扣减库存数据库选用的是 Sybase(收费,关系型数据库),相比查询的流量,扣减库存的流量是完全可以承载的。 + +> db-engines.com 这个网站可以对比主流数据库之间的差异 + +扣减库存之后再同步至 GemFire,然后在 GemFire 里进行动态计算,整个 GemFire 承载的是查询的流量。引入 GemFire 之后,整个系统的查询扩散瓶颈基本上就解决了。 + +GemFire 将很多机器内存汇总成一个大的节点,作为整体去管理,尽量保证业务运算和业务数据是在同一个节点,尽量避免多节点的网络通信。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN87QFxNZiazJesNsrdc4rz69jCTSnvjo2rSRZuaE7Y2rib4yTTVopbm0s2ibzB0qLzcIS8jGsnibkWEA/640?wx_fmt=png&from=appmsg) + +但是 GemFire 也存在不足的地方,对于扩容的支持不太友好。在 12306 中,也有过测试,需要几十个T的内存就可以将业务数据全部放到内存中来,因此直接将内存给加够,也就不需要很频繁的扩容。 + +### 余票库存的表如何设计? + +这里的设计思路都是猜测的,并不一定是 12306 真实的设计方案。 + +**12306 余票库存的表的设计是非常特色并且重要的** + +首先说一下需要哪几个表来表示余票的库存信息: + +1、基础的车次表:表示车次的编号以及发车时间等具体的车次信息,属于比较稳定的数据。 + +2、车的座位表:表示每个座位的具体信息,包括在几车厢、几行、几列,以及 `该座位的售卖情况`。 + +3、车的余票表:通过座位表可以计算出每个车位在各个车站区间还有多少余票,但是动态计算比较浪费性能,因此再添加余票表,通过定时计算余票信息放入到余票表中,提高查询的性能。 + +(其实还应该有站点表和车厢表,不过不太重要,这里直接就省略了) + +**这里说一下这 3 个表的对应关系:** + +比如车次为 K123,该车上有很多的座位,每个座位对应座位表中的一条数据。 + +而余票表指的是 K123 车次上,硬座、硬卧、软卧、无座各有多少张余票,余票表的信息可以由座位表来计算得到。 + +**接下来说一下如何通过座位表来表示用户购买的车票:** + +12306 中的车票信息其实是比较复杂的,因为各个车站之间是有依赖关系的,比如 4 个车站 A->B->C->D + +如果乘客购买 B->C 的车票的话,不仅 B->C 的库存要减一,B->D 的库存也要减一,这是排列组合的情况,**可以考虑通过二进制去简化车票的表示。** + +在座位表中,我们设置一个字段 `sell varchar(50)` 表示该座位的售卖情况,如果该车次有 4 个站 A->B->C->D,那么 sell 字段的长度就为 3,sell 字段的第一位表示该座位 A->B 的票是否已经被买了,第二位表示 B->C 的票是否已经被买了... + +如果乘客购买 B->C 的车票,则 sell 字段的值为:`010`。 + +如果乘客购买 B->D 的车票,此时发现该座位在 B->C 已经被卖出去了,因此不能将该座位出售给这位乘客。 + +如果乘客购买 C->D 的车票,则 sell 字段的值为:`011` ,表示 B->C,C->D 都已经有人了。 + +通过座位表来计算出余票,得到余票表。 + +**通过余票表提升查询性能** + +这里余票表就相当于是数据库中的视图。 + +如果要去查询一个车次中某一个类型的余票还有多少,还需要去对座位表进行计算,这个消耗是比较大的 ,因此通过余票表来加快对于余票的查询。 + +可以定时去计算座位表中的数据,将每种类型的座位的余票给统计出来,比如: + +硬卧:xx张 + +硬座:xx张 + +软卧:xx张 + +... + +再将余票表的信息给放入到缓存中,大大提高查询的性能。 + +我们在使用 12306 的时候,也会发现,有时候显示的有票,但是真正去买的时候发现已经没有余票了,**这就说明 12306 没有保证实时的一致性,只要保证了最终一致性即可,也就是用户真正去买的时候,保证对于余票数量的查询是准确的就可以了。** + +> 个人推测 12306 使用的是缓存+动态计算结合的方式,查询的时候使用的是缓存(最终一致性),等到真正下单的时候会再去动态计算一遍(实时一致性)。这样利用缓存就能隔绝掉很大的查询的流量,并且也能保证最终下单的准确性。 + +对于电商,如果商品销售小份额的超出库存,部分场景下可以通过补充库存进行弥补,仅要求弱一致性。而 12306 属于实时的交易型系统,库存资源的数量固定,对分布式事务要求强一致性。 + +**中间的站点如果太过火爆,导致两边的站点买不到票怎么办?** + +比如 A->B->C->D,对于一个车次中的座位来说,如果 B->C 的乘客非常多,那么是不是就会导致 A->D 买不到票了? + +这个是通过运营部来进行设计,首先考虑的肯定是要盈利,远途票价比较贵,因此比较倾向于远途的旅客,营业部根据具体的实际情况以及盈利情况来定一下各个区间预留多少票,给每个车站区间都留有一些余票,那么就不会因为某一个区间非常火爆,而导致其他乘客买不到长途的票了。 + +# 抢票软件推荐 + +https://www.bypass.cn/(亲测好用) + +# 巨人的肩膀 + +- [【高级进阶】12306未公开技术细节!技术专家必须理解的设计思想!](https://www.bilibili.com/video/BV16i4y1R78A/?spm_id_from=333.999.0.0&vd_source=a34520cd2d48f152421cf9a7a6983a81) +- [【12306完结】铁道部技术内幕!为什么是Gemfire?如何强一致?预留票机制?](https://www.bilibili.com/video/BV1DT4y117Vu/?spm_id_from=333.999.0.0&vd_source=a34520cd2d48f152421cf9a7a6983a81) +- [读扩散与写扩散分析](https://blog.csdn.net/u014630623/article/details/106276566/) +- [数据存储结构设计,是读扩散,还是写扩散?](https://www.jianshu.com/p/bd660e25e859) +- [由12306.CN谈谈网站性能技术](https://coolshell.cn/articles/6470.html) +- [12306 架构设计难点](https://mp.weixin.qq.com/s/t3MIU1WvAwpsxJYYfHAlCA) +- [12306的十年往事](https://www.huxiu.com/article/332685.html) +- [为什么车票刚出就没了?揭秘AI神器抢票内幕](https://m.jiemian.com/article/1890085.html) +- [进化的12306与杀不死的“黄牛”](https://m.huxiu.com/article/335461.html?type=text) +- [12306订票系统技术内幕 源码](https://blog.51cto.com/laoye/1032170) +- [架构必看:12306抢票亿级流量架构演进(图解+秒懂+史上最全)](https://www.cnblogs.com/crazymakercircle/p/15058702.html) +- [秘密合作半年 阿里同12306关系被曝光](https://developer.aliyun.com/article/219784) +- [抢了个票,还以为发现了12306的系统BUG](https://www.cnblogs.com/crazymakercircle/p/15058702.html) +- [12306 技术分析](https://bot-man-jl.github.io/articles/?post=2016/12306-Architecture) +- [从嗤之以鼻到“奇迹” 前淘宝工程师详解12306技术](https://cloud.tencent.com/developer/article/1419515) +- [Gemfire:分布式缓存利器](https://cloud.tencent.com/developer/article/1797916) +- [REDIS VS GEMFIRE](https://redis.io/comparisons/redis-vs-gemfire/) diff --git "a/docs/md/\346\236\266\346\236\204\350\256\276\350\256\241/\344\270\232\345\212\241\345\271\202\347\255\211\346\200\247\350\256\276\350\256\241\347\232\204\345\205\255\347\247\215\346\226\271\346\241\210.md" "b/docs/md/\346\236\266\346\236\204\350\256\276\350\256\241/\344\270\232\345\212\241\345\271\202\347\255\211\346\200\247\350\256\276\350\256\241\347\232\204\345\205\255\347\247\215\346\226\271\346\241\210.md" new file mode 100644 index 0000000..d9bcf62 --- /dev/null +++ "b/docs/md/\346\236\266\346\236\204\350\256\276\350\256\241/\344\270\232\345\212\241\345\271\202\347\255\211\346\200\247\350\256\276\350\256\241\347\232\204\345\205\255\347\247\215\346\226\271\346\241\210.md" @@ -0,0 +1,137 @@ +**导读**:现如今很多系统都会基于分布式或微服务思想完成对系统的架构设计。那么在这一个系统中,就会存在若干个微服务,而且服务间也会产生相互通信调用。 + +那么既然产生了服务调用,就必然会存在服务调用延迟或失败的问题。当出现这种问题,服务端会进行重试等操作或客户端有可能会进行多次点击提交。在存在重复请求的场景中(如支付交易),为确保系统最终处理结果的一致性并避免资损风险,必须通过业务幂等性设计保障数据操作的唯一性。 + +# 什么叫幂等 + +**幂等(Idempotence)** 是计算机科学和分布式系统中的核心概念,指在特定上下文中,**对同一操作进行多次执行所产生的影响,与仅执行一次该操作的影响完全相同**。无论该操作被调用一次还是多次,系统的最终状态始终保持一致,资源状态或业务结果不会因为重复调用而发生额外改变。 + +幂等用数学语言表达就是:**f(f(x))=f(x)** + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMAVurDibRjKEiaaLFuWkiaLKTXDopDs7Mibs82WTxkndPYTu2eYBfkTonoicWEXBDtRfKicrOpn4BgickpQ/640?wx_fmt=png&from=appmsg) + +在分布式系统和网络通信中,幂等性尤为重要,尤其是转账、支付等涉及金额交易的场景,如果出现幂等性的问题,造成的后果是非常严重的。 + +**事故:转账无幂等、交易无幂等、发优惠券无幂等,都会造成不小的事故**。 + +幂等性设计主要从两个维度进行考虑:**空间、时间**。 + +- 空间:定义了幂等的范围,如生成订单的话,不允许出现重复下单。 +- 时间:定义幂等的有效期。有些业务需要永久性保证幂等,如下单、支付等。而部分业务只要保证一段时间幂等即可。 + +# 业务问题抛出 + +在业务开发与分布式系统设计中,有非常多的场景需要考虑幂等性的问题,如: + +- 当用户购物进行下单操作,用户操作多次,但订单系统对于本次操作只能产生一个订单。 +- 当用户对订单进行付款,支付系统不管出现什么问题,应该只对用户扣一次款。 +- 当支付成功对库存扣减时,库存系统对订单中商品的库存数量也只能扣减一次。 +- 当对商品进行发货时,也需保证物流系统有且只能发一次货。 + +但是一旦考虑幂等后,服务逻辑务必会变的更加复杂。因此是否要考虑幂等,需要根据具体业务场景具体分析。 + +此处以下单减库存为例,当用户生成订单成功后,会对订单中商品进行扣减库存。 订单服务会调用库存服务进行库存扣减。库存服务会完成具体扣减实现: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMAVurDibRjKEiaaLFuWkiaLKTicibkFNxmMMzILbuZAvzFJldrWXgzTUJTJJZmYsXXPqfwatXs7biaDbdA/640?wx_fmt=png&from=appmsg) + +如果出现调用超时,如网络抖动,虽然库存服务执行成功了,但结果并没有在指定时间内返回,则订单服务会进行重试。那就会出现问题,此时出现库存扣减两次的问题。 对于这种问题,就需要考虑幂等性设计。 + +# 幂等设计实现 + +## 方案一:数据库唯一索引 + +在保存数据前,可以先 select 一下数据是否存在。如果数据已存在,说明是重复数据,则不再写入数据,如果数据不存在,则执行 insert 操作。如果 insert 成功,则直接返回成功,如果 insert 产生主键冲突异常,则捕获异常进行处理。 + +但在高并发的场景下,可能会出现两个请求 select 的时候,都没有查到数据,然后都执行了 insert 操作,所以此时会有重复数据产生,因此在数据库中,我们需要添加唯一索引来保证幂等,**唯一索引是不会引起重复数据的兜底策略**。 + +## 方案二:防重表机制 + +防重表机制与唯一索引机制是相同的原理,只不过是**单独建一个防重表,防重表也必须引入唯一索引,而且防重表与业务表必须在同一数据库,并且操作要在同一个事务中。** + +防重表机制的主要流程:把唯一主键插入防重表,再进行业务操作,且它们处于同一个事务中。当重复请求时,因为防重表有唯一约束,导致请求失败,可以避免幂等问题。 + +**注意防重表和业务表应该在同一个库中,这样就保证处在一个事务中,即使业务操作失败,也会把防重表的数据回滚。保证了数据的一致性。** + +该方案也是比较常用的,防重表跟业务无关,很多业务可以共用同一个防重表,只要规划好唯一主键即可。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMAVurDibRjKEiaaLFuWkiaLKTiaiaXKnlGk1pqUspibpicWCPqicKNRvwjuDq3a0gd5qJ5n0rcNbH4MReWXA/640?wx_fmt=png&from=appmsg) + +## 方案三:数据库乐观锁 + +乐观锁实现的方式有两种:基于版本号、基于条件。但是实现思想都是基于行锁来实现的。 + +### 基于版本号实现 + +通过为表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本号与对应记录的当前版本号进行比对,**如果提交的版本号等于当前版本号,则予以更新,否则认为是过期数据。** + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMAVurDibRjKEiaaLFuWkiaLKTFnCBGibumwV5JV4cEzTQATHeIlqU8NxaA6crYOu2zgWK5eU8v5pzy4Q/640?wx_fmt=png&from=appmsg) + +### 基于条件实现 + +版本号控制在并发场景中虽然能保证数据一致性,但在高并发库存扣减的场景下存在体验问题:当多个用户同时查询到可售库存后,只有基于版本号的最新请求能扣减成功,这会导致一些用户看似有库存却最终下单失败。 + +从业务角度而言,只要确保库存实际不发生超卖即可,此时更推荐直接通过数据库条件控制: + +```SQL +update tb_stock set amount=amount-#{num} +where goods_id=#{goodsId} and amount-#{num}>=0" +``` + +总结:在竞争不激烈,出现并发冲突几率较小时,推荐使用乐观锁。但是,乐观锁的每次冲突检测都需要与数据库交互,频繁的更新操作仍会对数据库产生一定压力。此外,在高并发场景下,大量事务竞争可能导致数据库连接池耗尽或成为性能瓶颈。 + +## 方案四:悲观锁 + +悲观锁的实现,往往依靠数据库提供的锁机制,具有强烈的独占和排他性。 + +通过 for update 可以实现排它锁; + +```SQL +select * from account where id = 123 for update; +``` + +悲观锁在同一事务操作过程中,锁住了一行数据。别的请求过来只能等待,如果当前事务耗时比较长,就很影响接口性能。所以一般不建议用悲观锁做这个事情。 + +## 方案五:防重 Token 令牌 + +采用 Token 机制确保幂等性是一种广泛应用的解决方案,能够覆盖绝大多数业务场景。该方案通过前后端协作实现。此方案包含两个请求阶段: + +1. 客户端请求服务端申请获取 token。 +2. 客户端携带 token 再次请求,服务端校验 token 后进行操作。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMAVurDibRjKEiaaLFuWkiaLKTa7LtgpbTLxBTD9gNUuXQTJjtgev7xesWl88QRdzbPcZiaQkvmRd47Iw/640?wx_fmt=png&from=appmsg) + +整体流程如下: + +1. 服务端提供获取 token 接口,供客户端进行使用。服务端生成 token 后,如果当前为分布式架构,将 token 存放于 redis 中(一般会设置一个过期时间),如果是单体架构,可以保存在本地缓存。 +2. 当客户端获取到 token 后,会携带着 token 发起请求。 +3. 服务端接收到客户端请求后,首先会判断该 token 在 redis 中是否存在。如果存在,则完成进行业务处理,业务处理完成后,再删除 token。如果不存在,代表当前请求是重复请求,直接向客户端返回对应标识。 + +### 存在问题 + +但是现在有一个问题,**当前是先执行业务再删除 token**。在高并发下,很有可能出现第一次访问时 token 存在,完成具体业务操作。但在还没有删除 token 时,客户端又携带 token发起请求,此时,因为 token 还存在,第二次请求也会验证通过,执行具体业务操作。 + +针对该问题,我们提出两种解决方案进行探讨: + +第一种方案:对于业务代码执行和删除 token 整体加线程锁。 当后续线程再来访问时,则阻塞排队。 + +第二种方案:借助 redis 单线程和 incr 是原子性的特点。当第一次获取 token 时,以 token 作为 key,对其进行自增。然后将 token 进行返回,当客户端携带 token 访问执行业务代码时,对于判断 token 是否存在不用删除,而是对其继续 incr。 如果 incr 后的返回值为 2。则是一个合法请求允许执行,如果是其他值,则代表是非法请求,直接返回。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMAVurDibRjKEiaaLFuWkiaLKTw3iaDNd32A9m9KewiawGtwgby6sVQbbOUIXp6cvoUPhH57jvSticJrU5A/640?wx_fmt=png&from=appmsg) + +前面提到的都是先执行业务再删除 token,那如果先删除 token 再执行业务呢?其实也会存在问题,假设具体业务代码执行超时或失败,没有向客户端返回明确结果,那客户端就很有可能会进行重试,但此时之前的 token 已经被删除了,**则会被认为是重复请求,不再进行业务处理**。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMAVurDibRjKEiaaLFuWkiaLKTEJKhnWTQtc4WVtTibu3AbgWpceqTznciaZ5q6E2Clowjv7lKSiaMC1Tdg/640?wx_fmt=png&from=appmsg) + +这种方案无需进行额外处理,一个 token 只能代表一次请求。 一旦业务执行出现异常,则让客户端重新获取令牌,重新发起一次访问即可。**推荐使用先删除 token 方案**。 + +但是无论先删 token 还是后删 token,都会有一个相同的问题。每次业务请求都会产生一个额外的请求去获 token。但是,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个请求,让其他九千九百多个请求都产生额外请求,就有一些得不偿失了。虽然 redis 性能好,但是这也是一种资源的浪费。 + +## 方案六:分布式锁 + +分布式锁实现幂等性的逻辑就是,请求过来时,先去尝试获得分布式锁,如果获得成功,就执行业务逻辑,反之获取失败的话,就舍弃请求直接返回成功。 + +分布式锁可以使用 Redis,也可以使用 ZooKeeper,Redis 相对来说会更加轻量级。 + +Redis 分布式锁,可以使用命令`SETNX + 唯一流水号` 实现,分布式锁的 key 必须为业务的唯一标识。 + +Redis 执行设置 key 的动作时,要设置过期时间,这个过期时间不能太短,太短拦截不了重复请求,也不能设置太长,会占存储空间。 diff --git "a/docs/md/\346\241\206\346\236\266/\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\344\270\215\345\217\252\346\234\211\346\246\202\345\277\265\357\274\201\344\270\207\345\255\227\351\225\277\346\226\207 + \344\273\243\347\240\201\347\244\272\344\276\213\357\274\214\346\211\213\346\212\212\346\211\213\345\270\246\344\275\240\347\216\251\350\275\254 RxJava.md" "b/docs/md/\346\241\206\346\236\266/\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\344\270\215\345\217\252\346\234\211\346\246\202\345\277\265\357\274\201\344\270\207\345\255\227\351\225\277\346\226\207 + \344\273\243\347\240\201\347\244\272\344\276\213\357\274\214\346\211\213\346\212\212\346\211\213\345\270\246\344\275\240\347\216\251\350\275\254 RxJava.md" new file mode 100644 index 0000000..465bc2f --- /dev/null +++ "b/docs/md/\346\241\206\346\236\266/\345\223\215\345\272\224\345\274\217\347\274\226\347\250\213\344\270\215\345\217\252\346\234\211\346\246\202\345\277\265\357\274\201\344\270\207\345\255\227\351\225\277\346\226\207 + \344\273\243\347\240\201\347\244\272\344\276\213\357\274\214\346\211\213\346\212\212\346\211\213\345\270\246\344\275\240\347\216\251\350\275\254 RxJava.md" @@ -0,0 +1,1707 @@ +# Reactive Streams 介绍 + +在聊 Reactive Streams 之前,先了解一下 Reactive Programming(反应式/响应式编程)。为了解决异步编程中出现的各种问题,程序员们提出了各种的思路去解决这些问题,这些解决问题的方式、方法,手段就可以叫做 Reactive Programming。 + +Reactive Programming 是一种编程思想,类似面向对象,函数式编程。 + +本质上是对数据流或某种变化做出的反应,这个变化什么时候触发是未知的,所以他是一种基于异步、回调的方式在处理问题。 + +当越来越多的程序员,开始使用这种编程思想时,需要一些大佬来统一一个思想规范。所以国外的几个大佬公司启动了 Reactive Streams 项目。Netflix、Pivotal、Lightbend 联合来为异步流处理提供标准,规范。 + +Reactive Streams 翻译过来就是响应式/反应式流。**其实是一种基于异步流处理的标准化规范,目的是在使用流处理时更加可靠,高效和响应式。** + +# Java 层面的 Reactive Streams + +基于这个规范的实现很多,比如三方库中比较出名的 RxJava,Reactor 等等。 + +但是 JDK8 版本中,Java 已经有了 CompletableFuture 的支撑,我们可以将大量的异步任务做好编排。但是在 JDK8 版本中的 CompletableFuture 依然有很多特性无法支撑。所以在 JDK9,CompletableFuture 做了很多的更新,比如支持延迟,超时,子类化之类的功能。 + +这时,咱们会发现,其实 CompletableFuture 已经可以去支撑做一些异步编程的操作了。但是为什么很多大公司依然还是使用 RxJava,Reactor 这种三方依赖库呢? + +问题在于,大多数的时候,咱们采用异步编程处理的任务并不是非常复杂的。这个时候,咱们确实不需要去使用 Reactive Streams 反应流的框架。如果系统越来越复杂,或者你处理的业务本身就是及其复杂的那种,你就要去写一个让人头皮发麻的代码了。随着时间的推移,这种代码会变成非常难以维护。 + +其次 CompletableFuture 并不是真正的基于 Reactive Streams 去实现。CompletableFuture 描述的是单次执行的结果。尽管可以通过各种方法将异步任务之间构建成一串任务组成的流程图,本质上依然是单次的结果。 + +反应式流,面向的是 Stream。 咱们 Java 中的 Stream API 更类似 Reactive Streams 的思想。Stream API 是同步阻塞的。 + +最经典的就是 CompletableFuture 无法处理 Reactive Streams 中的一个核心概念,Back Pressure(背压,反压,回压),比如在上下游承载能力不同时,比如下游玩不转了,需要告知上游采取一些策略去解决。CompletableFuture 明显无法处理这种。 + +其次还有 Java 中提供的回调,Future 机制在实现响应式编程中,问题和缺点都比较难处理。有个比较出名的概念叫做 Callback Hell(回调地狱)。简单来说就是回调里面套回调,虽然将子过程做到解耦,但是随着业务的负责,回调代码的可读性、复杂性就大大的增加,这个就是回调地狱。 + +所以,咱们需要一套框架或者说类库来实现真正响应式流,大概需要几个特性: + +* 支持将异步任务做封装以及组装,需要 API 对异步任务进行包装,并且需要很多子任务来对异步操作进行链式组装,过程中包括过滤,异常处理,超时等等操作。 +* 减少异步任务的嵌套,减少代码的复杂性,增加可读性,避免 Callback Hell 这种及其复杂恶心的代码。 +* 支持背压 Back Pressure,也就需要有上游和下游的概念,可以做到协商处理数据流的速度。 + +# Java 层面 Reactive Steams 的 API + +首先 Reactive Steams 响应流实现方式其实是基于观察者模式的扩展,同时也能看到发布订阅模式,责任链模式等等。 + +整个 Reactive Steams 流程大致如下。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUB1ovAJ9LveK6iaw8YUyCicia79IzCJWLHsPf9CS8o2bmxSOQt12GSDRKvA/640?wx_fmt=png&from=appmsg) + +直接在 JDK9 版本之上查看 Doug Lee 提供的 Flow 类。 + +在 Flow 类中,提供了核心的四个接口:Publisher,Subscriber,Subscription,Processor + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBTVSOkgjPOw4pnqTOrpwxic8X9o0Vtb09BUdP6jVCj41Yj1CVAqjexWA/640?wx_fmt=png&from=appmsg) + +Publisher:Publisher 是函数式接口,负责发布数据的。 Publisher 内部有一个方法 subscribe 方法,去和具体的订阅者绑定关系。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBxrb5DSib2xeqEeItcB7mpibkSCjZhseBSBHzSiciaNZa4BwKZYB5zmupFg/640?wx_fmt=png&from=appmsg) + +Subscriber:Subscriber 是订阅者,负责订阅,消费数据。四个方法: + +- onSubscribe:订阅成功后触发,并且表明可以开始接收发布者的数据元素了。 +- onNext:每次获取到发布者的数据元素都会执行 onNext。 +- onError:接收数据元素时,出现异常等问题时,走 onError。 +- onComplete:当指定接收的元素个数搞定后,触发 onComplete。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBJhzfo9CnDJicVedPtuy4Pp9Ivp8DcdgjH6v6dwvLoUwicSp88dfrP6rw/640?wx_fmt=png&from=appmsg) + +Subscription:发布者和订阅者是基于 Subscription 关联的。当建立了订阅的关系后,发布者会将 Subscription 传递给订阅者。订阅者指定获取元素的数量和取消订阅操作,都要基于 Subscription 去操作。提供了两个方法: + +- request:订阅者要获取的元素个数。 +- cancel:取消订阅,当前的订阅者不接收当前发布者的元素。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBhukW4Q6HRW1KPAuUONjNnYGDHDZNG1Cia3aaWSyxNHfNZu0zsTdZibWw/640?wx_fmt=png&from=appmsg) + +Processor:Processor 继承了 Publisher 和 Subscriber,即是发布者也是订阅者。Processor 一般作为数据的中转,订阅者处理完数据元素,可以再次发给下一个订阅者。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMTgibXaD43CfueC3XMT1iaUBGeW651DJBGcBsicB0QOgcMeBwYpE3zYb9Pbfk3vQhEibicNuvNFejlmqA/640?wx_fmt=png&from=appmsg) + +这四个接口很重要,是 Reactive Streams 的规范,但是可以明显的看到,内部没有具体的内容实现。 + +这里就类似 JDBC 这种规范,规范在 JDK9 中提出来了,想实现,可以基于当前的这四个接口再做具体的逻辑处理以及实现的细节。 + +# Java 层面 Reactive Steams 基本操作 + +咱们测试 Java 中的 Flow 里提供的 API 时,就是走最基本的操作。 + +其中 Processor 不需要重写,玩最基本的操作,不去做订阅者和发布者的转换。 + +其次 Subscription 也不需要重写,这东西就是提供了订阅者指定订阅的消息个数,以及取消的操作。 + +然后 Publisher 需要重写,但是 JDK 中已经提供了一个 Publisher 的实现,SubmissionPublisher,可以直接使用。 + +最后,Subscriber 需要咱们自己重写,指定好订阅消息的个数,已经消费的一些逻辑 + +```java +import java.util.concurrent.Flow; + + +public class MySubscriber implements Flow.Subscriber { + + @Override + // 绑定好订阅关系后,就会触发这个方法 + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(10); + } + + @Override + public void onNext(Integer item) { + System.out.println(Thread.currentThread().getName() + ":接收到数据流:" + item); + } + + @Override + public void onError(Throwable throwable) { + System.out.println(Thread.currentThread().getName() + ":接收消息出现异常:" + throwable.getMessage()); + } + + @Override + public void onComplete() { + System.out.println(Thread.currentThread().getName() + ":当前订阅者要求接收的消息全部处理完毕。"); + } +} +``` + +直接使用 SubmissionPublisher 测试整体效果 + +```java +public static void main(String[] args) { + // 只有一个工作线程的线程池 + ExecutorService executor = Executors.newFixedThreadPool(1); + // 指定缓冲区的大小 + int maxBufferCapacity = 5; + + // 需要指定两个参数 + // 第一个参数需要传递一个线程池,指定订阅者使用的线程 + // 第二个参数,需要指定一个缓冲区,发布者发布消息后,消息会扔到缓冲区里。 + SubmissionPublisher publisher = new SubmissionPublisher<>(executor,maxBufferCapacity); + + // 绑定订阅者 + MySubscriber subscriber = new MySubscriber(); + publisher.subscribe(subscriber); + + // 发布消息 + for (int i = 0; i < 10; i++) { + System.out.println(Thread.currentThread().getName() + ":发布消息:" + i); + publisher.submit(i); + } + + // 释放资源 + publisher.close(); + executor.shutdown(); +} +``` + +结果输出如下: + +``` +main:发布消息:0 +main:发布消息:1 +main:发布消息:2 +main:发布消息:3 +main:发布消息:4 +main:发布消息:5 +main:发布消息:6 +main:发布消息:7 +main:发布消息:8 +main:发布消息:9 +pool-1-thread-1:接收到数据流:0 +pool-1-thread-1:接收到数据流:1 +pool-1-thread-1:接收到数据流:2 +pool-1-thread-1:接收到数据流:3 +pool-1-thread-1:接收到数据流:4 +pool-1-thread-1:接收到数据流:5 +pool-1-thread-1:接收到数据流:6 +pool-1-thread-1:接收到数据流:7 +pool-1-thread-1:接收到数据流:8 +pool-1-thread-1:接收到数据流:9 +pool-1-thread-1:当前订阅者要求接收的消息全部处理完毕。 +``` + +- **缓冲区:** 缓冲区就是发布者和订阅者之间的一块内存,类似线程池中的阻塞队列,可以将消息扔到这个缓存区里。其次咱们设置的缓冲区大小是 5,但是发现 get 出来的时候,5 被替换为了 8。这是因为 SubmissionPublisher 为了更有效的使用内存,默认会基于 roundCapacity 方法将传递的缓冲区大小替换为 2 的 n 次幂。 +- **背压效果:** 当订阅者指定的消息已经全部处理完毕后,发布者最多只能发布缓冲区大小个数的消息,剩下的内容会基于背压的效果直接暂时不发送。 +- **onComplete:** 需要发布者做了close 操作,确认了发布者已经将消息全部发送,并且订阅者也已经将全部的消息处理完毕后,才会触发 onComplete。 +- **Subscription:** 订阅者可以在 onNext 或者其他方法中动态的使用 subscription 去指定后续需要几个消息订阅,以及是否需要取消订阅消息等操作。 + +# Reactive Steams 落地体验 + +## 回调地狱问题 + +前面的方式大致了解了 JDK9 中更新的 Reactive Streams 的规范,咱们实现也仅仅是看到了发布订阅和回压的效果。并没有看到如何解决回调地狱的问题。咱们可以通过 Spring5 官网提供的一个例子,来体验一下 CallBack Hell 回调地狱带来的问题。后面再根据三方的实现来看一下基于 Reactive Streams 实现后效果如何。这里基本是根据伪代码走的。 + +例子:在用户的 UI 页面上,展示当前用户最喜欢的 Top5 的商品详情。这里会根据用户的 ID 去查询当前用户 Top5 商品的ID,如果 ID 可以查询到之后再根据商品的 ID 去查询商品的详情。如果当前用户 ID 查询的结果不存在喜欢的 Top 商品,没有的话,通过推荐服务查询 Top5 的商品信息。展示给用户。 + +当前例子需要三个服务的支撑: + +* 根据用户 ID 查询用户的 Top5 商品ID。 +* 根据 Top5 商品ID查询商品详情。 +* 调用推荐服务,获取5个商品详情。 + +基于 Java 最原生的异步编程方式,实现上述操作,来看看到底什么是回调地狱。。。 + +商品详情实体类: + +```java +@Data +public class Fav { + + private String itemId; + + private String itemName; + + private String itemDetail; + +} +``` + +准备回调方法,拿到结果后触发 + +```java +public interface Callback { + + void onSuccess(T t); + + void onError(Throwable throwable); + +} +``` + +准备了访问三个服务的 Service 接口 + +```java +public interface UserService { + + /** + * 根据用户Id查询用户的Top5商品Id + * @param userId + * @param list + */ + void getFav(String userId, Callback> list); + +} + +public interface ItemService { + + /** + * 根据商品Id查询商品的详情 + * + * @param itemId + * @param callback + */ + void getDetail(String itemId, Callback callback); +} + +public interface SuggestionService { + + /** + * 调用推荐服务,获取推荐商品 + * @param favs + */ + void getSuggestion(Callback> favs); +} +``` + +准备了响应数据的 UI 线程工具以及响应方法 + +```java +public class UiUtils { + + public static void submitOnUiThread(Runnable runnable){ + // 线程池中的线程做响应的操作……………… + } + + + public static void show(Object obj){ + // 利用UI线程展示具体数据 + } + + public static void error(Object obj){ + // 出现错误响应的内容 + } + +} +``` + +完成了 Controller 中的异步编程效果 + +```java +@RestController +public class CallBackHellController { + + @Autowired + private UserService userService; + + @Autowired + private ItemService itemService; + + @Autowired + private SuggestionService suggestionService; + + + @GetMapping("/callbackhell") + public void callbackHell(String userId){ + //1、调用用户服务,查询Top5商品Id + userService.getFav(userId, new Callback>() { + @Override + public void onSuccess(List list) { + // 已经查询到商品Id,但是不知道是否有值 + if (list.isEmpty()){ + // 3、用户没有Top5商品Id,通过推荐服务查询推荐商品详情 + suggestionService.getSuggestion(new Callback>(){ + @Override + public void onSuccess(List favs) { + // 推荐服务查询到了商品详情,响应即可 + UiUtils.submitOnUiThread(() -> { + favs.stream().limit(5).forEach(UiUtils::show); + }); + } + @Override + public void onError(Throwable throwable) { + UiUtils.error(throwable); + } + }); + + } + else{ + // 2、通过用户查询到了Top5商品Id,通过商品Id查询商品详情 + list.stream().limit(5).forEach(itemId -> itemService.getDetail(itemId,new Callback(){ + + @Override + public void onSuccess(Fav fav) { + // 查询到了商品详情,利用UI线程,给客户端响应数据 + UiUtils.submitOnUiThread(() -> UiUtils.show(fav)); + } + + @Override + public void onError(Throwable throwable) { + // 出现异常了。 + UiUtils.error(throwable); + } + })); + } + } + @Override + public void onError(Throwable throwable) { + // 出现异常了。 + UiUtils.error(throwable); + } + }); + + + } + +} +``` + +## 解决回调地狱问题 + +这里为了解决回调地狱问题,需要一个 Reactor 的依赖来帮助咱们实现异步编程。 + +需要导入依赖 + +```xml + + + io.projectreactor + reactor-core + 3.7.7 + +``` + +不能再使用之前的 Callback 方式了。需要使用 reactor 提供的 Flux,并且这种链式操作会更直观,也更好维护。就只需要修改三个服务对应的 Service。 + +```java +public interface UserService { + + /** + * 根据用户ID查询Top5商品ID + * @param userId + * @return + */ + Flux> getFav(String userId); + +} + +public interface ItemService { + + + /** + * 根据商品ID查询商品详情 + * @param itemId + * @return + */ + Flux getDetail(String itemId); + +} + +public interface SuggestionService { + + /** + * 获取推荐的商品详情 + * @return + */ + Flux> getSuggestion(); + +} +``` + +然后就可以利用 Flux 提供的 API 来解决之前回调地狱的问题。 + +```java +@RestController +public class ReactorCallbackController { + + @Autowired + private UserService userService; + + @Autowired + private ItemService itemService; + + @Autowired + private SuggestionService suggestionService; + + + @GetMapping("reactorcallback") + public void reactorCallback(String userId){ + userService + .getFav(userId) // 根据用户Id查询Top5商品Id + .flatMap(itemService::getDetail) // 根据商品ID查询商品详情 + .switchIfEmpty(suggestionService.getSuggestion()) // 如果前面为null,这里通过推荐服务查询商品详情 + .take(5) // 获取前5个数据 + .publishOn(UiUtils.reactorOnUiThread()) // 使用Ui线程 + .subscribe(UiUtils::show,UiUtils::error); // 成功走show,失败走error + } + +} +``` + +## CompletableFuture 的异步编程 + +Future 的形式相比 Callback Hell 效果要好一些,虽然 JDK8 和 9 都对 CompletableFuture 做了各种优化,但是他的表现还是不太好。多个Future在嵌套时,可读性还是比较差的。并且 CompletableFuture 不存在什么回压,或者是延迟调用的功能。 + +现在借助 CompletableFuture 来实现一个场景。 + +1. 获取一个用户ID的列表。 +2. 通过用户ID分别获取他的名字以及统计信息。(希望这两个操作是并行执行的) +3. 当两个信息都获取到之后,封装成一个普通字符串即可。 +4. 响应数据,最后拿到结果(输出一下)。 + +实现代码 + +```java +public class GetNameAndStatTestByCF { + + public static void main(String[] args) { + // 1、获取一组用户ID列表 + CompletableFuture> idList = getID(); + CompletableFuture> dataCompletableFuture = idList.thenComposeAsync(ids -> { + Stream> resultStream = ids.stream().map(id -> { + // 2、并行基于ID查询名称信息 + CompletableFuture nameTask = getName(); + // 2、并行基于ID查询统计信息 + CompletableFuture statTask = getStat(); + // 让两个查询名称信息和查询统计信息操作并行执行 + return nameTask.thenCombineAsync(statTask, (name, stat) -> { + // 3、拿到信息组装 + return "Name:" + name + ",Stat:" + stat; + }); + }); + // 将resultStream转换成一个数组 + List> resultList = resultStream.toList(); + // 全部的任务封装起来 + CompletableFuture allDone = CompletableFuture.allOf(resultList.toArray(new CompletableFuture[]{})); + // 执行全部任务 + return allDone.thenApplyAsync(v -> resultList.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList())); + }); + + // 4、获取全部的组件信息后响应客户端(输出) + List data = dataCompletableFuture.join(); + System.out.println(data); + } + + // 模拟zz服务获取统计信息 + private static CompletableFuture getStat() { + return CompletableFuture.supplyAsync(() -> 666); + } + + // 模拟yy服务获取名称信息 + private static CompletableFuture getName() { + return CompletableFuture.supplyAsync(() -> "张三"); + } + + // 模拟xx服务,获取一组用户ID + private static CompletableFuture> getID() { + return CompletableFuture.supplyAsync(() -> { + // 模拟查询三方服务 + List list = new ArrayList<>(); + list.add("1"); + list.add("2"); + list.add("3"); + return list; + }); + } + +} +``` + +## 解决 CompletableFuture 的问题 + +CompletableFuture 可以实现一些简单的异步编程,但是可看性和维护性以后后期的扩展都需要对整体代码做比较大成本的维护。依然采用 Reactor 来实现一个一模一样的逻辑,再看代码效果。 + +```java +public class GetNameAndStatByReactor { + + public static void main(String[] args) { + // 1、获取一组用户ID列表 + Flux idFlux = getId(); + + Flux result = idFlux.flatMap(id -> { + // 2、并行基于ID查询名称信息 + Flux nameFlux = getName(id); + // 2、并行基于ID查询统计信息 + Flux statFlux = getStat(id); + // 俩任务并行处理完毕,触发3 + return nameFlux.zipWith(statFlux, (name, stat) -> { + // 3、拿到信息组装 + return "Name:" + name + ",Stat:" + stat; + }); + }); + Mono> listMono = result.collectList(); + List info = listMono.block(); + // 4、获取全部的组件信息后响应客户端(输出) + System.out.println(info); + } + + private static Flux getStat(String id) { + // 会查询三方服务,然后封装结果 + return Flux.just(888); + } + + private static Flux getName(String id) { + // 会查询三方服务,然后封装结果 + return Flux.just("张三"); + } + + + private static Flux getId() { + // 会查询三方服务,然后封装结果 + return Flux.just("1","2","3"); + } +} +``` + +# RxJava2 实现异步编程 + +RxJava 是一个小框架,或者是依赖库。在 RxJava 的1.x版本中,它并不基于 Reactive Streams 去实现的。没有关系,因为 RxJava 的 2 版本,就是基于Reactive Streams 实现的了。 + +使用 RxJava 巨简单,因为作者想将 RxJava 尽量做到轻量级,就一个依赖。 + +```xml + + + io.reactivex.rxjava2 + rxjava + 2.2.21 + +``` + +## RxJava2 的入门操作 + +获取一个 Person 对象的集合,将 Person 集合中的所有年龄大于10岁的 Person 对象筛选出来,并输出他的名字。 + +采用 RxJava 来实现一下: + +```java +public class Demo { + + public static void main(String[] args) { + //1、获取Person对象集合 + List personList = getPersonList(); + + //2、完成上面要求的操作 + //2.1、将person集合转换为RxJava的流 + Flowable.fromArray(personList.toArray(new Person[]{})) + //2.2 过滤年龄大于10岁的 + .filter(person -> person.getAge() > 10) + //2.3 获取筛选后的Person名称 + .map(person -> person.getName()) + //2.4 输出Name + .subscribe(System.out::println); + } + + private static List getPersonList() { + List personList = new ArrayList<>(); + personList.add(new Person("大娃",5)); + personList.add(new Person("二娃",7)); + personList.add(new Person("三娃",9)); + personList.add(new Person("四娃",11)); + personList.add(new Person("五娃",13)); + return personList; + } +} +``` + +## RxJava2 的基础处理流程 + +在 RxJava 中有三个核心的角色 + +* 被观察者(Observable) +* 观察者(Observer) +* 订阅(Subscribe) + +```java +public class Demo2 { + + public static void main(String[] args) { + //1、构建Observable + Observable observable = Observable.create(emitter -> { + emitter.onNext("Hello"); + emitter.onNext("World"); + emitter.onComplete(); + }); + + //2、构建Observer + Observer observer = new Observer<>() { + @Override + public void onSubscribe(Disposable d) { + System.out.println("开始订阅"); + } + + @Override + public void onNext(String s) { + System.out.println("观察者:" + s); + } + + @Override + public void onError(Throwable e) { + e.printStackTrace(); + } + + @Override + public void onComplete() { + System.out.println("订阅结束"); + } + }; + + //3、订阅 + observable.subscribe(observer); + } + +} +``` + +## 创建操作符 + +### create + +Observable.create() 是手动创建 Observable 的方法,允许完全控制数据的发射、完成和错误处理。 + +```java +Observable observable = Observable.create(emitter -> { + emitter.onNext("Hello"); + emitter.onNext("World"); + emitter.onComplete(); +}); + +observable.subscribe( + item -> System.out.println("收到: " + item), + error -> System.err.println("错误: " + error), + () -> System.out.println("完成") +); + +// 输出: +// 收到: Hello +// 收到: World +// 完成 +``` + +### just + +just 用于创建一个发射固定数据的 Observable,数据是预定义的,发射后立即完成。 + +```java +Observable.just("Hello") + .subscribe(item -> + System.out.println("收到: " + item) + ); + +// 输出: +// 收到: Hello +``` + +### fromArray + +fromArray 用于从数组创建一个 Observable,按数组顺序发射所有元素。 + +```java +String[] fruits = {"Apple", "Banana", "Cherry", "Date"}; +Observable.fromArray(fruits) + .subscribe(fruit -> + System.out.print(fruit + " ") + ); + +// 输出: +// Apple Banana Cherry Date +``` + +### fromCallable + +fromCallable 用于从 Callable 创建 Observable,Callable 的返回值会被包装成 Observable 发射。 + +```java + public static void main(String[] args) { + Observable.fromCallable(() -> "计算结果: " + System.currentTimeMillis()) + .subscribe(System.out::println); + + // 输出: + // 计算结果: 1620000000000 + } +``` + +### timer + +timer 用于创建一个延迟指定时间后发射单个数据的 Observable,通常是 0L,然后结束。 + +```java + public static void main(String[] args) throws InterruptedException { + System.out.println("开始时间: " + System.currentTimeMillis()); + + Observable.timer(2, TimeUnit.SECONDS) + .subscribe(tick -> + System.out.println("触发时间: " + System.currentTimeMillis() + ",值: " + tick) + ); + + Thread.sleep(3000); + } +``` + +默认使用 `Schedulers.computation()`线程池计算,线程池中的线程是守护线程,如果主线程结束守护线程也会随之终止。 + +### interval + +interval() 方法用于创建一个 周期性定时发射的 Observable,它会按照指定的时间间隔无限期地发射递增的数字序列(从0开始)。 + +```java + public static void main(String[] args) throws IOException { + Observable.interval(2, TimeUnit.SECONDS) + .subscribe(aLong -> System.out.println(Thread.currentThread().getName() + ":" + aLong)); + + System.in.read(); + } +``` + +### intervalRange + +intervalRange() 是 interval() 的增强版本,用于创建一个有限次数的周期性发射的 Observable。它允许你指定起始值、发射次数、初始延迟和间隔时间。 + +```java + public static void main(String[] args) throws IOException { + Observable.intervalRange(100, 4, 0, 2, TimeUnit.SECONDS) + .subscribe(aLong -> System.out.println(Thread.currentThread().getName() + ":" + aLong)); + System.in.read(); + } +``` + +### range & rangeLong + +这两个方法用于创建一个发射连续整数序列的 Observable: + +- `range(start, count)`:发射 Integer 类型的连续整数。 +- `rangeLong(start, count)`:发射 Long 类型的连续整数。 + +```java + public static void main(String[] args) throws IOException { + Observable.range(0, 10) + .subscribe(integer -> System.out.println(Thread.currentThread().getName() + ":" + integer)); + System.in.read(); + } +``` + +### never、error、empty + +这三个方法都是创建特殊的 Observable 的工厂方法,用于特定的场景。 + +* never:创建一个永远不会发射任何数据,也不会终止的 Observable。 +* error:创建一个立即发射错误的 Observable。 +* empty:创建一个立即完成但不发射任何数据的 Observable。 + +```java + public static void main(String[] args) { + // Observable.never() + // Observable.error(new RuntimeException("error事件")) + Observable.empty() + .subscribe(new Observer() { + @Override + public void onSubscribe(Disposable d) { + System.out.println("开始订阅"); + } + + @Override + public void onNext(Object s) { + System.out.println("观察者:" + s); + } + + @Override + public void onError(Throwable e) { + System.out.println("出现异常:" + e.getMessage()); + } + + @Override + public void onComplete() { + System.out.println("订阅结束"); + } + }); + } +``` + +## 转换操作符 + +转换操作符是 RxJava 中用于对 Observable 发射的数据进行变换、处理、组合的操作符,用于实现数据流的各种转换逻辑。 + +### map + +map() 是 RxJava 中最基本、最常用的转换操作符,用于对 Observable 发射的每个元素进行一对一转换。 + +简单来说:输入一个值,输出另一个值(1进1出)。 + +```java + public static void main(String[] args) { + Observable.just(1, 2, 3, 4, 5) + .map(number -> "数字: " + number) + .subscribe(text -> System.out.println(text)); + } +``` + +### flatMap + +flatMap() 用于将每个发射项转换为 Observable,然后"扁平化"合并成一个 Observable。 +简单来说:输入一个值,可以输出任意多个值(1进N出)。 + +```java + public static void main(String[] args) { + Observable.just("A", "B") + .map(letter -> letter + "1") + .subscribe(result -> System.out.print(result + " ")); + // 输出: A1 B1 + + // flatMap: 1对多转换 + Observable.just("A", "B") + .flatMap(letter -> + Observable.just(letter + "1", letter + "2", letter + "3") + ) + .subscribe(result -> System.out.print(result + " ")); + // 输出: A1 A2 A3 B1 B2 B3 + } +``` + +### concatMap + +concatMap() 是 flatMap() 的顺序保持版本,它会严格按照原始顺序依次处理每个元素,只有前一个元素的 Observable 完成之后,才会处理下一个。 +简单来说:flatMap是并发处理,concatMap是串行处理。 + +```java + public static void main(String[] args) throws IOException { + Observable.just(3, 1, 2) // 注意顺序:3, 1, 2 + .flatMap(num -> + Observable.just(num) + .delay(num, TimeUnit.SECONDS) // 延迟对应的秒数 + ) + .subscribe(num -> System.out.println("flatMap: " + num)); + // 输出顺序:1 2 3(谁先完成谁先输出) + + Observable.just(3, 1, 2) + .concatMap(num -> + Observable.just(num) + .delay(num, TimeUnit.SECONDS) + ) + .subscribe(num -> System.out.println("concatMap: " + num)); + // 输出顺序:3 1 2(严格保持原始顺序) + System.in.read(); + } +``` + +### buffer + +buffer() 用于将 Observable 发射的数据项收集到集合中,然后一次性发射这些集合,而不是单个发射。 +简单来说:把多个单独的数据"打包"成一批一起发射。 + +```java + public static void main(String[] args) { + Observable.range(1, 10) // 发射1-10 + .buffer(3) // 每3个一批 + .subscribe(batch -> + System.out.println("批次: " + batch) + ); + + // 输出: + // 批次: [1, 2, 3] + // 批次: [2, 4, 6] + // 批次: [7, 8, 9] + // 批次: [10] ← 最后一批不足3个 + } +``` + +### scan + +scan() 用于对 Observable 发射的数据进行累积计算,并发射每个中间结果。 +简单来说:像 Excel 里的累计求和,每来一个新数据,就与前一个结果计算,并输出当前累计值。 + +```java + public static void main(String[] args) { + Observable.just(1, 2, 3, 4, 5) + .scan(Integer::sum) + .subscribe(result -> System.out.print(result + " ")); + + // 输出: + // 1 3 6 10 15 + + // 计算过程: + // 初始:无种子值,第一次直接发射1 + // 1 + 2 = 3 + // 3 + 3 = 6 + // 6 + 4 = 10 + // 10 + 5 = 15 + } +``` + +### window + +window() 用于将 Observable 发射的数据分组到多个子 Observable 中,然后发射这些子 Observable 而不是单个数据项。 +简单来说:创建多个"窗口",每个窗口是一个 Observable,将数据分配到不同窗口中。 + +```java + public static void main(String[] args) { + // buffer: 直接发射List + Observable.range(1, 5) + .buffer(2) + .subscribe(list -> + System.out.println("buffer输出List: " + list) + ); + + // window: 发射Observable,需要进一步处理 + Observable.range(1, 5) + .window(2) + .flatMapSingle(Observable::toList // 需要手动转换 + ) + .subscribe(list -> + System.out.println("window输出List: " + list) + ); + + // 两者输出相同,但window更灵活: + // 输出: + // [1, 2] + // [3, 4] + // [5] + } +``` + +window 和 buffer 的区别就是: + +* window 返回的是被观察者的集合。 +* buffer 返回的是数据的集合。 + +## 过滤操作符 + +过滤操作符用于从数据流中筛选出需要的数据,过滤掉不需要的数据。 + +### filter + +filter() 是 RxJava 中最基本的过滤操作符,用于根据指定条件筛选 Observable 发射的数据,只让满足条件的数据通过,不满足条件的被过滤掉。 + +对每个数据项进行判断,返回 true则通过,返回 false则丢弃。 + +```java + public static void main(String[] args) { + Observable.range(1, 10) // 发射1-10 + .filter(number -> number % 2 == 0) // 只保留偶数 + .subscribe(even -> System.out.print(even + " ")); + + // 输出: + // 2 4 6 8 10 + } +``` + +### ofType + +ofType() 是一个类型过滤操作符,用于过滤 Observable 发射的数据,只保留指定类型的数据,其他类型的数据会被过滤掉。 +核心思想:只让指定类型的数据通过,相当于 filter(item -> item instanceof TargetType) 的简化版。 + +```java + public static void main(String[] args) { + Observable mixedObservable = Observable.just( + "Hello", // String + 123, // Integer + 45.6, // Double + "World", // String + true, // Boolean + 789 // Integer + ); + + // 只保留字符串类型 + mixedObservable + .ofType(String.class) + .subscribe(str -> + System.out.println("字符串: " + str) + ); + + // 输出: + // 字符串: Hello + // 字符串: World + } +``` + +### distinct + +distinct() 用于过滤 Observable 中重复的数据项,确保每个数据项只发射一次。 +核心思想:去重,保证发射的数据序列中不包含重复元素。 + +```java + public static void main(String[] args) { + Observable.just(1, 2, 2, 3, 3, 3, 4, 5, 5, 1) + .distinct() + .subscribe(num -> System.out.print(num + " ")); + + // 输出: + // 1 2 3 4 5 + // 注意:最后的 1 也被去重了 + } +``` + +### skip & skipLast + +- skip(n):跳过 Observable 发射的前 n 个数据项。 +- skipLast(n):跳过 Observable 发射的后 n 个数据项。 + +核心思想:skip是"跳过开头",skipLast是"跳过结尾"。 + +```java + public static void main(String[] args) { + Observable.create(emitter -> { + emitter.onNext(1); + emitter.onNext(2); + emitter.onError(new RuntimeException("中间出错")); + }) + .skipLast(1) + .subscribe( + num -> System.out.println("数据: " + num), + error -> System.err.println("错误: " + error.getMessage()), + () -> System.out.println("完成") + ); + + // 输出: + // 数据: 1 + // 错误: 中间出错 + } +``` + +### distinctUntilChanged + +distinctUntilChanged() 用于过滤掉连续重复的数据,只保留发生变化的数据。 +核心思想:去重,但只去重连续相同的值,不连续出现的相同值会被保留。 + +```java + public static void main(String[] args) { + Observable.just(1, 1, 2, 2, 1, 3, 3, 2, 2, 1) + .distinctUntilChanged() + .subscribe(num -> System.out.print(num + " ")); + + // 输出: + // 1 2 1 3 2 1 + + // 解释: + // 原始: 1, 1, 2, 2, 1, 3, 3, 2, 2, 1 + // 去重连续重复后: 1, 2, 1, 3, 2, 1 + // 注意:中间的 1 虽然出现过,但因为不连续,所以保留了 + } +``` + +### take + +take() 用于从 Observable 的开头取出指定数量的数据,然后完成。 +核心思想:只取前 N 个数据,之后的数据忽略,Observable 提前完成。 + +```java + public static void main(String[] args) { + Observable.range(1, 10) // 发射1-10 + .take(3) // 只取前3个 + .subscribe( + num -> System.out.print(num + " "), + error -> { + }, + () -> System.out.println("\n已完成") + ); + + // 输出: + // 1 2 3 + // 已完成 + // 注意:4-10 不会被发射 + } +``` + +### firstElement & lastElement & elementAt + +这三个操作符都用于从 Observable 中取出特定位置的单个元素: + +- **`firstElement()`**:取第一个元素 +- **`lastElement()`**:取最后一个元素 +- **`elementAt(index)`**:取指定索引位置的元素 + +```java + public static void main(String[] args) { + Observable.range(1, 5) // 1,2,3,4,5 + .firstElement() // 取第一个 + .subscribe( + num -> System.out.println("第一个: " + num), + error -> { + }, + () -> System.out.println("没有第一个元素") + ); + + // 输出: + // 第一个: 1 + } +``` + +## 组合操作符 + +组合操作符是 RxJava 中用于将多个 Observable 组合成一个 Observable 的操作符。它们处理多个数据流之间的关系,实现流的合并、连接、组合等操作。 + +### concat + +concat() 用于顺序连接多个 Observable,前一个 Observable 完成后,才开始发射下一个 Observable 的数据。 +核心思想:串行连接,保持顺序,像排队一样一个接一个。 + +```java + public static void main(String[] args) { + Observable first = Observable.just("A", "B", "C"); + Observable second = Observable.just("D", "E", "F"); + Observable third = Observable.just("G", "H"); + + Observable.concat(first, second, third) + .subscribe( + letter -> System.out.print(letter + " "), + error -> { + }, + () -> System.out.println("\n全部完成") + ); + + // 输出: + // A B C D E F G H + // 全部完成 + // 严格按照 first → second → third 的顺序 + } +``` + +### concatArray + +concatArray() 是 concat() 的数组版本,用于顺序连接多个 Observable(以数组形式提供),功能与 concat() 完全相同,只是参数形式不同。 + +```java + public static void main(String[] args) { + // 创建Observable数组 + Observable[] observables = new Observable[]{ + Observable.just("A", "B"), + Observable.just("C", "D", "E"), + Observable.just("F", "G") + }; + + // 使用 concatArray 连接 + Observable.concatArray(observables) + .subscribe(letter -> System.out.print(letter + " ")); + + // 输出: + // A B C D E F G + // 顺序连接所有数组中的Observable + } +``` + +### merge + +merge() 用于并发合并多个 Observable,将所有数据按实际到达时间顺序混合发射。 + +核心思想:并行执行,谁先到谁先出,像多车道合并成单车道。 + +```java + public static void main(String[] args) throws InterruptedException { + Observable fast = Observable.interval(100, TimeUnit.MILLISECONDS) + .map(i -> i + 100) // 100, 101, 102... + .take(3); + Observable slow = Observable.interval(200, TimeUnit.MILLISECONDS) + .map(i -> i + 200) // 200, 201, 202... + .take(3); + + Observable.merge(fast, slow) + .subscribe(num -> System.out.print(num + " ")); + + Thread.sleep(1000); + + // 输出(实际时间顺序): + // 100 101 200 102 201 202 + // 解释: + // 100ms: fast发射100 + // 200ms: slow发射200, fast发射101 + // 300ms: fast发射102 + // 400ms: slow发射201 + // 600ms: slow发射202 + } +``` + +### zip + +zip() 用于将多个 Observable 的数据按索引配对组合,像拉链一样一一对应。 + +核心思想:等待所有Observable都有数据,然后配对发射,以最短的Observable为准。 + +```java + public static void main(String[] args) { + Observable letters = Observable.just("A", "B", "C", "D"); + Observable numbers = Observable.just(1, 2, 3); + + Observable.zip( + letters, + numbers, + (letter, number) -> letter + number + ) + .subscribe(result -> System.out.print(result + " ")); + + // 输出: + // A1 B2 C3 + // 注意:D 被丢弃了,因为 numbers 只有3个 + + } +``` + +### startWith & startWithArray + +这两个操作符都用于在 Observable 发射数据之前,先发射一些指定的数据,就像给数据流添加"开场白"。 + +- startWith():添加单个元素或 Observable。 +- startWithArray():添加多个元素(可变参数)。 + +```java + public static void main(String[] args) { + // startWith: 添加单个元素 + Observable.just("B", "C") + .startWith("A") + .subscribe(letter -> System.out.print("startWith: " + letter + " ")); + + // startWithArray: 添加多个元素 + Observable.just("D", "E") + .startWithArray("A", "B", "C") + .subscribe(letter -> System.out.print("startWithArray: " + letter + " ")); + + // 输出: + // startWith: A B C + // startWithArray: A B C D E + } +``` + +### count + +count() 用于统计 Observable 发射的元素数量,返回一个发射单个计数值的 Observable。 + +核心思想:数一数发射了多少个元素。 + +```java + public static void main(String[] args) { + Observable.just("A", "B", "C", "D", "E") + .count() + .subscribe(count -> + System.out.println("元素数量: " + count) + ); + + // 输出: + // 元素数量: 5 + } +``` + +## 功能操作符 + +功能操作符是 RxJava 中用于辅助调试、监控、错误处理、资源管理和工具性功能的操作符。它们不直接处理数据流,而是提供辅助功能。 + +### delay + +delay() 用于延迟发射 Observable 的数据,可以延迟整个流,也可以延迟每个元素。 +核心思想:让数据"等一会儿"再发射。 + +```java + public static void main(String[] args) throws InterruptedException { + System.out.println("开始时间: " + System.currentTimeMillis()); + + Observable.just("A", "B", "C") + .delay(1, TimeUnit.SECONDS) // 延迟1秒 + .subscribe(item -> + System.out.println(System.currentTimeMillis() + ": " + item) + ); + + Thread.sleep(2000); + + // 输出: + // 开始时间: 1766569373459 + // 1766569374574: A + // 1766569374580: B + // 1766569374580: C + // 注意:A,B,C几乎同时发射,都延迟了1秒 + } +``` + +### subscribeOn & observerOn + +这两个操作符用于控制 Observable 在哪个线程上执行和观察,是 RxJava 线程调度的核心。 + +- **`subscribeOn()`**:指定 Observable 执行在哪个线程 +- **`observeOn()`**:指定 Observer 观察在哪个线程 + +subscribeOn 示例: + +```java + public static void main(String[] args) throws InterruptedException { + Observable.create(emitter -> { + System.out.println("执行线程: " + Thread.currentThread().getName()); + emitter.onNext("数据"); + emitter.onComplete(); + }) + .subscribeOn(Schedulers.io()) // 在IO线程执行 + .subscribe(data -> + System.out.println("接收线程: " + Thread.currentThread().getName()) + ); + + Thread.sleep(1000); + + // 输出: + // 执行线程: RxCachedThreadScheduler-1 + // 接收线程: RxCachedThreadScheduler-1 + // 注意:执行和接收都在IO线程 + } +``` + +observeOn 示例: + +```java + public static void main(String[] args) throws InterruptedException { + Observable.create(emitter -> { + System.out.println("执行线程: " + Thread.currentThread().getName()); + emitter.onNext("数据"); + emitter.onComplete(); + }) + .observeOn(Schedulers.io()) // 在IO线程接收 + .subscribe(data -> + System.out.println("接收线程: " + Thread.currentThread().getName()) + ); + + Thread.sleep(1000); + + // 输出: + // 执行线程: main + // 接收线程: RxCachedThreadScheduler-1 + // 注意:执行在主线程,接收在IO线程 + } +``` + +### retry + +retry 用于在 Observable 发生错误时自动重试,实现错误恢复机制,直到成功或达到重试上限。 + +```java + public static void main(String[] args) { + AtomicInteger attempt = new AtomicInteger(); + + Observable.create(emitter -> { + attempt.getAndIncrement(); + System.out.println("第" + attempt + "次尝试"); + if (attempt.get() < 3) { + emitter.onError(new RuntimeException("随机失败")); + } else { + emitter.onNext("成功"); + emitter.onComplete(); + } + }) + .retry(2) // 最多重试2次 + .subscribe( + data -> System.out.println("结果: " + data), + error -> System.err.println("最终失败: " + error.getMessage()) + ); + + // 输出: + // 第1次尝试 + // 第2次尝试 + // 第3次尝试 + // 结果: 成功 + // 尝试3次后成功(初始1次+重试2次) + } +``` + +### retryUntil + +retryUntil 用于自定义重试停止条件,当条件满足时停止重试。 + +```java + public static void main(String[] args) { + AtomicInteger attempt = new AtomicInteger(0); + Observable.create(emitter -> { + int count = attempt.incrementAndGet(); + System.out.println("第" + count + "次尝试"); + if (count < 3) { + emitter.onError(new RuntimeException("失败")); + } else { + emitter.onNext("成功"); + emitter.onComplete(); + } + }) + .retryUntil(() -> { + // 返回 true 时停止重试 + return attempt.get() >= 3; // 尝试3次后停止 + }) + .subscribe( + data -> System.out.println("结果: " + data), + error -> System.err.println("最终失败: " + error.getMessage()) + ); + + // 输出: + // 第1次尝试 + // 第2次尝试 + // 第3次尝试 + // 结果: 成功 + } +``` + +## 生命周期的功能操作符 + +很多功能操作符贯穿整个发布订阅的生命周期中。 + +### doOnSubscribe- 订阅时 + +```java + public static void main(String[] args) { + Observable.just("A", "B", "C") + .doOnSubscribe(disposable -> + System.out.println("有人订阅了!") + ) + .subscribe(); + // 输出: + // 有人订阅了! + } +``` + +### doOnNext - 发射每个元素时 + +```java + public static void main(String[] args) { + Observable.range(1, 3) + .doOnNext(num -> + System.out.println("即将发射: " + num) + ) + .map(num -> num * 2) + .doOnNext(num -> + System.out.println("发射后: " + num) + ) + .subscribe(); + + // 输出: + // 即将发射: 1 + // 发射后: 2 + // 即将发射: 2 + // 发射后: 4 + // 即将发射: 3 + // 发射后: 6 + } +``` + +### doAfterNext - 发射后执行 + +```java +Observable.just("A", "B", "C") + .doOnNext(item -> + System.out.println("发射前: " + item) + ) + .doAfterNext(item -> + System.out.println("发射后: " + item) + ) + .subscribe(item -> + System.out.println("接收: " + item) + ); + +// 输出: +// 发射前: A +// 接收: A +// 发射后: A +// 发射前: B +// 接收: B +// 发射后: B +// 发射前: C +// 接收: C +// 发射后: C +``` + +### doOnError - 出错时 + +```java + public static void main(String[] args) { + Observable.error(new RuntimeException("测试错误")) + .doOnError(error -> + System.err.println("捕获到错误: " + error.getMessage()) + ) + .onErrorReturnItem("默认值") + .subscribe(); + + // 输出: + // 捕获到错误: 测试错误 + } +``` + +### doOnComplete - 完成时 + +```java + public static void main(String[] args) { + Observable.just("任务1", "任务2") + .doOnComplete(() -> + System.out.println("所有任务都完成了!") + ) + .subscribe(); + + // 输出: + // 所有任务都完成了! + } +``` + +### doOnTerminate - 完成/错误前执行 + +```java + public static void main(String[] args) { + // 正常完成的情况 + Observable.just("A", "B") + .doOnTerminate(() -> + System.out.println("即将完成/出错") + ) + .doOnComplete(() -> + System.out.println("doOnComplete") + ) + .subscribe( + item -> System.out.println("接收: " + item), + error -> { + }, + () -> System.out.println("onComplete") + ); + + // 输出: + // 接收: A + // 接收: B + // 即将完成/出错 + // doOnComplete + // onComplete + + // 出错的情况 + Observable.error(new RuntimeException("测试")) + .doOnTerminate(() -> + System.out.println("即将完成/出错") + ) + .doOnError(error -> + System.out.println("doOnError: " + error.getMessage()) + ) + .subscribe( + item -> { + }, + error -> System.out.println("onError: " + error.getMessage()) + ); + + // 输出: + // 即将完成/出错 + // doOnError: 测试 + // onError: 测试 + + } +``` + +### doAfterTerminate - 完成/错误后执行 + +```java + public static void main(String[] args) { + // 正常完成的情况 + Observable.just("A", "B") + .doAfterTerminate(() -> + System.out.println("完成/出错后执行") + ) + .doOnComplete(() -> + System.out.println("doOnComplete") + ) + .subscribe( + item -> System.out.println("接收: " + item), + error -> { + }, + () -> System.out.println("onComplete") + ); + + // 输出: + // 接收: A + // 接收: B + // doOnComplete + // onComplete + // 完成/出错后执行 + + // 出错的情况 + Observable.error(new RuntimeException("测试")) + .doAfterTerminate(() -> + System.out.println("完成/出错后执行") + ) + .doOnError(error -> + System.out.println("doOnError: " + error.getMessage()) + ) + .subscribe( + item -> { + }, + error -> System.out.println("onError: " + error.getMessage()) + ); + + // 输出: + // doOnError: 测试 + // onError: 测试 + // 完成/出错后执行 + } +``` + +### doOnDispose - 取消订阅时 + +```java + public static void main(String[] args) throws InterruptedException { + Disposable disposable = Observable.interval(1, TimeUnit.SECONDS) + .doOnDispose(() -> + System.out.println("订阅被取消了") + ) + .subscribe(num -> + System.out.println("计数: " + num) + ); + + Thread.sleep(3500); + disposable.dispose(); + + // 输出: + // 计数: 0 + // 计数: 1 + // 计数: 2 + // 订阅被取消了 + } +``` + +### doFinally - 最终执行 + +```java + public static void main(String[] args) { + Observable.just("数据") + .doFinally(() -> + System.out.println("无论如何都会执行") + ) + .subscribe(); + + // 输出: + // 无论如何都会执行 + } +``` + +### doOnEach - 每个事件时 + +```java + public static void main(String[] args) { + Observable.just("A", "B", "C") + .doOnEach(notification -> { + if (notification.isOnNext()) { + System.out.println("发射: " + notification.getValue()); + } else if (notification.isOnComplete()) { + System.out.println("完成"); + } else if (notification.isOnError()) { + System.out.println("错误: " + notification.getError()); + } + }) + .subscribe(); + + // 输出: + // 发射: A + // 发射: B + // 发射: C + // 完成 + } +``` + +### doOnRequest - 请求时(背压相关) + +```java + public static void main(String[] args) { + Flowable.range(1, 100) + .doOnRequest(requested -> + System.out.println("下游请求了 " + requested + " 个元素") + ) + .subscribe( + num -> System.out.println("接收: " + num), + error -> { + }, + () -> System.out.println("完成") + ); + + } +``` + +# 结束 + +响应式编程确实需要一些时间来掌握,但一旦理解核心概念,你就会发现它在处理异步数据流时的强大之处。 + +从回调地狱到流畅的链式调用,从手忙脚乱的资源管理到智能的背压控制,响应式编程让复杂异步操作变得清晰可控。虽然学习曲线存在,但投入是值得的。 + +记住,不是所有场景都需要响应式。在合适的业务场景下使用,才能发挥最大价值。建议从实际项目的小模块开始尝试,逐步积累经验。 + +希望这篇内容能为你打开响应式编程的大门。编程之路就是不断学习的过程,保持好奇,继续探索吧! diff --git "a/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Groovy\345\210\235\345\255\246\350\200\205\346\214\207\345\215\227.md" "b/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Groovy\345\210\235\345\255\246\350\200\205\346\214\207\345\215\227.md" index 1076678..795ca7b 100644 --- "a/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Groovy\345\210\235\345\255\246\350\200\205\346\214\207\345\215\227.md" +++ "b/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Groovy\345\210\235\345\255\246\350\200\205\346\214\207\345\215\227.md" @@ -1,18 +1,16 @@ -[TOC] - -## 摘要 - Groovy是一种基于Java平台的动态编程语言,它结合了Python、Ruby和Smalltalk等语言的特性,同时与Java无缝集成。在本篇博客中,我们将探讨Groovy与Java之间的联系与区别,深入了解Groovy的语法,并展示如何在Java中使用GroovyShell来运行Groovy脚本。 -## Groovy与Java的联系和区别 +## Groovy & Java + +Groovy与Java之间有着紧密的联系,同时也存在一些重要的区别。 -Groovy与Java之间有着紧密的联系,同时也存在一些重要的区别。首先,Groovy是一种动态语言,它允许在运行时动态修改代码。这使得Groovy在处理反射、元编程和脚本化任务时更加灵活。与此相反,Java是一种静态类型的编程语言,它要求在编译时就要确定类型和结构。 +首先,Groovy是一种动态语言,它允许在运行时动态修改代码。这使得Groovy在处理反射、元编程和脚本化任务时更加灵活。与此相反,Java是一种静态类型的编程语言,它要求在编译时就要确定类型和结构。 -另一个联系和区别在于Groovy与Java代码的互操作性。**Groovy可以直接调用Java类和库。这意味着可以在Groovy中使用Java类,也可以在Java中使用Groovy类。这种无缝集成使得Groovy成为Java开发人员的有力补充**。 +另一个联系和区别在于Groovy与Java代码的互操作性。**Groovy可以直接调用Java类和库,这意味着可以在Groovy中使用Java类,也可以在Java中使用Groovy类。这种无缝集成使得Groovy成为Java开发人员的有力补充。** -Groovy与Java相比,提供了一些额外的功能和简化的语法。例如,Groovy支持动态类型、闭包、运算符重载等特性,使得代码更加简洁易读。下面我们将介绍Groovy的语法。 +Groovy与Java相比,提供了一些额外的功能和简化的语法。例如,Groovy支持**动态类型**、**闭包**、**运算符重载**等特性,使得代码更加简洁易读。下面我们将介绍Groovy的语法。 -## Groovy的语法 +## Groovy语法 Groovy的语法与Java有许多相似之处,但也有一些重要的区别。下面是一些Groovy语法的关键要点: @@ -27,7 +25,7 @@ name = 42 // 可以将不同类型的值赋给同一个变量 ### 元编程 -Groovy支持元编程,这意味着你可以在运行时动态修改类、对象和方法的行为。通过使用Groovy的元编程特性,你可以更加灵活地编写代码,并且可以根据需要动态添加、修改或删除类的属性和方法。例如: +**Groovy支持元编程,这意味着你可以在运行时动态修改类、对象和方法的行为。**通过使用Groovy的元编程特性,你可以更加灵活地编写代码,并且可以根据需要动态添加、修改或删除类的属性和方法。例如: ```groovy class Person { @@ -45,18 +43,6 @@ Person.metaClass.sayHello = { println(person.sayHello()) // 输出: Hello, Alice! ``` -### 处理集合的便捷方法 - -Groovy提供了丰富的集合操作方法,使得处理集合变得更加便捷。它支持链式调用,可以通过一条语句完成多个集合操作,如过滤、映射、排序等。例如: - -```groovy -def numbers = [1, 2, 3, 4, 5] -def result = numbers.findAll { it % 2 == 0 }.collect { it * 2 }.sum() -println(result) -``` - -在这个示例中,我们对列表中的偶数进行过滤、乘以2并求和。 - ### 闭包 闭包是Groovy中一个强大而有用的特性,它可以简化代码并实现更灵活的编程。闭包是一个可以作为参数传递给方法或存储在变量中的代码块。下面是一个使用闭包的示例: @@ -200,6 +186,18 @@ numbers.each { number -> 在这个示例中,我们使用`each`方法和闭包来遍历列表`numbers`中的每个元素,并打印出来。 +#### 处理集合的便捷方法 + +Groovy提供了丰富的集合操作方法,使得处理集合变得更加便捷。它支持链式调用,可以通过一条语句完成多个集合操作,如过滤、映射、排序等。类似Java中的Stream流,例如: + +```groovy +def numbers = [1, 2, 3, 4, 5] +def result = numbers.findAll { it % 2 == 0 }.collect { it * 2 }.sum() +println(result) +``` + +在这个示例中,我们对列表中的偶数进行过滤、乘以2并求和。 + ### 异常处理 在Groovy中,我们可以使用`try-catch`块来捕获和处理异常。下面是一个异常处理的示例: @@ -404,4 +402,6 @@ Spock是一个基于Groovy的测试框架,它结合了JUnit和其他传统测 ## 总结 -Groovy是一种强大的动态编程语言,与Java完美结合,为开发人员提供了更灵活和简洁的语法。它与Java具有紧密的联系,可以无缝地与Java代码互操作。Groovy支持动态类型、闭包、运算符重载等特性,使得代码更易读、简洁。通过使用GroovyShell,您可以在Java项目中动态执行Groovy代码,利用Groovy的动态性和灵活性。 \ No newline at end of file +Groovy是一种强大的动态编程语言,与Java完美结合,为开发人员提供了更灵活和简洁的语法。 + +它与Java具有紧密的联系,可以无缝地与Java代码互操作。Groovy支持动态类型、闭包、运算符重载等特性,使得代码更易读、简洁。通过使用GroovyShell,你可以在Java项目中动态执行Groovy代码,利用Groovy的动态性和灵活性。 diff --git "a/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Scala\345\210\235\345\255\246\350\200\205\346\214\207\345\215\227.md" "b/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Scala\350\257\255\350\250\200\345\205\245\351\227\250\357\274\232\345\210\235\345\255\246\350\200\205\347\232\204\345\237\272\347\241\200\350\257\255\346\263\225\346\214\207\345\215\227.md" similarity index 93% rename from "docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Scala\345\210\235\345\255\246\350\200\205\346\214\207\345\215\227.md" rename to "docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Scala\350\257\255\350\250\200\345\205\245\351\227\250\357\274\232\345\210\235\345\255\246\350\200\205\347\232\204\345\237\272\347\241\200\350\257\255\346\263\225\346\214\207\345\215\227.md" index d9585bf..b4184db 100644 --- "a/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Scala\345\210\235\345\255\246\350\200\205\346\214\207\345\215\227.md" +++ "b/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/Scala\350\257\255\350\250\200\345\205\245\351\227\250\357\274\232\345\210\235\345\255\246\350\200\205\347\232\204\345\237\272\347\241\200\350\257\255\346\263\225\346\214\207\345\215\227.md" @@ -1,48 +1,56 @@ -[TOC] +在计算机编程的世界里,Scala是一个不可或缺的语言。 -因为之后工作会接触到Spark离线任务相关的内容,所以肯定会接触到Scala,谈到大数据技术栈,Scala语言基本绕不开。像Spark。Flink和Kafka等,源码中都能看到Scala的影子。本文旨在对Scala语法进行扫盲,让初学者能够看懂Scala代码,目的就达到了,至于底层原理不会展开叙述。 +作为一种在Java虚拟机(JVM)上运行的静态类型编程语言,Scala结合了面向对象和函数式编程的特性,使它既有强大的表达力又具备优秀的型态控制。 -先分享Scala官方的网站:https://docs.scala-lang.org/ +对于初学者来说,理解Scala的基本语法是掌握这门语言的关键步骤。本文将带领大家逐步了解Scala的基础知识,无论你是编程新手还是想要扩展技能集的专业开发者,都可以在这篇文章中找到有用的信息。 -大部分的学习资料都可以在这找到,语言可以切换为中文,非常友好。 +先分享Scala的官方网站:https://docs.scala-lang.org/。 -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjsgMYsLdO2QfiaJDgmyTxRvox43SR51ggRGN6BiaOhxpKMtRHkcYickiaC6Q/640?wx_fmt=png) +大部分的学习资料都可以在这找到,语言支持切换中文,非常友好。 -另外我们可以使用Scastie在浏览器上直接运行Scala代码进行调试:https://scastie.scala-lang.org/ +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjsgMYsLdO2QfiaJDgmyTxRvox43SR51ggRGN6BiaOhxpKMtRHkcYickiaC6Q/640) -## Scala跟Java的区别和联系 +另外我们可以使用Scastie网站,在浏览器上直接运行Scala代码进行调试:https://scastie.scala-lang.org/。 -Scala语言和Java语言有许多相似之处,但也有一些明显的区别。**Scala语言来源于Java,它以Java的虚拟机(JVM)为运行环境,Scala源码 (.scala)会编译成.class文件。这意味着Scala程序可以与Java程序互操作,并且可以利用JVM的优化和性能**。 +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNbu94YvaQeUktQE3yRnqdzlUKBEq8ichKIIzpxTtv1xew9hxib7Ou5h4aP28noupt5tjhI5wpiaKrwg/640) -在语法上,Scala和Java也有一些区别。例如,在Scala中,一切皆为对象,而在Java中,基本类型、null、静态方法等不是对象。在Scala中,成员变量/属性必须显示初始化,而在Java中可以不初始化。此外,在Scala中,异常处理采用Try-catch {case-case}-finally的方式,而在Java中采用Try-catch-catch-finally的方式。 +## Scala & Java + +Scala语言和Java语言有许多相似之处,但也有一些明显的区别。 + +**Scala语言来源于Java,它以Java虚拟机(JVM)为运行环境,Scala源码 (.scala)会编译成.class文件。这意味着Scala程序可以与Java程序互操作,并且可以利用JVM的优化和性能。** + +在语法上,Scala和Java有一些区别。 + +例如,**在Scala中,一切皆为对象**,而在Java中,基本类型、null、静态方法等不是对象。在Scala中,成员变量/属性必须显示初始化,而在Java中可以不初始化。此外,在Scala中,异常处理采用Try-catch {case-case}-finally的方式,而在Java中采用Try-catch-catch-finally的方式。 Scala还有一些特有的概念,例如:**惰性函数、伴生对象、特质、偏函数**等。这些概念都为Scala语言提供了更多的灵活性和表达能力。使得Scala语言非常适合用来开发大数据处理框架。此外,Scala语言的语法糖也非常甜,可以用更少的代码量来实现相同的功能。 ## Scala安装 -Scala安装很简单。 +先从安装Scala说起,Scala的安装也很简单。 1. 首先Idea安装 Scala插件。 - ![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjsicYaEhvUYNkAdX79icagGAljRPGEmwtSJHGXjE4VgMk5ica0uayhxzK8A/640?wx_fmt=png) + ![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjsicYaEhvUYNkAdX79icagGAljRPGEmwtSJHGXjE4VgMk5ica0uayhxzK8A/640) 2. 项目结构里点击全局库,添加 Scala SDK进行下载。 - ![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjsF2icxJlNs9bvIbxvrq7wvsjQAxYp3kQr2hfdUZQFZL8pWibGs7IA7W2w/640?wx_fmt=png) + ![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjsF2icxJlNs9bvIbxvrq7wvsjQAxYp3kQr2hfdUZQFZL8pWibGs7IA7W2w/640) 3. 右键点击添加到你要使用Scala的项目的项目库,项目的库里就会多出Scala的SDK。 - ![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjsCia8djsBUP8pVvJWykpBtBPOSHwicmxluNWySOYxd0KSo3V8oq34jvicw/640?wx_fmt=png) + ![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjsCia8djsBUP8pVvJWykpBtBPOSHwicmxluNWySOYxd0KSo3V8oq34jvicw/640) 到这就结束了,然后我们就可以在项目里使用Scala了。 -新建一个Scala项目,运行**Hello Wrold**试一下。 +新建一个Scala项目,运行Hello Wrold试一下。 -![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjskHoia03xEOx0BO5X5IBeMeGQ8sRSiaagMABZf394mLkUWdUXSbXicsS9A/640?wx_fmt=png) +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPLktFJAvceDP9EU1YkQdjskHoia03xEOx0BO5X5IBeMeGQ8sRSiaagMABZf394mLkUWdUXSbXicsS9A/640) -## Scala中的数据类型 +## 数据类型 -![img](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNibrCwVLmuEcvHm7rwkFCvgiaWwQTEib93ibwxdsC8Ha727hS6U7osnPDuY5EShDY095mHKRric5SFXAA/640?wx_fmt=png) +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNibrCwVLmuEcvHm7rwkFCvgiaWwQTEib93ibwxdsC8Ha727hS6U7osnPDuY5EShDY095mHKRric5SFXAA/640) Scala中的数据类型可以分为两大类:值类型(`AnyVal`)和引用类型(`AnyRef`)。这两种类型都是 `Any` 类型的子类。 @@ -52,7 +60,7 @@ Scala中的数据类型可以分为两大类:值类型(`AnyVal`)和引用 在Scala的数据类型层级结构的底部,还有两个特殊的数据类型: `Nothing` 和 `Null`。其中, `Nothing` 类型是所有类型的子类型,它没有任何实例。而 `Null` 类型是所有引用类型的子类型,它只有一个实例: `null`。 -## Scala语法 +## 语法 主方法是一个程序的入口点。JVM要求一个名为`main`的主方法,接受一个字符串数组的参数。你可以如下所示来定义一个主方法。 @@ -73,7 +81,7 @@ object Main extends App { 需要注意的是,这种方法在Scala 3中不再推荐使用。它们被新的`@main`方法取代了,这是在Scala 3中生成可以从命令行调用的程序的推荐方法。`App`目前仍以有限的形式存在,但它不支持命令行参数,将来会被弃用。 -### val和var +### val & var 在 Scala 中,`val` 和 `var` 都可以用来定义变量,但它们之间有一些重要的区别。 @@ -104,6 +112,8 @@ var x: Int = 1 + 1 在Scala 中,使用方括号 `[]` 来定义泛型类型。而在Java中是使用`<>`。 +例如,下面这段代码: + ```scala object Main extends App { trait Animal { @@ -192,7 +202,9 @@ import _root_.users._ ### 包对象 -在 Scala 中,包对象(Package Object)是一种特殊的对象,它与包同名,并且可以在包中定义一些公共的成员和方法,供包中的其他类和对象直接使用。包对象可以解决在包级别共享常量、类型别名、隐式转换等问题。在 Scala 中,可以使用 `package` 关键字定义一个包对象。包对象的文件名必须为 `package.scala`,并与包名一致。 +在 Scala 中,包对象(Package Object)是一种特殊的对象,它与包同名,并且可以在包中定义一些公共的成员和方法,供包中的其他类和对象直接使用。包对象可以解决在包级别**共享常量**、**类型别名**、**隐式转换**等问题。 + +在 Scala 中,可以使用 `package` 关键字定义一个包对象。包对象的文件名必须为 `package.scala`,并与包名一致。 下面是关于包对象的解释和示例代码: @@ -231,7 +243,9 @@ object Main { ### 特质 -在Scala中,类是单继承的,但是特质(trait)可以多继承。这意味着,一个类只能继承一个父类,但可以继承多个特质。这样,从结果上看,就实现了多重继承。 +在Scala中,类是单继承的,但是特质(trait)可以多继承。 + +这意味着,一个类只能继承一个父类,但可以继承多个特质。这样,从结果上看,就实现了多重继承。 下面是一个例子: @@ -259,7 +273,7 @@ B 例子中,定义了两个特质 `A` 和 `B`,它们分别有一个方法 `printA` 和 `printB`。然后我们定义了一个类 `C`,它继承了特质 `A` 和 `B`。这样,类 `C` 就可以使用特质 `A` 和 `B` 中定义的方法了。 -特质也可以有默认的实现。 +特质也可以有默认的实现: ```scala trait Greeter { @@ -361,7 +375,7 @@ println(product) // 输出:50 ### 传名参数 -传名参数(Call-by-Name Parameters)是一种特殊的参数传递方式,它允许我们将表达式作为参数传递给函数,并在需要时进行求值。传名参数使用 `=>` 符号来定义,以表示传递的是一个表达式而不是具体的值。下面是关于传名参数的解释和示例代码: +传名参数(Call-by-Name Parameters)是一种特殊的参数传递方式,它允许我们将表达式作为参数传递给函数,并在需要时进行求值。传名参数使用 `=>` 符号来定义,以表示传递的是一个表达式而不是具体的值。 传名参数的特点是,在每次使用参数时都会重新求值表达式,而不是在调用函数时进行求值。这样可以延迟表达式的求值,只在需要时才进行计算。传名参数通常用于需要延迟计算、惰性求值或者需要按需执行的场景。 @@ -380,6 +394,13 @@ def randomNumber(): Int = { } callByName(randomNumber()) + +输出: +Inside callByName +Generating random number +Param 1: 53 +Generating random number +Param 2: 87 ``` 在上述示例中,定义了一个名为 `callByName` 的函数,它接受一个传名参数 `param`。在函数体内,我们打印出两次参数的值。 @@ -390,7 +411,7 @@ callByName(randomNumber()) 当程序执行时,会先打印出 "Inside callByName" 的消息,然后两次调用 `param`,即 `randomNumber()`。在每次调用时,都会重新生成一个新的随机数,并打印出相应的值。 -这说明传名参数在每次使用时都会重新求值表达式,而不是在调用函数时进行求值。这样可以实现按需执行和延迟计算的效果。 +**这说明传名参数在每次使用时都会重新求值表达式,而不是在调用函数时进行求值。这样可以实现按需执行和延迟计算的效果。** ### implicit @@ -425,7 +446,7 @@ foo // 输出 1 在主程序中,我们调用了方法 `foo`,但没有显式地传入参数。由于方法 `foo` 接受一个隐式参数,因此编译器会尝试寻找一个隐式值来作为参数传入。在这个例子中,编译器找到了我们定义的隐式值 `x` 并将其作为参数传入方法 `foo`。 -### Object和Class +### Object & Class 在Scala中,`class` 和 `object` 都可以用来定义类型,但它们之间有一些重要的区别。`class` 定义了一个类,它可以被实例化。每次使用 `new` 关键字创建一个类的实例时,都会创建一个新的对象。 @@ -495,7 +516,9 @@ MyClass.printSecret(a) // 输出 42 ### 样例类 -样例类(case class)是一种特殊的类,**常用于描述不可变的值对象**(Value Object)。它们非常适合用于不可变的数据。定义一个样例类非常简单,只需在类定义前加上`case`关键字即可。例如,下面是一个简单的样例类定义: +样例类(case class)是一种特殊的类,**常用于描述不可变的值对象(Value Object) **。 + +它们非常适合用于不可变的数据。定义一个样例类非常简单,只需在类定义前加上`case`关键字即可。例如,下面是一个简单的样例类定义: ```scala case class Person(var name: String, var age: Int) @@ -834,7 +857,7 @@ Size1: 0 Contains element: false ``` -特别注意:**迭代器是一次性的,所以在使用完毕后就不能再次使用。因此,在上面的代码中,我们在调用 `next` 方法后就不能再使用其他方法来访问迭代器中的元素了。所以 size1输出为0**。 +特别注意:**迭代器是一次性的,所以在使用完毕后就不能再次使用。因此,在上面的代码中,我们在调用 `next` 方法后就不能再使用其他方法来访问迭代器中的元素了。所以 size1输出为0。** ### Tuple @@ -939,7 +962,7 @@ address match { ### 流程判断 -#### while和if +#### while & if ```scala object Main { @@ -1178,7 +1201,7 @@ println(processValue(true)) // 输出: Unknown value 在测试部分,我们调用了`processValue`方法并传入不同类型的值进行测试。根据值的类型,方法将返回相应的描述字符串。 -**Scala的模式匹配是我觉得非常实用和灵活的一个功能,比Java的`switch`语句更加强大和灵活。Scala的模式匹配可以匹配不同类型的值,包括数字、字符串、列表、元组等。而Java的`switch`语句只能匹配整数、枚举和字符串类型的值**。 +**Scala的模式匹配是我觉得非常实用和灵活的一个功能,比Java的`switch`语句更加强大和灵活。Scala的模式匹配可以匹配不同类型的值,包括数字、字符串、列表、元组等。而Java的`switch`语句只能匹配整数、枚举和字符串类型的值。** ##### 密封类 @@ -1447,9 +1470,9 @@ makeSound(cat) // 输出:Animal sound 在 Scala 中,内部类是一个定义在另一个类内部的类。内部类可以访问外部类的成员,并具有更紧密的关联性。下面是一个关于 Scala 中内部类的解释和示例代码: -在 Scala 中,内部类可以分为两种类型:成员内部类(Member Inner Class)和局部内部类(Local Inner Class)。 +在 Scala 中,内部类可以分为两种类型:**成员内部类(Member Inner Class)**和**局部内部类(Local Inner Class)**。 -**成员内部类:** 成员内部类是定义在外部类的作用域内,并可以直接访问外部类的成员(包括私有成员)。成员内部类可以使用外部类的实例来创建和访问。 +**成员内部类**:成员内部类是定义在外部类的作用域内,并可以直接访问外部类的成员(包括私有成员)。成员内部类可以使用外部类的实例来创建和访问。 下面是一个示例代码: @@ -1736,3 +1759,19 @@ println(res) 在主程序中,我们首先打印了一行分隔符。然后我们打印了变量 `res` 的值。由于 `res` 是一个惰性值,因此在打印它之前,函数 `sum` 并没有被执行。只有当我们首次对 `res` 取值时,函数 `sum` 才会被执行。 这就是Scala中惰性函数的基本用法。你可以使用 `lazy` 关键字定义惰性函数,让函数的执行被推迟。 + +## 总结 + +在总结之处,我希望强调Scala的美学和实用性。它是一种同时支持函数式编程和面向对象编程的语言,Scala的语法设计使其对初学者非常友好,同时也为更深入地探索编程提供了空间。 + +学习Scala不仅能够帮助你提高编程效率,还能开阔你的编程视野。当你熟练掌握Scala后,你将发现一个全新的、充满无限可能的编程世界正在向你敞开。今天,我们只是轻轻掀开了Scala的神秘面纱,未来等待你去挖掘的还有更多。 + +请继续探索和尝试,让自己真正理解并掌握Scala的精髓。持续学习,不断思考,享受编程的乐趣。 + +最后,希望这篇文章能给你带来收获和思考。 + + + + + + diff --git "a/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/\350\207\252\347\240\224 DSL \347\245\236\345\231\250\357\274\232\344\270\207\345\255\227\346\213\206\350\247\243 ANTLR 4 \346\240\270\345\277\203\345\216\237\347\220\206\344\270\216\351\253\230\347\272\247\345\272\224\347\224\250.md" "b/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/\350\207\252\347\240\224 DSL \347\245\236\345\231\250\357\274\232\344\270\207\345\255\227\346\213\206\350\247\243 ANTLR 4 \346\240\270\345\277\203\345\216\237\347\220\206\344\270\216\351\253\230\347\272\247\345\272\224\347\224\250.md" new file mode 100644 index 0000000..3afc345 --- /dev/null +++ "b/docs/md/\347\274\226\347\250\213\350\257\255\350\250\200/\350\207\252\347\240\224 DSL \347\245\236\345\231\250\357\274\232\344\270\207\345\255\227\346\213\206\350\247\243 ANTLR 4 \346\240\270\345\277\203\345\216\237\347\220\206\344\270\216\351\253\230\347\272\247\345\272\224\347\224\250.md" @@ -0,0 +1,872 @@ +DSL(领域特定语言) 是一种为解决特定领域的问题而专门设计的计算机语言,它不同于通用编程语言(如 Python、Java)。它通常具有高度定制化的语法和结构,聚焦于某个特定任务或领域(如数据库查询、硬件配置、报表生成),通过提供更简洁、直观且贴近领域术语的表达方式,大幅提升该领域人员的工作效率和生产力,降低复杂性。 + +**通俗来说,DSL 就像是为某个专业领域量身定做的“行话”工具。** + +说到构建自定义 DSL,高效且灵活的语法解析至关重要,**ANTLR 正是解决这一核心挑战的利器。** + +# 简介 + +- 官方地址:https://www.antlr.org/ +- GitHub:https://github.com/antlr/antlr4 +- 在线调试:http://lab.antlr.org/ +- IDEA插件:ANTLR V4 + +ANTLR 4(**AN**other **T**ool for **L**anguage **R**ecognition,版本4)是一个开源的解析器生成器工具,用于构建语言识别程序。它能够根据用户定义的语法规则,自动生成词法分析器(Lexer)和语法分析器(Parser),从而实现对结构化文本(如编程语言、配置文件、数据格式等)的解析、转换或翻译。 + +ANTLR 4 最大的核心价值就是降低语言处理的门槛。在ANTRL 4没有出现之前,语言处理主要依赖正则表达式、手工编写解析器以及早期的解析器生成工具(如Lex/Yacc)。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlUShfOl7emPyR2HvcwwYKDLAWTBTzEUv9pkSKcDmsZGDeK2BhPAhsPA/640?wx_fmt=png&from=appmsg) + +ANTLR 4 的使用很简单,因为其存在的本身的意义就是为了加快语言类应用程序的编写速度,就是为了非专业人员对语言类应用程序快速开发而生的。 + +首先我们要进行ANTLR 4元语言的编写,也就是需要我们根据我们自己的需要来编写一份语法文件,一份后缀为 **.g4** 的文件,这份文件是我们构建ANTLR 4语言类应用程序的基础,目前ANTLR 4已经支持了数十种编程语言的生成,可以满足不同语言的开发需求。 + +官方也提供了相关的文件,GitHub:https://github.com/antlr/grammars-v4。 + +有了这些 Java 文件,语言类应用程序的开发人员就不需要再去思考如何手动编写解析语法树的程序,因为ANTLR 4已经帮我们把这些事情都做了,ANTLR 4自带的jar 包和自动生成的这些语法分析器以及之后所提到的监听器 Listener 和访问器 Visitor 都能够完美的帮我们来处理任何语言类应用程序的自定义需求,从而真正达到即使你没学过编译原理也能自己开发应用程序的效果。 + +ANTLR 是用 Java 编写的,因此你需要首先安装 Java,哪怕你的目标是使用 ANTLR 来生成其他语言(如C#和C++)的解析器。 + +下图是我使用 IDEA 中的 ANTLR 4 插件,以及我自己编写的语法,自动生成的语法解析树,这一切都是ANTLR 4帮我们自动完成的。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlgibU3cuJ4l8JecpiblPtQ5HCFTJSIDicad9RHCM8K9aIia78S39ZJ0tAfg/640?wx_fmt=png&from=appmsg) + + + +简而言之,ANTLR 工具将语法文件转换成可以识别该语法文件所描述的语言的程序。例如,给定一个识别 JSON 的语法,ANTLR工具将会根据该语法生成一个程序,此程序可以通过 ANTLR 运行库来识别输入的 JSON。 + +# 基础概念 + +## 文件声明 + +以下是一个包含完整头部声明的 ANTLR 4 语法文件示例,涵盖所有关键字的解释: + +```java +// =========== ANTLR4 语法文件头部声明示例 =========== +grammar MathParser; // [1] 主声明 + +// [2] 导入声明(组合语法) +import TrigParser, VectorParser; // 导入其他语法模块 + +// [3] 选项配置 +options { + language = Java; // 目标生成语言 + tokenVocab = CoreTokens; // 从外部语法导入词法符号 + superClass = MathBase; // 自定义基类 + contextSuperClass = MyCtx; // 自定义上下文基类 +} + +// [4] 辅助符号声明 +tokens { + // 显式定义新token + PI = 'π'; // 带字面量的token + FUNCTION_CALL, // 无字面量的抽象token + VECTOR_DOT_PRODUCT // 用于语法树节点的标签 +} + +// [5] 头部注入 (生成文件顶部的代码) +@header { + package com.company.math; + import static com.company.math.TrigUtil.*; +} + +// [6] 成员注入 (向解析器类添加代码) +@members { + private boolean debug = true; + private int errorCount = 0; + + @Override + public void reportError(RecognitionException e) { + errorCount++; + super.reportError(e); + } + + public int getErrorCount() { + return errorCount; + } +} + +// [7] 规则定义区 +expression: /* 规则内容 */; +// ======================================== +``` + +- **grammar**:定义语法名称(必须匹配文件名),声明完整/词法/解析语法类型。 +- **import**:导入外部语法文件实现规则复用,支持模块化开发。语法导入允许你将语法分解成可复用的逻辑单元。ANTLR 处理被导入的语法的方式和面向对象语言中的父类非常相似。一个语法会从其导入的语法中继承所有的规则、词法符号声明和具名的动作。位于“主语法”中的规则将会覆盖其导入的语法中的规则,以此来实现继承机制。ANTLR将被导入的规则放置在主语法的词法规则列表末尾。这意味着,主语法中的词法规则具有比被导入语法中的规则更高的优先级。 +- **options**:配置代码生成选项(目标语言/基类/符号表等)。 +- **tokens**:声明辅助符号(抽象Token/别名/语法树标签)。tokens 区域存在的意义在于,它定义了一份语法所需,但却未在本语法中列出对应规则的词法符号。大多数情况下,tokens 区域用于定义本语法中动作所需的词法符号类型。 +- **@header**:向生成文件顶部注入代码(包声明/导入语句)。用于将代码注入生成的识别类中的类声明之前。用于将代码注入为识别类的字段和方法。 +- **@members**:向解析器类添加自定义成员(字段/方法/状态管理)。 + +关于 @header 和 @members,其中 @header 用于当 ANTLR 4 工具生成词法分析器和语法分析器时,将 @header 中的内容原封不动的复制到生成的 Java 文件的顶部,而 @members 用于将代码插入到生成的 Java 类当中,其中可以包含字段声明,自定义方法等内容。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlib6guJtUic38icNMVT7yAq9ia9Ao64X14cY4nZiayEVBmRLl7NNPPQtfYUg/640?wx_fmt=png&from=appmsg) + +从图中我们可以看到我们预先在语法文件中进行了 @header 和 @members 的定义和编写,然后利用 ANTLR 4 工具自动生成我们所需要的词法解析器和语法分析器等相关的 Java 文件,后续生成的这些 Java 文件中的相关位置包含了我们在 @header 和 @members 中所定义的相关内容。 + +不带前缀的语法声明是混合语法,可以同时包含词法规则和语法规则。欲创建一份只允许语法规则出现的文件,使用如下声明: + +```java +parser grammar Name; +``` + +同理,纯词法的文件如下所示: + +```java +lexer grammar Name; +``` + +## 词法规则 + +词法文件的规则以大写字母开头。 + +将字符聚集为单词或者符号(词法符号,token)的过程称为词法分析(lexicalanalysis)或者词法符号化(tokenizing)。我们把可以将输入文本转换为词法符号的程序称为词法分析器(lexer)。词法分析器可以将相关的词法符号归类,例如INT(整数)、ID(标识符)、FLOAT(浮点数)等。当语法分析器不关心单个符号,而仅关心符号的类型时,词法分析器就需要将词汇符号归类。词法符号包含至少两部分信息:词法符号的类型(从而能够通过类型来识别词法结构)和该词法符号对应的文本。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuVPVRGXctAoX0Rg9OYEfaEPBj3MZVibyFASBuYSs9gNQyAmKSYO4Pn1Q/640?wx_fmt=png&from=appmsg) + +Java 词法规则示例: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuuLDjMLFnZfHULKvmCO4vvrtX9vEnCdpfg4rib8XQBicEFpyE1dH74wMg/640?wx_fmt=png&from=appmsg) + +接下来介绍一下词法规则是如何编写的。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlDN5xdXc5l19uEovB5EupSvFQeiaK62MvaTxMcRfZiaqacvibVoklY0AMA/640?wx_fmt=png&from=appmsg) + +如上图所示词法规则以大写的字母开头,或者以冒号开头后跟大写字母,这样做是为了与之后所要介绍的语法规则做区分。例如上图中我们就给出了一些示例的规则,定义了INT,ID,STRING类型的词法单元,冒号后面是对这些词法单元的描述。 + +这种词法规则的类型被称之为标准词法符号类型,这一类词法规则必须用大写字母开头,经过ANTLR 4工具处理会生成可直接在解析器中引用的符号,其规则匹配的优先级由在语法文件中声明词法规则的顺序和词法规则的长度来决定。 + +其中有很多符号,比如“+”代表着 INTEGER 这一词法规则使用出现至少一次的自然数组成的,而 IDENTIFIER 这一规则中的“*”则代表着 IDENTIFIER 这一词法规则是由大小写字母或下划线加上至少出现0次的单词字符组成的。而 STRING 词法规则中单引号中间的内容则代表着中间的内容直接匹配,是固定的。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlfQmE7UfzQdnygUevQichUk0pz1WicrtzBsWJicw594RlNKODV0KbLib7nw/640?wx_fmt=png&from=appmsg) + +第二类词法规则被称之为片段规则,通过关键字 **fragment** 来定义。 + +片段规则具有以下特点:首先片段规则是不能独立匹配的,fragment 规则不能直接用于匹配输入文本。它们只能被其他非片段的词法规则所引用。 + +将一条规则声明为 fragment 可以告诉 ANTLR,该规则本身不是一个词法符号,它只会被其他的词法规则使用。这意味着我们不能在文法规则中引用 HEX_DIGIT。 + +通常使用片段规则是为了提高可读性和重用性,通过将常用的字符模式提取为片段规则,可以使词法规则更加简洁和易于维护。例如,可以将字母或数字的模式定义为片段规则,然后在多个词法规则中引用它们。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlSXLNTPKsS0se2YZFjYSiaibLMeDwmt0bODXODQG3bEuSCDOhQibBSc3bA/640?wx_fmt=png&from=appmsg) + +第三类词法规则被称之为**指令规则**。 + +- 第一种被称之为跳过指令,ANTLR 4在词法分析过程中会忽略这些匹配的空白字符,不会将它们作为(token)传递给语法分析器; +- 第二种被称之为通道指令,使用 -> channel(HIDDEN) 指令,ANTLR 将这些注释标记发送到一个隐藏通道,使得它们不会被默认的语法分析器处理,但仍然可以在需要时访问; +- 第三种被称之为模式指令,使用 -> pushMode(XML_MODE) 指令,ANTLR 会切换到 XML_MODE 模式,这允许在不同的上下文中使用不同的词法规则集; +- 最后一种被称之为类型指令,使用 -> type(DOLLAR_SIGN) 指令,ANTLR 会将匹配的标记类型动态设置为 DOLLAR_SIGN,这可以用于在语法分析中对不同类型的标记进行区分和处理。 + +## 语法规则 + +语法文件的规则以小写字母开头。 + +首先我们来介绍语法规则的规则组成元素。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlDj1q0xDFrfib3ib2L0L96KXVoKYk1ib7oVTkAdSJ6JBaB5vBbYNTT3DtA/640?wx_fmt=png&from=appmsg) + +以上名为 assignment 的语法规则中所包含的大写字母序列 IDENTIFIER 被称之终结符,它来自词法分析器,我们在词法规则中会对其进行定义。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAliaTB1XM3vgy6o36ics5fmgRBhThJ6dP5c8zXvdNV9pIlE8tZMLYNcRQA/640?wx_fmt=png&from=appmsg) + +与此相对的是非终结符,比如以上 expression 语法规则中的 term,这些非终结符,由小写字母命名,并且由其他规则所定义。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAlnXGaWTEibwzujpW5YibicudHXd51Ujq7Or0sGU7TJUu2RCOF2y9ehQXEQ/640?wx_fmt=png&from=appmsg) + +除了之前介绍的终结符和非终结符两种元素之外,还有带参数的规则和带返回值的规则。因此,参数和返回值也是语法规则的重要元素。 + +[String className],表示这个规则接受一个参数 className,类型为 String。在解析过程中,可以将外部传入的类名用于匹配。[Object value],表示这个规则在匹配成功后会返回一个 Object 类型的值,存储在 value 中。 + +ANTLR 4的语法规则的核心语法构造分为四种模式,分别是序列模式、选择模式、分组模式、循环模式。 + +**序列模式** + +```java +sqlSelect : SELECT column FROM table WHERE condition; +``` + +元素必须严格按顺序出现(如 SQL 语句结构)。 + +**选择模式** + +```java +dataType : INT | STRING | BOOL; +``` + +多选一匹配(如数据类型只能为三者之一)。 + +**分组模式** + +```java +functionCall : ID '(' (arg (',' arg)*)? ')'; +``` + +括号强制组合子规则(如函数参数列表的逗号分隔结构)。 + +**循环模式** + +```java +emailList : address (',' address)+; +``` + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAl7745lpeR6Kuw7fuK4EjNCgQO0hfNUu2fEC1AGQvusGcbicmzMwDseYQ/640?wx_fmt=png&from=appmsg) + +后缀运算符控制重复次数(如至少一个邮箱地址的逗号分隔列表)。 + +### 规则标签 + +在 ANTLR 4 中,规则标签(Rule Labels)是提升语法可读性、精确控制解析树生成的关键机制,我们可以使用 # 给最外层的备选分支添加标签,以获得更加精确的语法分析器监听器事件。一条规则中的备选分支要么全部带上标签,要么全部不带标签。标签主要有两种应用形式: + +------ + +**分支备选标签(Alternative Labels)** + +在规则的选择分支(`|`)中标注备选项: + +```java +expression + : left=expr '+' right=expr # AddExpr // # 定义标签 + | left=expr '*' right=expr # MulExpr + | NUMBER # NumLiteral + ; +``` + +**作用**: + +> 为每个分支生成独立的上下文类(如`AddExprContext`),在监听器/访问器中提供类型精确的访问方法 + +**生成代码优势**: + +``` +// 自动生成精确的进入/退出方法 +@Override +public void enterAddExpr(MyParser.AddExprContext ctx) { + // 直接访问带标签的元素 + ExprContext left = ctx.left; // 无需遍历子节点 + ExprContext right = ctx.right; +} +``` + +------ + +**元素标签(Element Labels)** + +在规则中标记特定子元素: + +``` +funcCall : func=ID '(' args+=expr (',' args+=expr)* ')'; +``` + +**三种标记方式**: + +| 标签语法 | 适用对象 | 返回值类型 | 访问示例 | +| :--------------: | :------: | :---------------: | :------------------------------: | +| `label=TOKEN` | 词法符号 | `TerminalNode` | `ctx.ID().getText()` | +| `label=rule` | 规则引用 | `RuleContext`子类 | `ctx.expr().value` | +| `labelList+=...` | 重复元素 | `List` | `for (exprContext e : ctx.args)` | + +**实战应用场景** + +- 场景1:四则运算精确解析 + +```java +expr + : left=expr op=('*'|'/') right=expr # MulDiv + | left=expr op=('+'|'-') right=expr # AddSub + | NUM # Number + | '(' expr ')' # Parens + ; +``` + +**生成的监听器接口**: + +```java +void enterMulDiv(ExprParser.MulDivContext ctx); +void enterAddSub(ExprParser.AddSubContext ctx); +void exitMulDiv(ExprParser.MulDivContext ctx); +// ... +``` + +- 场景2:函数调用语义分析 + +```java +functionCall + : func=ID '(' + (firstArg=expr (',' otherArgs+=expr)*)? + ')' # FuncCall + ; +``` + +**在访问器中直接获取元素**: + +```java +public Object visitFuncCall(FuncCallContext ctx) { + String funcName = ctx.func.getText(); + List args = new ArrayList<>(); + if(ctx.firstArg != null) { + args.add(ctx.firstArg); + args.addAll(ctx.otherArgs); + } + // ...处理函数调用 +} +``` + +# TokenStream + +词法分析器处理字符序列并将生成的词法符号提供给语法分析器,语法分析器随即根据这些信息来检查语法的正确性并建造出一棵语法分析树。这个过程对应的ANTLR 类是 CharStream、Lexer、Token、Parser,以及 ParseTree。连接词法分析器和语法分析器的“管道”就是 TokenStream。下图展示了这些类型的对象在内存中的交互方式。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5faUDyibyvydyIO119zMK7skMfBVUW6KAIonmMuzPMfO7wepPGfD4s1fw/640?wx_fmt=png&from=appmsg) + +ParseTree 的子类 RuleNode 和 TerminalNode ,二者分别是子树的根节点和叶子节点。RuleNode 有一些令人熟悉的方法,例如 getChild() 和 getParent() ,但是,对于一个特定的语法,RuleNode 并不是确定不变的。为了更好地支持对特定节点的元素的访问,ANTLR 会为每条规则生成一个 RuleNode 的子类。如下图所示,在我们的赋值语句的例子中,子树根节点的类型实际上是:StatContext、AssignContext 以及 ExprContext。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5fJPwSLxSkfJy8wg2g6dNBFQTCAYkroBicHV7PVGc5UR1eWo2TicibTTO5g/640?wx_fmt=png&from=appmsg) + +因为这些根节点包含了使用规则识别词组过程中的全部信息,它们被称为上下文(context)对象。每个上下文对象都知道自己识别出的词组中,开始和结束位置处的词法符号,同时提供访问该词组全部元素的途径。例如,AssignContext 类提供了方法 ID() 和方法 expr() 来访问标识符节点和代表表达式的子树。 + +# 监听器和访问器 + +ANTLR 的运行库提供了两种遍历树的机制。默认情况下,ANTLR 使用内建的遍历器访问生成的语法分析树,并为每个遍历时可能触发的事件生成一个语法分析树监听器接口(parse-tree listener interface)。监听器非常类似于 XML 解析器生成的 SAX 文档对象。SAX 监听器接收类似 startDocument() 和 endDocument() 的事件通知。一个监听器的方法实际上就是回调函数,正如我们在图形界面程序中响应复选框点击事件一样。除了监听器的方式,我们还将介绍另外一种遍历语法分析树的方式:访问者模式(vistor pattern)。 + +## 监听器 + +为了将遍历树时触发的事件转化为监听器的调用,ANTLR 运行库提供了 ParseTreeWalker 类。我们可以自行实现 ParseTreeListener 接口,在其中填充自己的逻辑代码(通常是调用程序的其他部分),从而构建出我们自己的语言类应用程序。ANTLR 为每个语法文件生成一个 ParseTreeListener 的子类,在该类中,语法中的每条规则都有对应的 enter 方法和 exit 方法。例如,当遍历器访问到 assign 规则对应的节点时,它就会调用 enterAssign() 方法,然后将对应的语法分析树节点——AssignContext 的实例——当作参数传递给它。在遍历器访问了 assign 节点的全部子节点之后,它会调用 exitAssign() 。下图用粗虚线标识了 ParseTreeWalker对语法分析树进行深度优先遍历的过程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5fGlEhwmC07TibicibWVXgBAGZ5bMyC2WtRc3ArQI9ZmlHEgwPquCYxibmicA/640?wx_fmt=png&from=appmsg) + +下图显示了在我们的赋值语句生成的语法分析树中,ParseTreeWalker 对监听器方法的完整的调用顺序。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5fMKFZmIOFCI0fuVickY6icEyCmKfEPWmkQIKXViaETAz1rviaLgD6srKIDg/640?wx_fmt=png&from=appmsg) + +监听器机制的优秀之处在于,这一切都是自动进行的。我们不需要编写对语法分析树的遍历代码,也不需要让我们的监听器显式地访问子节点。 + +## 访问器 + +有时候,我们希望控制遍历语法分析树的过程,通过显式的方法调用来访问子节点。下图是是使用常见的访问者模式对我们的语法分析树进行操作的过程。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5fnFo6m8UQCFDULbPds1MCVTWVXCaWPaticRibxFWOEq5cC1ykHgUjUAcA/640?wx_fmt=png&from=appmsg) + +其中,粗虚线显示了对语法分析树进行深度优先遍历的过程。细虚线标示出访问器方法的调用顺序。我们可以在自己的程序代码中实现这个访问器接口,然后调用visit() 方法来开始对语法分析树的一次遍历。 + +```java +ParseTree tree=...; // tree是语法分析得到的结果 +MyVisitor v = new MyVisitor(); +v.visit(tree); +``` + +ANTLR 内部为访问者模式提供的支持代码会在根节点处调用 visitStat() 方法。接下来,visitStat() 方法的实现将会调用 visit() 方法,并将所有子节点当作参数传递给它,从而继续遍历的过程。或者,visitMethod() 方法可以显式调用 visitAssign() 方法等。ANTLR会提供访问器接口和一个默认实现类,免去我们一切都要自行实现的麻烦。这样,我们就可以专注于那些我们感兴趣的方法,而无须覆盖接口中的方法。 + +同时访问者机制支持泛型返回值,可以实现数据聚合。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNFp93h7jYGKGDcTibRGQpAl0lxNWsgnPSKVMnnHHdmVjngyv2ozxmBaFNXGGdmIxdjPt9jtNZG5zw/640?wx_fmt=png&from=appmsg) + +**访问器机制和监听器机制的最大的区别在于,监听器的方法会被 ANTLR 提供的遍历器对象自动调用,而在访问器的方法中,必须显式调用 visit 方法来访问子节点。忘记调用visit() 的后果就是对应的子树将不会被访问。** + +# 语义判定 + +语义判定(Semantic Predicates)允许在语法规则中嵌入布尔表达式,从而在运行时动态控制解析过程。这使得 ANTLR4 能够处理上下文相关的语法结构。 + +基本语法: + +``` +ruleName + : {布尔表达式}? 规则元素 // 验证型判定 + | {布尔表达式}?=> 规则元素 // 门控型判定 + ; +``` + +## 判定类型 + +**验证型判定** + +- 语法:`{布尔表达式}?` +- 行为: + - 尝试匹配规则元素 + - 如果匹配成功,评估布尔表达式 + - 如果表达式为 `false`,放弃当前分支并尝试其他备选分支 + +``` +expr + : {isType("int")}? ID // 只有当 isType("int") 为 true 时才匹配 + | INT + ; +``` + +**门控型判定** + +- 语法:`{布尔表达式}?=>` +- 行为: + - 在尝试匹配规则元素前评估布尔表达式 + - 如果表达式为 `false`,立即放弃整个分支 + - 不会尝试匹配规则元素 + +``` +statement + : {inLoop()}?=> 'break' ';' // 只有在循环中才允许 break + | 'continue' ';' + ; +``` + +## 实现机制 + +**在语法文件中声明**: + +```java +grammar ContextSensitive; + +@parser::members { + private SymbolTable symbolTable = new SymbolTable(); + + private boolean isType(String id) { + return symbolTable.isType(id); + } +} + +expr + : {isType($ID.text)}? ID // 使用语义判定 + | INT + ; +``` + +ANTLR 会将语义判定转换为解析器代码: + +```java +public class ContextSensitiveParser extends Parser { + // ... + + public final ExprContext expr() { + // 尝试第一个备选分支 + if (isType(input.LT(1).getText())) { + // 创建上下文对象 + // 匹配 ID + } + // 否则尝试第二个分支 + else { + // 匹配 INT + } + } +} +``` + +# Channel + +在 ANTLR 4 中,通道(channels)是一种强大的机制,用于将词法标记(tokens)分类处理。ANTLR 4 有两个预定义通道: + +- 默认通道 (Token.DEFAULT_CHANNEL),通道号: 0,包含所有需要被解析器处理的标记。 +- 隐藏通道 (Token.HIDDEN_CHANNEL),通道号: 1,包含所有不需要被解析器直接处理的标记。 + +**通道与 skip 的区别** + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScN4J4O1ibCDpu5gM5zAXibE5f2ibRwteApMbic0uAS1sZGmgE4bq3zR6Vw033Rorib0tArFPaIbYzeg3dw/640?wx_fmt=png&from=appmsg) + +**自定义通道** + +``` +// ===== 1. 声明通道 ===== +channels { + ERROR_CHANNEL, // 自定义错误信息通道 + HIDDEN_COMMENTS // 隐藏注释通道 +} + +// ===== 2. 将词法规则定向到通道 ===== +ERROR_TOKEN : '' -> channel(ERROR_CHANNEL); // 捕获错误标记 +LINE_COMMENT : '//' ~[\r\n]* -> channel(HIDDEN_COMMENTS); // 隐藏注释 +BLOCK_COMMENT : '/*' .*? '*/' -> channel(HIDDEN_COMMENTS); + +// ===== 3. 保留传统空白符处理 ===== +WS : [ \t\r\n]+ -> skip; // 完全跳过空白符 +``` + +ANTLR 4 通过 `channels{}` 声明自定义通道,并用 `-> channel(NAME) `将词法规则输出定向到指定通道,保留但隔离特殊内容。 + +# 嵌入动作 + +ANTLR 的嵌入动作(Embedded Actions)是在语法规则中**直接插入目标语言代码**的机制,它允许开发者在解析过程的关键节点执行自定义逻辑。 + +``` +语法规则 { 代码块 } +``` + +ANTLR 在解析时会在对应位置**实时执行这些代码** + +**执行时机** + +1. **元素匹配前**:`{代码} 规则元素` +2. **元素匹配后**:`规则元素 {代码}` +3. **规则匹配完成**:`规则元素 @after {代码}` + +------ + +**动作类型与代码示例** + +- **简单打印动作**(调试追踪) + +``` +expression + : left=expression '+' { System.out.println("检测到加号"); } + right=expression + { System.out.println("完成加法: "+$left.value+"+"+$right.value); } + ; +``` + +**输出示例**: + +``` +检测到加号 +完成加法: 5+3 +``` + +- **条件拦截动作**(语义检查) + +``` +vectorOperation + : ID '=' (vec1=vector '×' vec2=vector + { + if($vec1.dimension != $vec2.dimension) + throw new RuntimeException("维度不匹配"); + }) + { System.out.println("叉积运算完成"); } + ; +``` + +- **动态计算动作**(属性传递) + +``` +number returns [int value] + : digits=INT { $value = Integer.parseInt($digits.text); } + | hex='0x' hexDigits=HEX + { $value = Integer.parseInt($hexDigits.text,16); } + ; +``` + +- **集合构造动作**(数据聚合) + +``` +jsonArray returns [List list = new ArrayList<>()] + : '[' + (first=jsonValue { $list.add(first); } + (',' next=jsonValue { $list.add(next); })* + )? ']' + ; +``` + +- **符号表管理动作**(语义分析) + +``` +variableDecl + : type ID + { + Symbol sym = new Symbol($ID.text, $type.text); + currentScope.addSymbol(sym); + } + '=' expr ';' + ; +``` + +- **自动代码生成**(DSL编译) + +``` +sqlSelect + : 'SELECT' columns+=column (',' columns+=column)* + { out.write("SELECT " + $columns.get(0).text); + for(int i=1; i<$columns.size(); i++) { + out.write("," + $columns.get(i).text); + } + } + 'FROM' table=ID + { out.write(" FROM " + $table.text); } + ; +``` + +注意:动作会使语法与目标语言耦合,优先使用监听器/访问器模式,避免过度使用。 + +# 处理优先级、左递归和结合性 + +在自顶向下的语法和手工编写的递归下降语法分析器中,处理表达式都是一件相当棘手的事情,这首先是因为大多数语法都存在歧义,其次是因为大多数语言的规范使用了一种特殊的递归方式,称为左递归(left recursion)。 + +自顶向下的语法和语法分析器的经典形式无法处理左递归。为了阐明这个问题,假设有一种简单的算术表达式语言,它包含乘法和加法运算符,以及整数因子。表达式是自相似的,所以,很自然地,我们说,一个乘法表达式是由*连接的两个子表达式,一个加法表达式是由+连接的两个子表达式。另外单个整数也可以作为简单的表达式。这样写出的就是下列看上去非常合理的规则: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmu7eltp0aeSUhEKwJRpuwKZ85ppFQZVnExu8fEiak6k3Leicw25YDOZbiaQ/640?wx_fmt=png&from=appmsg) + +问题在于,对于某些输入文本而言,上面的规则存在歧义。换句话说,这条规则可以用不止一种方式匹配某种输入的字符流,这个语法在简单的整数表达式和单运算符表达式上工作得很好——例如1+2和1*2——是因为只存在一种方式去匹配它们。对于1+2,上述语法只能用第二个备选分支去匹配,如下图左侧的语法分析树所示。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuAvL2y4L0RTiblKJTfR5smoKbdn3VequOa1fjAr3ljSiaOaxaeCyU8Fqw/640?wx_fmt=png&from=appmsg) + +但是对于 1+2*3 这样的输入而言,上述规则能够用两种方式解释它,如上图中间和右侧的语法分析树所示。它们的差异在于,中间的语法分析树表示将1加到2和3相乘的结果上去,而右侧的语法分析树表示将1和2相加的结果与3相乘。这就是运算符优先级带来的问题,传统的语法无法指定优先级。大多数语法工具,例如Bison,使用额外的标记来指定运算符优先级。 + +与之不同的是,**ANTLR 通过优先选择位置靠前的备选分支来解决歧义问题**,这隐式地允许我们指定运算符优先级。例如,expr 规则中,乘法规则在加法规则之前,所以ANTLR在解决歧义问题时会优先处理乘法。默认情况下,ANTLR按照我们通常对*和+的理解,将运算符从左向右地进行结合。尽管如此,一些运算符——例指数运算符——是从右向左结合的,所以我们需要在这样的运算符上使用 assoc 选项手工指定结合性。这样,输入的 2^3^4 就能够被正确解释为2^(3^4): + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuGdu9n1mtfwrf6oic05n3xIesZ33dl0y4tIDl1OKaMYRZvtPDvh1ppBg/640?wx_fmt=png&from=appmsg) + +注:在ANTLR 4.2之后, 需要被放到备选分支的最左侧,否则会收到警告。在本例中,正确写法是: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmu32RZeknL2e40xq5gADxJng1mtAylovTiaCTN3QcbbMCt1UqXAabiaHuw/640?wx_fmt=png&from=appmsg) + +如下图所示的语法分析树展示了^符号的左结合版本和右结合版本在处理相同输入时的差异。通常人们采用右侧语法分析树所代表的解释方式,不过,语言设计者可以自由地决定使用哪一种结合性。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmu1DpDicym2eEg1u6iaDrDHaOVSMYVM2w2tJgdv4c0xzmbqyGK3RdAgNEA/640?wx_fmt=png&from=appmsg) + +若要将上述三种运算符组合成为同一条规则,我们就必须把^放在最前面,因为它的优先级比*和+都要高(1+2^3的结果是9)。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuOmqYRmFntAw86ibOJFTmPCBsjm882HREjiakEwjLxHtI4qFnDGicmWK1w/640?wx_fmt=png&from=appmsg) + +ANTLR 4的一项重大改进就是,它已经可以处理直接左递归了。左递归规则是这样的一种规则:在某个备选分支的最左侧以直接或者间接方式调用了自身。上面的例子中的expr规则是直接左递归的,因为除INT之外的所有备选分支都以expr规则本身开头(它同时也是右递归(rightrecursive)的,因为它的某些备选分支在最右侧引用了expr)。虽然ANTLR 4已经能够处理直接左递归,但是它还无法处理间接左递归。这意味着我们无法将expr规则分解为下列规则,尽管它们在语义上等价: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMUGlpbOHNGFoD4xwJ7lxmuwF6yYa3xcPDnU9EeTPYuJpCGTFYpEBD88vAPibFdI2C8AibEnibZ7eziaw/640?wx_fmt=png&from=appmsg) + +# 非贪婪匹配 + +在 ANTLR 中,**非贪婪匹配(Non-Greedy Matching)** 是处理文本模式的特殊策略,它会尽可能少地匹配字符(即采用"最小匹配"原则)。这与默认的贪婪匹配(尽可能多匹配)形成对比,是解决词法歧义的关键技术。 + +**贪婪匹配(默认行为)** + +``` +STRING : '"' .* '"'; // 匹配从第一个"到最后一个" +``` + +**非贪婪匹配** + +``` +STRING_LAZY : '"' .*? '"'; // ? 启用非贪婪 +``` + +**通配符模式说明**: + +| 模式 | 符号 | 匹配策略 | +| :----: | :---: | :----------: | +| 贪婪 | `.*` | 最长可能匹配 | +| 非贪婪 | `.*?` | 最短可能匹配 | + +**实战应用场景** + +- 场景1:注释匹配 + +``` +// 错误:贪婪匹配会吃光所有内容 +DOC_COMMENT : '/*' .* '*/'; + +// 正确:非贪婪只匹配最近的一对 +DOC_COMMENT_LAZY : '/*' .*? '*/'; +``` + +- 场景2:模板字符串 + +``` +TEMPLATE : '`' ('\\`' | .)*? '`'; +``` + +正确处理带转义符的模板: + +- 场景3:XML标签内联 + +``` +TAG_CONTENT : '<' .*? '>'; +``` + +# 辅助类 + +## ParseTreeProperty + +`ParseTreeProperty` 是 ANTLR 4 中一个强大的辅助类,用于将自定义数据与解析树(Parse Tree)中的节点关联起来。它是实现属性文法(Attribute Grammar)的核心工具,特别适用于需要在语法分析过程中计算和传递属性的场景。 + +`ParseTreeProperty` 主要用于解决以下问题: + +1. **存储节点相关数据**:为每个解析树节点关联自定义属性 +2. **实现属性传递**:在树遍历过程中收集和传递上下文信息 +3. **实现代码生成**:保存每个节点的代码生成结果 +4. **类型检查**:记录表达式的类型信息 +5. **符号表关联**:将作用域和符号表与语法结构关联 + +```java +/ 1. 创建数据容器 +ParseTreeProperty dataMap = new ParseTreeProperty<>(); + +// 2. 向节点注入数据(通常在监听器/访问器中) +@Override +public void exitAddExpr(CalcParser.AddExprContext ctx) { + int left = dataMap.get(ctx.left); // 取左子树数据 + int right = dataMap.get(ctx.right); + int result = left + right; + dataMap.put(ctx, result); // 当前节点存储计算结果 +} + +// 3. 从根节点获取最终结果 +public int getResult(ParseTree tree) { + return dataMap.get(tree); // 返回根节点存储的计算结果 +} +``` + +## TokenStreamRewriter + +`TokenStreamRewriter` 是 ANTLR4 中一个强大的工具类,用于在不修改原始令牌流的情况下,对令牌流进行非破坏性编辑。它特别适用于源代码转换、重构和代码生成等场景。 + +其中的关键之处在于,TokenStreamRewriter 对象实际上修改的是词法符号流的“视图”而非词法符号流本身。它认为所有对修改方法的调用都只是一个“指令”,然后将这些修改放入一个队列;在未来词法符号流被重新渲染为文本时,这些修改才会被执行。在每次我们调用 getText() 的时候,rewriter 对象都会执行上述队列中的指令。 + +**简单使用示例**:在方法调用前插入日志 + +```java +public class RewriterExample { + public static void main(String[] args) { + // 1. 创建输入流 + String input = "public class Test {\n" + + " public void method() {\n" + + " System.out.println(\"Hello\");\n" + + " }\n" + + "}"; + CharStream charStream = CharStreams.fromString(input); + + // 2. 创建词法分析器和令牌流 + JavaLexer lexer = new JavaLexer(charStream); + CommonTokenStream tokens = new CommonTokenStream(lexer); + + // 3. 创建重写器 + TokenStreamRewriter rewriter = new TokenStreamRewriter(tokens); + + // 4. 创建解析器 + JavaParser parser = new JavaParser(tokens); + ParseTree tree = parser.compilationUnit(); + + // 5. 遍历解析树并修改 + ParseTreeWalker walker = new ParseTreeWalker(); + walker.walk(new InsertLogListener(rewriter), tree); + + // 6. 获取修改后的文本 + System.out.println(rewriter.getText()); + } + + static class InsertLogListener extends JavaBaseListener { + private final TokenStreamRewriter rewriter; + + public InsertLogListener(TokenStreamRewriter rewriter) { + this.rewriter = rewriter; + } + + @Override + public void enterMethodCall(JavaParser.MethodCallContext ctx) { + // 获取方法名令牌 + Token methodNameToken = ctx.Identifier().getSymbol(); + + // 在方法调用前插入日志语句 + String logStmt = "\n System.out.println(\"Calling method: " + + methodNameToken.getText() + "\");"; + + rewriter.insertBefore(methodNameToken.getTokenIndex(), logStmt); + } + } +} +``` + +**输出结果**: + +```java +public class Test { + public void method() { + System.out.println("Calling method: println"); + System.out.println("Hello"); + } +} +``` + +# 错误报告与恢复 + +ANTLR 的错误报告与恢复机制是其生成健壮解析器的核心,它通过智能的错误检测、精确报告及自动恢复策略,确保即使面对非法输入也能进行结构化处理而非直接崩溃。 + +对于词法错误和语法错误,ANTLR 4 会定位错误的起始位置,向后删除字符直到发现合法的 token 边界,然后就会接着解析后续输入。 + +``` +// 自动生成详细的错误诊断 +line 5:8 missing '}' at '{' +line 10:22 mismatched input ';' expecting ',' +``` + +- **信息结构**: + + ``` + 位置: 行号:列号 + 类型: [missing|mismatched|extraneous] + 详情: 期望内容/实际内容 + ``` + +**自定义错误处理器** + +重写 `BaseErrorListener`: + +```java +public class VerboseListener extends BaseErrorListener { + @Override + public void syntaxError(Recognizer recognizer, + Object offendingSymbol, + int line, int charPos, + String msg, RecognitionException e) { + // 生成更友好的错误提示 + String error = String.format("[CUSTOM] Line %d:%d - %s", line, charPos, msg); + System.err.println(error); + } +} +// 注册自定义监听器 +parser.removeErrorListeners(); +parser.addErrorListener(new VerboseListener()); +``` + +# 性能优化 + +## 提高语法分析器的速度 + +ANTLR 4 的自适应语法分析策略功能比 ANTLR 3 更加强大,不过这是以少量的性能损失为代价的。如果你需要尽可能快的速度和尽可能少的内存占用,你可以使用两步语法分析策略。第一步使用功能稍弱的语法分析策略——SLL——在大多数情况下它已经足够了(它和ANTLR 3的策略相似,只是不需要回溯)。如果第一步的语法分析失败,那么就必须使用全功能的 LL 语法分析。这是因为,在第一步失败后,我们无法知道原因究竟是真正的语法错误,还是 SLL 的功能不够强大 + +由于能够通过 SLL 的输入一定能够通过全功能的 LL,所以一旦第一步成功,就无须使用更昂贵的策略。 + +```java + try { + parser.compilationUnit(); + //如果抵达此处,证明没有语法错误,SLL(*)就够了 + //无需使用全功能的LL(*) + } catch (RuntimeException ex) { + if (ex.getClass() == RuntimeException.class && + ex.getCause() instanceof RecognitionException) { + //BailErrorStrategy会将RecognitionExceptions封装在 + // RuntimeException中,所以这里需要检查是不是 + //一个真正的RecognitionException + tokenStream.reset();//回滚输入流 + //重新使用标准的错误监听器和错误处理器 + parser.addErrorListener(ConsoleErrorListener.INSTANCE); + parser.setErrorHandler(new DefaultErrorStrategy()); + parser.getInterpreter().setPredictionMode(PredictionMode.SLL); + parser.compilationUnit(); + + + parser.addErrorListener(new SyntaxErrorListener()); + ParseTree tree = parser.compilationUnit(); + // 使用访问器转换DSL + Map externalVarMaps = new HashMap<>(); + externalVarMaps.put("features", Sets.newHashSet("test_tz_string_auto_test", "test_feature_999", "sys_attr5")); + ParentVisitor visitor = new ParentVisitor(123L, tokenStream, parser, externalVarMaps); + String dsl = visitor.visit(tree); + log.info("Generated DSL:\n{}", dsl); + } + + } +``` + +如果第二步失败,那就意味着一个真正的语法错误。 + +## 无缓冲的字符流和词法符号流 + +因为 ANTLR 的识别器在默认情况下会将输入的完整字符流和全部词法符号放入缓冲区,所以它无法处理大小超过内存的文件,也无法处理类似套接字(socket)连接之类的无限输入流。为解决此问题,你可以使用字符流和词法符号流的无缓冲版本:UnbufferedCharStream 和 UnbufferedTokenStream,它们使用一个滑动窗口来处理流。 + +为展示二者的实际应用,下图是一个 CSV语法,它计算一个文件中两列浮点数的和: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNEy6jVOX0gaqFrNhAicGdJSyOPQx6FVvyIDicZNZFvibVCQN8VYwlc8T2ZKEXsMKjQ46SDtrzKf5aIw/640?wx_fmt=png&from=appmsg) + + + +如果你需要的只是每一列的和,你就应该在内存中只保留一个或两个词法符号用于记录结果。欲关闭 ANTLR 的缓冲功能,需要完成三件事情。首先,使用无缓冲的流代替常见的 ANTLFileStream 和 CommonTokenStream。其次,传给词法分析器一个词法符号工厂,将输入流中的字符拷贝到生成的词法符号中去。否则,词法符号的 getTex() 方法就会尝试访问可能已经不再可用的字符流。最后,阻止语法分析器建立语法分析树。如下图标记的关键代码: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNEy6jVOX0gaqFrNhAicGdJSfQ6U5EwyDaRTMLwBlvibzFfIEmicYpHmMG2qMyfSSsqiaYlOQZ6HaztCA/640?wx_fmt=png&from=appmsg) + +当效率是首要目标时,无缓冲流是非常有用的。使用它们的缺点是你需要手工处理与缓冲区相关的事情。例如,你不能在规则的内嵌动作中使用 $text,因为它们是从输入流中获取文本的。 + +# 结尾 + +这篇关于 ANTLR 的技术指南到此告一段落。作为领域特定语言(DSL)构建的利器,ANTLR 通过其强大的语法解析能力、灵活的监听器/访问器机制,以及高效的错误恢复策略,彻底革新了语言处理技术的开发范式。 + +无论是设计数据库查询语言、配置文件解析器,还是实现复杂的领域专用逻辑,ANTLR 都提供了从词法分析到语法树遍历的全套解决方案。其自动生成的解析器代码和直观的规则定义方式,让开发者能专注于业务逻辑而非底层细节,真正实现了"用语法驱动开发"的高效实践。通过掌握 ANTLR,你已拥有了一把打开自定义语言世界的钥匙。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/.DS_Store" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/.DS_Store" new file mode 100644 index 0000000..7fbbcf9 Binary files /dev/null and "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/.DS_Store" differ diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\344\272\253\345\205\203\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\344\272\253\345\205\203\346\250\241\345\274\217.md" new file mode 100644 index 0000000..2c44828 --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\344\272\253\345\205\203\346\250\241\345\274\217.md" @@ -0,0 +1,216 @@ +当系统中存在大量相似对象时,每个对象都需要占用一定的内存空间,如果这些对象的大部分属性是相同的,那么频繁创建这些对象会导致内存消耗过大。享元模式将这些相同部分抽取出来作为共享的内部状态,在需要时进行共享,从而减少内存占用。 + +享元模式(Flyweight Pattern)是一种结构型设计模式,旨在通过共享对象来最大化内存利用和性能提升,享元模式尝试重用现有的同类对象,如果未找到匹配的对象,则创建新对象。 + +## 使用场景 + +- 当系统中存在大量相似对象且造成了内存浪费时,可以考虑使用享元模式。 +- 对象的状态可以外部化,并且剥离出共享部分和特有部分。 +- 需要缓冲池的场景。 + +享元模式在对象池中的使用是一种常见的场景,通过对象池管理和复用对象实例,可以提高系统性能和资源利用率。对象池通常用于缓存、连接池等场景,其中对象的创建成本较高或者频繁创建销毁会影响性能时,对象池就显得尤为重要。 + +在 Java 中,String 类的 `intern()` 方法是享元模式的一个应用。`intern()` 方法返回字符串对象的规范化表示形式,即返回字符串池中与调用字符串等效的字符串。如果字符串池中已经存在等效的字符串,则返回该字符串;否则,将此字符串添加到字符串池中,并返回新的字符串引用。 + +下面是一个示例代码,演示了 String 类的 `intern()` 方法的应用: + +```java +public class StringInternExample { + public static void main(String[] args) { + String str1 = "hello"; + String str2 = new String("hello"); + String str3 = str2.intern(); + + System.out.println("str1 == str2: " + (str1 == str2)); // false + System.out.println("str1 == str3: " + (str1 == str3)); // true + } +} +``` + +在上述示例中,`str1` 和 `str2` 是两个不同的字符串对象,尽管它们的值相同,但由于 `str2` 使用了 `new String()` 构造方法创建,在堆内存中会生成一个新的对象。而通过调用 `intern()` 方法后,`str3` 返回的是字符串池中已存在的字符串对象,因此 `str1` 和 `str3` 指向的是同一个对象,所以输出结果为 `"str1 == str3: true"`。这就是 `intern()` 方法的享元模式应用,避免了重复创建相同的字符串对象,节省了内存空间。 + +## 具体实现 + +享元模式包含以下几个角色: + +- **抽象享元(Flyweight):** 定义了享元对象的外部状态和内部状态,通过这个抽象类可以接受并作用于外部状态。 +- **具体享元(Concrete Flyweight):** 继承了抽象享元类,包含内部状态和外部状态。具体享元对象需要确保内部状态是可以共享的,同时提供操作外部状态的方法。 +- **非共享具体享元(Unshared Concrete Flyweight):** 与共享具体享元相对应,非共享具体享元是不能被共享的享元对象,通常是在具体享元中无法共享的情况下使用。 +- **享元工厂(Flyweight Factory):** 负责创建和管理享元对象,在请求时返回已经创建的享元对象实例或者新创建一个享元对象。享元工厂通常会维护一个享元池用于存储已经创建的享元对象。 + +在享元模式中,核心在于区分内部状态和外部状态。内部状态是可以共享的部分,而外部状态是对象的非共享部分。 + +- **内部状态(Intrinsic State):** 内部状态是享元对象固有的、可以共享的状态,它存储在享元对象内部并且不会随着外部环境的变化而改变。内部状态可以被多个享元对象共享,因此通常将其设计为不可变的属性。内部状态对于享元对象的具体实现是必需的,但不会随着外部环境的变化而改变。 +- **外部状态(Extrinsic State):** 外部状态是享元对象的可变部分,它随着外部环境的变化而变化,需要通过客户端传入享元对象来进行处理。外部状态并不影响享元对象的内部结构或行为,它只是作为享元对象行为的参数或上下文信息传入。外部状态具有固化特性,不应该随内部状态改变而改变,否则导致系统的逻辑混乱。 + +通过区分内部状态和外部状态,享元模式实现了将对象的共享部分和变化部分分离的目的,有效地减少了系统中重复对象的数量,提高了系统的性能和资源利用率。内部状态是享元对象本身的属性,而外部状态则是根据具体情况动态变化的参数。 + +实现步骤和示例代码如下: + +1.首先定义抽象享元角色。 + +```java +public abstract class Flyweight { + //内部状态 + private String intrinsic; + //外部状态 + protected final String extrinsic; + //要求享元角色必须接受外部状态 + public Flyweight(String extrinsic){ + this.extrinsic = extrinsic; + } + //定义业务操作 + public abstract void operate(); + //内部状态的getter/setter + public String getIntrinsic() { + return intrinsic; + } + public void setIntrinsic(String intrinsic) { + this.intrinsic = intrinsic; + } +} +``` + +抽象享元角色一般为抽象类,它是描述一类事物的方法。 + +2.具体享元角色。 + +```java +public class ConcreteFlyweight1 extends Flyweight{ + //接受外部状态 + public ConcreteFlyweight1(String extrinsic){ + super(extrinsic); + } + //根据外部状态进行逻辑处理 + public void operate(){ + //业务逻辑 + } +} +``` + +```java +public class ConcreteFlyweight2 extends Flyweight{ + //接受外部状态 + public ConcreteFlyweight2(String extrinsic){ + super(extrinsic); + } + //根据外部状态进行逻辑处理 + public void operate(){ + //业务逻辑 + } +} +``` + +具体享元角色实现自己的业务逻辑,然后接收外部状态,以便内部业务逻辑对外部状态的依赖。 + +3.享元工厂。 + +```java +public class FlyweightFactory { + //定义一个池容器 + private static Map pool = new HashMap<>(); + + //享元工厂 + public static Flyweight getFlyweight(String extrinsic) { + //需要返回的对象 + Flyweight flyweight; + //在池中没有该对象 + if (pool.containsKey(extrinsic)) { + flyweight = pool.get(extrinsic); + } else { + //根据外部状态创建享元对象 + flyweight = new ConcreteFlyweight1(extrinsic); + //放置到池中 + pool.put(extrinsic, flyweight); + } + return flyweight; + } +} +``` + +4.客户端调用 + +```java + public static void main(String[] args) { + Flyweight flyweight1 = FlyweightFactory.getFlyweight("hello world"); + System.out.println(flyweight1.hashCode()); + Flyweight flyweight2 = FlyweightFactory.getFlyweight("hello world"); + System.out.println(flyweight2.hashCode()); + } + + Output: + 1705736037 + 1705736037 +``` + +可以发现对象打印的 hashCode 一致,说明对象得到了复用。 + +> Tips:外部状态最好以Java的基本类型作为标志,如String、int等,可以大幅地提升效率。如果使用自己编写的类作为外部状态,则必须覆写equals方法和hashCode方法,否则会出现通过键值搜索失败的情况,例如map.get(object)、map.contains(object)等会返回失败的结果。 + +## 线程安全问题 + +享元模式在多线程环境下可能存在线程安全问题,主要原因是享元对象的内部状态和外部状态被多个线程共享和修改,可能导致数据竞争和不一致性。具体来说,如果多个线程同时尝试修改同一个享元对象的外部状态,就会引发线程安全问题。 + +下面是示例代码: + +```java + public static void main(String[] args) { + for (int i = 0; i < 10; i++) { + new Thread(() -> { + Flyweight flyweight1 = FlyweightFactory.getFlyweight("hello world"); + Flyweight flyweight2 = FlyweightFactory.getFlyweight("hello world"); + System.out.println(flyweight1 == flyweight2); + }).start(); + } + } + +Output: +true +false +true +true +true +true +true +true +true +true +``` + +这段代码展示了多线程环境下使用享元模式的示例。在 `main` 方法中,通过循环创建了 10 个线程,在每个线程中尝试获取表示 "hello world" 的享元对象,并比较两个获取的对象是否相等。 + +可以观察到输出中存在 false,说明对象不一样了,存在线程安全问题。 + +要想实现线程安全,需要对享元工厂类稍加改造,代码如下: + +```java +public class FlyweightFactory { + //定义一个池容器 + private static Map pool = new ConcurrentHashMap<>(); + + //享元工厂 + public static synchronized Flyweight getFlyweight(String extrinsic) { + Flyweight flyweight = pool.putIfAbsent(extrinsic, new ConcreteFlyweight1(extrinsic)); + if (flyweight == null) { + return pool.get(extrinsic); + } + return flyweight; + } +} +``` + +这样就解决了线程安全问题,不过性能上会有所降低,在需要的地方考虑一下线程安全即可,在大部分的场景下都不用考虑。 + +## 总结 + +享元模式通过共享相似对象来减少内存消耗,提高系统性能。它适用于存在大量相似对象且造成内存浪费的场景,但需要注意对内部状态和外部状态的管理。合理应用享元模式可以有效优化系统架构,提升性能。 + +**优点** + +- 大幅减少内存使用,提高系统性能,实现了对象的复用,节约资源。 +- 在一定程度上实现了对象状态的外部化,方便对对象状态的管理和维护。 + +**缺点** + +- 对象状态的外部化可能导致系统不稳定,需要谨慎设计。 +- 提高了系统的复杂度,需要分离出外部状态和内部状态,而且外部状态具有固有化的性质,不应该随着内部状态的变化而变化,否则会造成系统的混乱。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\344\273\243\347\220\206\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\344\273\243\347\220\206\346\250\241\345\274\217.md" new file mode 100644 index 0000000..b8164de --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\344\273\243\347\220\206\346\250\241\345\274\217.md" @@ -0,0 +1,449 @@ +代理模式(Proxy Pattern)是一种结构型设计模式,也叫做委托模式,它允许你提供一个间接访问对象的方式。 + +用一句话描述代理模式就是:**为其他对象提供一种代理以控制对这个对象的访问** + +## 使用场景 + +- 远程代理(Remote Proxy):用于在不同地址空间中代表对象,使得客户端可以访问远程的对象。 +- 虚拟代理(Virtual Proxy):用于按需创建昂贵对象的代表,延迟对象的实例化,提高系统性能。 +- 保护代理(Protection Proxy):用于控制对真实对象的访问权限,在访问真实对象之前进行安全检查。 +- 智能引用(Smart Reference):用于在访问对象时执行额外的操作,如引用计数、懒加载等。 +- 日志记录(Logging Proxy):用于记录方法调用的日志信息,方便调试和监控系统运行状态。 +- 权限控制(Access Control Proxy):用于控制用户对对象的访问权限,限制某些用户的操作。 +- 延迟加载(Lazy Loading Proxy):用于延迟加载对象的数据,直到真正需要使用时才进行加载。 + +代理模式在Java中的Spring框架和Dubbo框架中都有广泛的应用: + +- **Spring框架中的AOP(面向切面编程)**:Spring使用代理模式实现AOP功能,允许开发者定义切面(Aspect),并通过代理机制将切面织入到目标对象的方法调用中,实现横切关注点的管理,如日志记录、事务管理等。 +- **Dubbo框架中的远程服务代理**:Dubbo是一种高性能的分布式服务框架,其中的服务消费者与服务提供者之间的通信通过代理模式来实现。Dubbo会根据配置信息动态生成接口的代理实现类,在远程调用时通过代理对象进行通信,隐藏了远程调用的复杂性,使得调用方可以像调用本地方法一样调用远程服务。 + +通过代理模式,可以实现对对象的访问控制、附加功能增强、性能优化等目的,提高系统的灵活性、可维护性和可扩展性。 + +## 具体实现 + +代理模式涉及以下几个角色: + +- **抽象主题(Subject)**:是一个接口或抽象类,定义了真实主题和代理对象共同实现的方法,客户端通过抽象主题访问真实主题。 +- **真实主题(Real Subject)**:是真正执行业务逻辑的对象,实现了抽象主题定义的方法,是代理模式中被代理的对象。 +- **代理(Proxy)**:持有对真实主题的引用,可以控制对真实主题的访问,在其自身的方法中可以调用真实主题的方法,同时也可以在调用前后执行一些附加操作。 + +实现代理模式步骤如下: + +首先定义一个接口: + +```java +public interface Subject { + void request(); +} +``` + +然后实现真实主题类: + +```java +public class RealSubject implements Subject { + @Override + public void request() { + System.out.println("Real Subject handles the request."); + } +} +``` + +接着创建代理类: + +```java +public class Proxy implements Subject { + private RealSubject realSubject; + + @Override + public void request() { + if (realSubject == null) { + realSubject = new RealSubject(); + } + preRequest(); + realSubject.request(); + postRequest(); + } + //前置处理 + private void preRequest() { + System.out.println("Proxy performs pre-request actions."); + } + //后置处理 + private void postRequest() { + System.out.println("Proxy performs post-request actions."); + } +} +``` + +客户端调用: + +```java + public static void main(String[] args) { + Proxy proxy = new Proxy(); + proxy.request(); + } +``` + +输出: + +``` +Proxy performs pre-request actions. +Real Subject handles the request. +Proxy performs post-request actions. +``` + +> Tips:一个代理类,可以代理多个真实角色,并且真实角色之间允许有耦合关系。 + +## 普通代理 & 强制代理 + +在代理模式中,可以区分普通代理和强制代理: + +- **普通代理**(Normal Proxy):由代理类控制对真实主题的访问,客户端直接与代理类交互,代理类负责将请求转发给真实主题,调用者只知代理而不用知道真实的角色是谁,屏蔽了真实角色的变更对高层模块的影响。 +- **强制代理**(Force Proxy):“强制”必须通过真实角色查找到代理角色,否则不能访问。并且只有通过真实角色指定的代理类才可以访问,也就是说由真实角色管理代理角色。强制代理不需要产生一个代理出来,代理的管理由真实角色自己完成。 + +上面提供的代码例子就是普通代理,下面用代码演示下强制代理: + +```java +// 抽象主题接口 +public interface Subject { + /** + * 待具体实现的方法 + */ + void request(); + + /** + * 获取每个具体实现对应的代理对象实例 + * @return 返回对应的代理对象 + */ + Subject getProxy(); +} + + +// 强制代理对象 +public class ForceProxy implements Subject { + + private Subject subject; + + public ForceProxy(Subject subject) { + this.subject = subject; + } + + /** + * 待具体实现的方法 + */ + @Override + public void request() { + preRequest(); + subject.request(); + postRequest(); + } + + /** + * @return 返回对应的代理对象就是自己 + */ + @Override + public Subject getProxy() { + return this; + } + + private void postRequest() { + System.out.println("访问真实主题以后的后续处理"); + } + + private void preRequest() { + System.out.println("访问真实主题之前的预处理"); + } +} + + +// 具体的实现对象 +public class RealSubject implements Subject { + + /** + * 该具体实现对象的代理对象 + */ + private Subject proxy; + + @Override + public Subject getProxy() { + proxy = new ForceProxy(this); + return proxy; + } + + /** + * 待具体实现的方法 + */ + @Override + public void request() { + if (isProxy()) { + System.out.println("访问真实主题方法"); + } else { + System.out.println("请使用指定的代理访问"); + } + } + + private boolean isProxy() { + return proxy != null; + } +} +``` + +客户端调用: + +```java + public static void main(String[] args) { + Subject subject = new RealSubject(); + subject.request(); + } + Output: + 请使用指定的代理访问 + + + + public static void main(String[] args) { + Subject subject = new RealSubject(); + Subject proxy = new ForceProxy(subject); + proxy.request(); + } + Output: + 访问真实主题之前的预处理 + 请使用指定的代理访问 + 访问真实主题以后的后续处理 + + + + public static void main(String[] args) { + Subject subject = new RealSubject(); + Subject proxy = subject.getProxy(); + proxy.request(); + } + Output: + 访问真实主题之前的预处理 + 访问真实主题方法 + 访问真实主题以后的后续处理 +``` + +通过代码可以观察到,强制代理模式下,不允许通过真实角色来直接访问,只有通过真实角色来获取代理对象,才能访问。 + +高层模块只需调用`getProxy`就可以访问真实角色的所有方法,它根本就不需要产生一个代理出来,代理的管理已经由真实角色自己完成。 + +## 动态代理 + +前面讲的普通代理和强制代理都属于静态代理,也就是说自己写代理类的方式就是静态代理。 + +静态代理有一个缺点就是要在实现阶段就要指定代理类以及被代理者,很不灵活。 + +**而动态代理是一种在运行时动态生成代理类的机制,可以在不预先知道接口的情况下动态创建接口的实现类,允许在运行阶段才指定代理哪一个对象,比如Spring AOP就是非常经典的动态代理的应用** + +下面是两个动态代理常用的实现方式: + +- **JDK 动态代理** :基于 Java 反射机制,在运行时动态创建代理类和对象。JDK 动态代理要求被代理的类实现一个或多个接口,通过 `java.lang.reflect.Proxy` 和 `java.lang.reflect.InvocationHandler` 接口来实现代理对象的生成和方法调用。 +- **CGLIB 动态代理**:不要求被代理的类实现接口,通过继承被代理类来生成代理对象。CGLIB 使用字节码生成库ASM来动态生成代理类,因此性能略高于 JDK 动态代理。 + +### JDK动态代理 + +JDK实现动态代理的核心机制就是`java.lang.reflect.Proxy`类和`java.lang.reflect.InvocationHandler`接口。 + +JDK动态代理的动态代理类需要去实现JDK自带的`java.lang.reflect.InvocationHandler`接口,该接口中的`invoke()`方法能够让动态代理类实例在运行时调用被代理类需要对外实现的所有接口中的方法,也就是完成对真实主题类方法的调用。 + +具体实现步骤如下: + +1. 创建一个接口`Subject`表示被代理的对象需要实现的方法。 +2. 创建一个真实主题类`RealSubject`,实现`Subject`接口,定义真正的业务逻辑。 +3. 创建一个实现`InvocationHandler`接口的代理处理器类`DynamicProxyHandler`,在`invoke`方法中执行额外的操作,并调用真实主题的方法。 +4. 在主程序中使用`Proxy.newProxyInstance()`方法动态生成代理对象,并调用代理对象的方法。 + +下面是动态代理的示例代码,一起来感受一下: + +```java +// 1. 创建接口 +public interface Subject { + void request(); +} + +// 2. 创建真实主题类 +public class RealSubject implements Subject { + @Override + public void request() { + System.out.println("RealSubject handles the request."); + } +} + +// 3. 创建代理处理器类 +public class DynamicProxyHandler implements InvocationHandler { + private Object target; + + DynamicProxyHandler(Object target) { + this.target = target; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + // 执行额外操作 + System.out.println("Before requesting..."); + + // 调用真实主题对象的方法 + Object result = method.invoke(target, args); + + // 执行额外操作 + System.out.println("After requesting..."); + + return result; + } +} + +public class DynamicProxyExample { + + public static void main(String[] args) { + // 创建真实主题对象 + Subject realSubject = new RealSubject(); + + // 创建代理处理器对象 + InvocationHandler handler = new DynamicProxyHandler(realSubject); + + // 创建动态代理对象 + Subject proxy = (Subject) Proxy.newProxyInstance( + realSubject.getClass().getClassLoader(), + realSubject.getClass().getInterfaces(), + handler); + + // 调用代理对象的方法 + proxy.request(); + } +} + +``` + +这段代码演示了使用 JDK 动态代理实现动态代理的过程: + +1. 首先,创建了一个真实主题对象 `realSubject`,表示被代理的真实对象。 +2. 接着,创建了一个代理处理器对象 `handler`,类型为 `InvocationHandler`,并将真实主题对象传入代理处理器中,用于处理代理对象的方法调用。 +3. 然后,通过 `Proxy.newProxyInstance()` 方法创建了一个动态代理对象 `proxy`,该方法接受三个参数: + - 类加载器:使用真实主题对象的类加载器。 + - 接口数组:指定代理对象需要实现的接口,这里使用真实主题对象的接口数组。 + - 处理器:指定代理对象的调用处理器,即前面创建的代理处理器对象 `handler`。 +4. 最后,通过代理对象 `proxy` 调用 `request()` 方法,实际上会委托给代理处理器 `handler` 的 `invoke()` 方法来处理方法调用,进而调用真实主题对象的对应方法。 + +这段代码通过 JDK 动态代理机制实现了代理对象的动态创建和方法调用处理,实现了对真实主题对象的间接访问,并在调用真实主题对象方法前后进行了额外的处理。 + +其动态调用过程如图所示: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScMZlyXAj3bIWtygKjsic14u2euOxwCOLSBgWN1jgz5thlg8otSNHjt4FgibEUvboRf43ZRfic8iamc3bw/640) + +### cglib动态代理 + +JDK的动态代理机制只能代理实现了接口的类,否则不能实现JDK的动态代理,具有一定的局限性。 + +CGLIB(Code Generation Library)是一个功能强大的字节码生成库,可以用来在运行时扩展Java类和实现动态代理。 + +相对于JDK动态代理基于接口的代理,cglib动态代理基于子类的代理,可以代理那些没有接口的类,通俗说cglib可以在运行时动态生成字节码。 + +cglib的原理是对指定的目标类生成一个子类,并覆盖其中方法实现增强,因为采用的是继承,所以不能对final修饰符的类进行代理。 + +下面是一个使用cglib实现动态代理的示例代码,包括实现步骤: + +1. 创建一个真实主题类`RealSubject`,无需实现任何接口。 +2. 创建一个实现`MethodInterceptor`接口的代理处理器类`DynamicProxyHandler`,在`intercept`方法中执行额外的操作,并调用真实主题的方法。 +3. 在主程序中使用`Enhancer`类创建代理对象,并设置代理处理器。 + +使用 cglib 需要添加对应的依赖: + +```xml + + + cglib + cglib + 3.3.0 + +``` + +```java +// 1. 创建真实主题类 +public class RealSubject { + public void request() { + System.out.println("RealSubject handles the request."); + } +} + +// 2. 创建代理处理器类 +public class DynamicProxyHandler implements MethodInterceptor { + + /** + * 通过Enhancer 创建代理对象 + */ + private Enhancer enhancer = new Enhancer(); + + /** + * 通过class对象获取代理对象 + * @param clazz class对象 + * @return 代理对象 + */ + public Object getProxy(Class clazz) { + // 设置需要代理的类 + enhancer.setSuperclass(clazz); + // 设置enhancer的回调 + enhancer.setCallback(this); + return enhancer.create(); + } + + @Override + public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { + // 执行额外操作 + System.out.println("Before requesting..."); + + // 调用真实主题对象的方法 + Object result = proxy.invokeSuper(obj, args); + + // 执行额外操作 + System.out.println("After requesting..."); + + return result; + } +} + +public class CglibProxyExample { + public static void main(String[] args) { + DynamicProxyHandler proxy = new DynamicProxyHandler(); + RealSubject realSubject = (RealSubject) proxy.getProxy(RealSubject.class); + + // 调用代理对象的方法 + realSubject.request(); + } +} +``` + +输出: + +``` +Before requesting... +RealSubject handles the request. +After requesting... +``` + +cglib动态代理相比于JDK动态代理的优缺点如下: + +**优点**: + +- 可以代理没有实现接口的类。 +- 性能更高,因为直接操作字节码,无需反射。 + +**缺点**: + +- 生成的代理类会继承被代理类,可能会影响某些设计。 +- 无法代理static方法,因为cglib是基于继承来生成代理类的,而静态方法是属于类而非对象的 +- 对于final方法,cglib无法覆盖,仍然会调用父类方法。 + +## 总结 + +代理模式是一种常用的设计模式,在软件开发中有着广泛的应用。通过引入代理对象,可以实现对真实对象的访问控制、附加功能增强、性能优化等目的。 + +**优点** + +- 可以控制对真实对象的访问,在不改变原始类代码的情况下扩展其行为。 +- 代理模式能将客户端与目标对象分离,在一定程序上降低了系统的耦合度. + +**缺点** + +- 增加了系统复杂性,引入了多余的代理类,因此有些类型的代理模式可能会造成请求的处理速度变慢。 +- 实现代理模式需要额外的工作,有些代理模式的实现非常复杂。 + +总的来说,代理模式通过引入代理对象,实现了对真实对象的间接访问和控制,为系统的设计提供了一种简洁而有效的解决方案。在日常的软件开发中,合理地运用代理模式可以为系统带来更好的结构和性能表现。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\345\215\225\344\276\213\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\345\215\225\344\276\213\346\250\241\345\274\217.md" new file mode 100644 index 0000000..2bce6a2 --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\345\215\225\344\276\213\346\250\241\345\274\217.md" @@ -0,0 +1,297 @@ +在软件开发中,有些对象我们只需要一个实例,通过单例模式可以确保一个类只有一个实例,并提供了全局访问点以便其他对象可以使用该实例。本文将介绍单例模式的使用场景、实现方式和总结。 + +单例模式属于创建型设计模式,它限制一个类只能创建一个实例。这个实例可以通过全局访问点来获取,从而确保所有代码都共享同一个实例。 + +Spring 框架应用中的 ApplicationContext 就是单例模式中的饿汉式。 + +单例模式在很多场景下都有应用,比如线程池、数据库连接池、配置对象等。通过使用单例模式,可以降低系统中对象的数量,减少资源开销,并且方便管理和控制这些共享的实例。 + +**优点** + +- 由于单例模式在内存中只有一个实例,减少了内存开支,特别是一个对象需要频繁地创建、销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显。 +- 由于单例模式只生成一个实例,所以减少了系统的性能开销,当一个对象的产生需要比较多的资源时,如读取配置、产生其他依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后用永久驻留内存的方式来解决(在Java EE中采用单例模式时需要注意JVM 垃圾回收机制)。 +- 单例模式可以避免对资源的多重占用,例如一个写文件动作,由于只有一个实例存在内存中,避免对同一个资源文件的同时写操作。 +- 单例模式可以在系统设置全局的访问点,优化和共享资源访问,例如可以设计一个单例类,负责所有数据表的映射处理。 + +**缺点** + +- 单例模式一般没有接口,扩展很困难,若要扩展,除了修改代码基本上没有第二种途 径可以实现。单例模式为什么不能增加接口呢?因为接口对单例模式是没有任何意义的,它要求“自行实例化”,并且提供单一实例、接口或抽象类是不可能被实例化的。当然,在特殊情况下,单例模式可以实现接口、被继承等,需要在系统开发中根据环境判断。 +- 单例模式对测试是不利的,在并行开发环境中,如果单例模式没有完成,是不能进行测试的,没有接口也不能使用mock的方式虚拟一个对象。 +- 单例模式与单一职责原则有冲突。一个类应该只实现一个逻辑,而不关心它是否是单例的,是不是要单例取决于环境,单例模式把“要单例”和业务逻辑融合在一个类中。 + +## 使用场景 + +**为什么要用单例模式?** + +- 单例模式节省公共资源 + +比如:大家都要喝水,但是没必要每人家里都打一口井是吧,通常的做法是整个村里打一个井就够了,大家都从这个井里面打水喝。 + +对应到我们计算机里面,像日志管理、打印机、数据库连接池、应用配置。 + +- 单例模式方便控制 + +就像日志管理,如果多个人同时来写日志,你一笔我一笔那整个日志文件都乱七八糟,如果想要控制日志的正确性,那么必须要对关键的代码进行上锁,只能一个一个按照顺序来写,而单例模式只有一个人来向日志里写入信息方便控制,避免了这种多人干扰的问题出现。 + +单例模式适用于以下场景: + +- 当一个类只需要一个实例时。 +- 当多个对象需要共享同一个实例时。 +- 当创建实例需要耗费大量资源时。 + +单例模式的应用场景之一:日志记录器。 + +```java +public class Logger { + private static Logger instance; + + private Logger() { + // 私有构造方法,防止外部实例化 + } + + public static synchronized Logger getInstance() { + if (instance == null) { + instance = new Logger(); + } + return instance; + } + + public void log(String message) { + System.out.println("Log: " + message); + } +} +``` + +在上述示例中,`Logger` 类只能创建一个实例。通过 `getInstance()` 静态方法,我们可以获取该实例,并且在需要记录日志的地方调用 `log` 方法进行日志记录。 + +## 序列化对单例模式的破坏 + +序列化可能会破坏某些单例模式实现方式,特别是那些使用懒加载或延迟初始化的方式。在进行反序列化时,会创建一个新的对象实例,从而破坏了原本的单例特性。 + +以下是一个简单的示例代码,演示了序列化对懒汉式单例模式的影响: + +```java +public class Singleton implements Serializable { + private static Singleton instance; + + private Singleton() { + // 私有构造方法 + } + + public static Singleton getInstance() { + if (instance == null) { + instance = new Singleton(); + } + return instance; + } + + public static void main(String[] args) { + Singleton singleton1 = Singleton.getInstance(); + + try { + // 将singleton1对象序列化到文件中 + ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("singleton.ser")); + outputStream.writeObject(singleton1); + outputStream.close(); + + // 从文件中反序列化出一个新对象 + ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("singleton.ser")); + Singleton singleton2 = (Singleton) inputStream.readObject(); + inputStream.close(); + + // 比较两个对象是否相同 + System.out.println("singleton1: " + singleton1.hashCode()); + System.out.println("singleton2: " + singleton2.hashCode()); + } catch (Exception e) { + e.printStackTrace(); + } + } +} +``` + +输出: + +``` +singleton1: 868693306 +singleton2: 625576447 +``` + +运行以上代码,输出结果会显示 singleton1 和 singleton2 的哈希码不同,说明反序列化过程创建了一个新的对象实例,破坏了单例模式。 + +要解决这个问题,可以使用枚举方式实现单例模式,或者可以在类中添加一个 `readResolve()` 方法,并返回单例实例。这样在反序列化时会调用该方法,从而确保只返回单例对象: + +```java +private Object readResolve() throws ObjectStreamException { + return instance; +} +``` + +在上述示例的 Singleton 类中添加 `readResolve()` 方法后,再运行代码,输出结果将会显示 singleton1 和 singleton2 的哈希码相同,保证了单例模式的正确性。`readResolve()` 方法能够让我们控制反序列化时返回的对象,从而避免破坏单例特性。 + +## 实现方式 + +单例模式的实现有多种方式,如下所示: + +### 饿汉式 + +```java +public class EagerSingleton { + //确保对象实例只有一个 + private static final EagerSingleton instance = new EagerSingleton(); + // 私有构造方法,防止外部实例化 + private EagerSingleton() { + + } + //以静态方法返回实例 + public static EagerSingleton getInstance() { + return instance; + } +} +``` + +优点: + +- 线程安全:由于在类加载时就创建了实例,所以不会出现多线程并发访问时创建多个实例的问题。 +- 简单直观:饿汉模式的实现相对简单,代码易于理解和维护。 +- 性能高:由于实例在类加载时就创建了,因此获取实例的速度较快。 + +缺点: + +- 不能懒加载:由于实例在类加载时就创建了,即使在某些情况下并不需要使用该实例,也会占用一定的资源。 + +饿汉模式适用于在程序运行期间始终需要使用的实例,并且对性能要求较高的场景。但是需要注意内存浪费的问题。 + +### 懒汉式 + +```java +public class LazySingleton { + private static LazySingleton instance; + // 私有构造方法,防止外部实例化 + private LazySingleton() { + + } + + public static LazySingleton getInstance() { + if (instance == null) { + instance = new LazySingleton(); + } + return instance; + } +} +``` + +在懒汉式中,实例在第一次被使用时才会被创建。但是当多个线程同时调用 `getInstance()` 方法时,可能会导致创建多个实例。存在线程安全问题。 + +优点: + +- 延迟加载:懒汉模式在第一次使用时才会创建实例,可以避免不必要的资源消耗。 + +缺点: + +- 线程安全性需要额外考虑:在最简单的懒汉模式实现中,当多个线程同时调用 `getInstance()` 方法时,可能会创建多个实例。为了解决这个问题,可以使用同步关键字或者其他线程安全的方式进行控制,但这可能会影响性能。 +- 性能开销:由于懒汉模式需要在获取实例时进行判断和创建,会带来一定的性能开销,特别是在高并发的情况下。 + +总体来说,懒汉模式适用于在程序运行期间可能不会立即使用到实例的情况,可以实现延迟加载。但是需要注意线程安全性和性能开销的问题,在多线程环境下要特别小心处理。 + +如果要保证懒汉模式的线程安全性,则需要加锁解决线程同步问题。 + +#### 双重校验锁 + +```java +public class LazySingleton{ + /** + * volatile 关键字可以保证线程间变量的可见性,还有一个作用就是阻止局部重排序的发生 + */ + private volatile static LazySingleton INSTANCE = null; + private LazySingleton(){} + public static LazySingleton getInstance(){ + if(INSTANCE == null) + { + synchronized(LazySingleton.class){ + if(INSTANCE == null){ + INSTANCE = new LazySingleton(); + } + } + return INSTANCE; + } + } +} +``` + +双层校验锁的懒汉模式可以确保在多线程环境下仅创建一个实例,并保证线程安全性。具体解释如下: + +1. 首先,如果实例已经被创建,则直接返回该实例,避免了不必要的同步开销。 +2. 当第一个线程到达`getInstance()`方法时,会检查实例是否为空。由于在多线程环境下可能有多个线程同时通过这一判断,因此需要在 synchronized 关键字内再次进行空检查。 +3. 在进入 synchronized 块之前,使用双重检查来确保只有第一个线程能够创建实例。即使有其他线程在第一个线程进入 synchronized 块之后抢占CPU资源,它们也会发现实例已经被创建,从而避免重复创建实例。 +4. 使用volatile关键字修饰 INSTANCE 变量,可以确保变量的可见性,在多线程环境下,一个线程修改了 INSTANCE 的值,其他线程能够立即看到最新的值,避免了指令重排序带来的问题。 + +#### 静态内部类 + +基于静态内部类实现线程安全,性能比双重检查锁要好。 + +```Java +public class Singleton { + private static class LazyHolder { + private static final Singleton INSTANCE = new Singleton(); + } + private Singleton (){ + /*为了避免反射破坏单例,需要在构造方法中增加限制,一旦出现多次重复创建,直接抛出异常*/ + if (null != LazyHolder.INSTANCE) { + throw new RuntimeException("创建Singleton异常,不允许创建多个实例!"); + } + } +/** + * 调用静态方法的时候会先加载Singleton类,静态内部类只有在使用的时候才会被加载。 + * 而ClassLoader加载的时候是单个线程的。所以既能够实现需要的时候才被加载,也能够实现线程安全。 + */ + public static final Singleton getInstance() { + return LazyHolder.INSTANCE; + } +} +``` + +这种方式利用了类加载机制来保证只创建一个instance实例,从而保证线程安全性。具体解释如下: + +1. 静态内部类 LazyHolder 只有在 `getInstance()` 方法被调用时才会被加载,从而实现了延迟加载的效果。 +2. 类加载是由 ClassLoader 负责的,ClassLoader 在加载类的过程中是单线程进行的,因此在加载 LazyHolder 类时是线程安全的。 +3. LazyHolder 类中的 INSTANCE 是final修饰的,保证了实例的唯一性和不可更改性。 +4. 在 Singleton 的构造方法中,通过判断 LazyHolder.INSTANCE 是否为 null 来防止通过反射手段创建多个实例。如果尝试重复创建实例,将抛出异常。 + +### 枚举式 + +枚举方式理论上是实现单例模式的最佳方式,这种方式也是《Effective Java》的作者 Josh Bloch 提倡的方式。 + +```java +public enum Singleton { + INSTANCE; + + // 其他成员方法和属性 + public void doSomething() { + // 实现具体的功能 + } +} +``` + +枚举方式实现的单例模式能够保证线程安全,原因如下: + +1. 枚举类型在Java中是线程安全的,线程安全性由JVM本身来保证,它的实例在类加载过程中被初始化,并且只会被初始化一次。这意味着在多线程环境下,不会出现多个线程创建多个实例的情况。 +2. 枚举实例是在类加载时被创建的,而且是静态常量,因此在整个应用程序生命周期内,只会存在一个实例。无论何时访问枚举实例,都会返回同一个对象。 + +相比前面的实现方式,枚举方式有两大优点: + +1. **防止反射攻击和序列化破坏**:枚举本身就具有防止反射攻击和序列化破坏的特性。枚举实例的创建由JVM自动管理,不可通过反射调用私有构造函数创建新的实例,同时枚举类型默认实现了Serializable接口,因此也能够防止序列化破坏单例。 +2. **简洁明了**:使用枚举方式实现单例模式非常简洁清晰,代码量少,易于理解和维护。 + +## 总结 + +选择单例模式的实现方式取决于具体的需求和场景。下面是对不同实现方式的一些建议: + +- **饿汉式**:如果单例对象在程序运行期间始终需要存在,并且占用资源较小,则可以考虑使用饿汉式。它能够保证在任何时候都能获得单例对象,但可能会提前加载实例造成资源浪费。 +- **懒汉式**:如果单例对象在程序中的使用并不频繁,或者占用资源较大,希望在需要时才进行初始化,可以选择懒汉式。懒汉式能够延迟加载实例,节省资源,但需要考虑线程安全性。 +- **双重校验锁(Double-Checked Locking)**:这种方式结合了懒汉式和饿汉式的优点,即实现了延迟加载和线程安全。适用于资源消耗较大、需要延迟加载的情况。 +- **静态内部类**:静态内部类方式实现的单例模式具有延迟加载和线程安全的特点,同时也解决了双重校验锁的问题。适用于资源消耗较小、只在需要时才进行初始化的情况。 +- **枚举方式**:枚举方式是最简洁且安全可靠的单例实现方式,适用于任何情况。它具有线程安全性、实例唯一性和防止反射攻击、序列化破坏等优点。 + +总而言之,单例模式作为一种常见的设计模式,在软件开发中有着广泛的应用。选择适合的实现方式,并根据具体需求进行灵活运用,将有助于提升系统的性能和可维护性。 + +选择合适的单例模式实现方式需要综合考虑需求、资源消耗、线程安全性以及代码简洁性等因素。无论选择哪种方式,保证线程安全是非常重要的,同时也需要注意防止反射攻击和序列化破坏。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\345\267\245\345\216\202\346\226\271\346\263\225\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\345\267\245\345\216\202\346\226\271\346\263\225\346\250\241\345\274\217.md" new file mode 100644 index 0000000..1a30878 --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\345\267\245\345\216\202\346\226\271\346\263\225\346\250\241\345\274\217.md" @@ -0,0 +1,274 @@ +在面向对象设计中,经常需要创建对象实例。传统的方式是在代码中直接使用 `new` 关键字来创建对象,但这种方式可能会导致高耦合和难以扩展。 + +工厂方法模式属于创建型模式,通过定义一个用于创建对象的接口,将具体的实例化延迟到子类中,提供了一种灵活、可扩展的对象创建方式,使得系统更加符合开闭原则。 + +## 使用场景 + +工厂方法模式适用于以下场景: + +- 对象的创建过程比较复杂,包含一系列步骤或依赖关系,需要隐藏创建细节,只关注对象的使用。 +- 需要在运行时动态决定创建哪个具体对象。 +- 希望通过扩展工厂类来添加新的产品,而不是修改已有的代码。 + +一个常见的工厂方法模式在 Spring 中的应用例子是通过 `FactoryBean` 接口来创建自定义的工厂 Bean。 + +假设我们有一个名为 `UserService` 的服务类,它依赖于另一个名为 `UserRepository` 的数据访问对象。我们可以使用工厂方法模式来创建 `UserService` 实例,并将其作为一个 Bean 注册到 Spring 容器中。 + +首先,我们创建一个实现了 `FactoryBean` 接口的工厂类 `UserServiceFactory`: + +```java +public class UserServiceFactory implements FactoryBean { + private UserRepository userRepository; + + public void setUserRepository(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserService getObject() throws Exception { + UserService userService = new UserService(); + userService.setUserRepository(userRepository); + return userService; + } + + @Override + public Class getObjectType() { + return UserService.class; + } + + @Override + public boolean isSingleton() { + return true; + } +} +``` + +在上述代码中,`UserServiceFactory `实现了 `FactoryBean` 接口,并重写了相关方法。在 `getObject()` 方法中,我们创建了一个 `UserService`实例,并设置了其依赖的 `UserRepository`。`getObjectType()` 方法返回了工厂创建的对象类型,`isSingleton()` 方法表示该工厂创建的对象是否为单例。 + +接下来,我们需要将 `UserServiceFactory` 和 `UserRepository` 注册到Spring容器中。可以通过XML配置文件进行配置: + +```xml + + + + + + + +``` + +在上述配置中,我们首先创建了一个 `UserRepository` 的Bean,并将其注入到 `UserServiceFactory` 工厂类中。然后,通过 `factory-bean` 属性指定使用`userServiceFactory` 工厂来创建 `userService` 的实例。 + +这样,当Spring容器初始化时,会自动调用 `UserServiceFactory` 的 `getObject()` 方法来创建 `UserService` 实例,并将其作为一个 Bean 注册到容器中。可以通过 `@Autowired` 或其他方式来注入 `UserService` 对象,并使用它的服务。 + +通过这种方式,我们成功地应用了工厂方法模式,在 Spring 中管理和创建了 `UserService` 实例,并解耦了对象的创建和依赖注入过程。 + +## 具体实现 + +工厂方法模式涉及以下几个角色: + +- 抽象产品(Abstract Product):定义了产品的抽象接口或抽象类,具体产品需要实现这个接口或继承这个抽象类。 +- 具体产品(Concrete Product):实现了抽象产品定义的接口或继承抽象产品的抽象类,是工厂方法模式所创建的对象。 +- 抽象工厂(Abstract Factory):定义了一个创建产品对象的抽象工厂接口,其中包含了创建产品的抽象方法。 +- 具体工厂(Concrete Factory):实现了抽象工厂接口,负责创建具体的产品对象。具体工厂类通常含有与业务相关的逻辑,并在工厂方法中实例化具体产品对象。 + +在工厂方法模式中,抽象工厂和抽象产品是核心,而具体工厂和具体产品则根据实际需求进行扩展和实现。 + +通过这些角色的协作,工厂方法模式实现了将产品的创建过程封装起来,使得客户端与具体产品解耦,同时也提供了灵活性和可扩展性。 + +**抽象产品类和具体产品类** + +首先定义一个抽象产品类 `Product`: + +```java +public abstract class Product { + public abstract void use(); +} +``` + +然后创建具体产品类,如 `ConcreteProductA` 和 `ConcreteProductB`,它们分别继承自 `Product` 并实现了其中的抽象方法。 + +```java +public class ConcreteProductA extends Product { + + @Override + public void use(){ + System.out.println("use ConcreteProductA"); + } +} +``` + +```java +public class ConcreteProductB extends Product { + @Override + public void use(){ + System.out.println("use ConcreteProductB"); + } +} +``` + +**抽象工厂类和具体工厂类** + +接下来定义一个抽象工厂类 `Factory`,其中包含一个抽象的工厂方法 `createProduct()`,用于创建具体的产品对象: + +```java +public abstract class Factory { + public abstract Product createProduct(); +} +``` + +对于每个具体产品,创建相应的具体工厂类: + +```java +public class ConcreteFactoryA extends Factory { + + @Override + public Product createProduct() { + return new ConcreteProductA(); + } +} +``` + +```java +public class ConcreteFactoryB extends Factory { + + @Override + public Product createProduct() { + return new ConcreteProductB(); + } +} +``` + +**客户端代码** + +在客户端代码中,我们可以根据需要选择不同的具体工厂类来创建产品对象。 + +```java +public class Client { + public static void main(String[] args) { + Factory factory = new ConcreteFactoryA(); + Product product = factory.createProduct(); + product.use(); + } +} +``` + +通过工厂方法模式,我们将对象的创建过程分散到不同的具体工厂类中,每个具体工厂类只负责创建对应的产品对象。这样可以降低代码的耦合度,同时也方便添加新的产品和工厂。 + +**优点** + +- 符合开闭原则:工厂方法模式将产品的创建过程封装在具体工厂类中,新增产品时只需添加对应的工厂类,而无需修改已有的代码。 +- 客户端与具体产品解耦:客户端代码只和抽象工厂类以及抽象产品类交互,无需关心具体的实现细节,从而实现了高层模块和底层模块的解耦。 +- 扩展性好:通过添加新的具体工厂类和具体产品类,可以灵活地扩展系统,符合开放封闭原则。 +- 容易进行单元测试:由于工厂方法模式将对象的创建过程封装到具体工厂类中,我们可以轻松地替换具体工厂类来进行单元测试,提高代码的可测试性。 + +**缺点** + +- 类的数量增加:引入工厂方法模式会增加类的数量,增加了系统的复杂度。 +- 增加了系统的抽象性和理解难度:相比于简单工厂模式,工厂方法模式引入了更多的抽象类和接口,对于初学者来说可能更难理解。 + +>注意:工厂方法模式适合复杂对象,而简单对象,特别是只需要通过 new 就可以完成创建的对象,无需使用工厂模式。如果使用工厂模式,就需要引入一个工厂类,会增加系统的复杂度。 + +## 简单工厂模式 + +当只有少量具体产品类时,并且对象的创建逻辑相对简单,没有必要为每个具体产品类创建一个对应的工厂类,此时使用简单工厂模式会更加简洁和直观。 + +简单工厂模式(Simple Factory Pattern)是工厂方法模式的弱化。 + +简单工厂模式由三个主要角色组成: + +- 工厂类(Factory Class):负责创建对象的核心类,它通常包含一个静态方法或者非静态方法,根据客户端传入的参数来创建相应的对象实例。 +- 抽象产品类(Abstract Product Class):定义了具体产品类的共同接口或抽象类,描述了产品的通用行为。 +- 具体产品类(Concrete Product Class):实现了抽象产品类所定义的接口或抽象类,具体产品类是工厂类所创建的目标对象。 + +下面是一个简单的示例代码,演示了简单工厂模式的实现: + +```java +// 抽象产品类 +public interface Animal { + void speak(); +} + +// 具体产品类1 +public class Cat implements Animal { + @Override + public void speak() { + System.out.println("Meow!"); + } +} + +// 具体产品类2 +public class Dog implements Animal { + @Override + public void speak() { + System.out.println("Woof!"); + } +} + +// 工厂类 +public class AnimalFactory { + public static Animal createAnimal(String type) { + if (type.equalsIgnoreCase("cat")) { + return new Cat(); + } else if (type.equalsIgnoreCase("dog")) { + return new Dog(); + } + throw new IllegalArgumentException("Invalid animal type: " + type); + } +} +``` + +在上述代码中,我们定义了一个抽象产品类 `Animal`,并有两个具体产品类 `Cat` 和 `Dog`,它们都实现了 `Animal` 接口。工厂类 `AnimalFactory` 负责根据客户端传入的参数创建相应的具体产品对象。 + +使用简单工厂模式,客户端可以通过调用工厂类的静态方法 `createAnimal()` 来获取所需的具体产品对象。例如: + +```java +Animal cat = AnimalFactory.createAnimal("cat"); +cat.speak(); // 输出:Meow! + +Animal dog = AnimalFactory.createAnimal("dog"); +dog.speak(); // 输出:Woof! +``` + +简单工厂模式因为工厂类定义了一个静态方法,因此也叫做静态工厂模式。其缺点是工厂类的扩展比较困难,不符合开闭原则,并且随着产品类型增多,简单工厂模式工厂类的代码可能会变得复杂,因此不适用于大规模或复杂的应用程序,但它仍然是一个非常实用的设计模式。 + +## 延迟初始化 + +延迟初始化:一个对象被消费完毕后,并不立刻释放,工厂类保持其初始状态,等待再次被使用。 + +延迟加载的工厂类,参考代码如下: + +```java +public class ProductFactory { + private static final Map prMap = new HashMap(); + + public static synchronized Product createProduct(String type) throws Exception { + Product product = null; + //如果Map中已经有这个对象 + if (prMap.containsKey(type)) { + product = prMap.get(type); + } else { + if (type.equals("Product1")) { + product = new ConcreteProduct1(); + } else { + product = new ConcreteProduct2(); + } + //同时把对象放到缓存容器中 + prMap.put(type, product); + } + return product; + } +} +``` + +代码算是比较简单,通过定义一个Map容器,容纳所有产生的对象,如果在Map容器中已经有的对象,则直接取出返回;如果没有,则根据需要的类型产生一个对象并放入到Map容器中,以方便下次调用。 + +这样的好处是可以限制某一个产品类的最大实例化数量,通过判断Map中已有的对象数量来实现。 + +延迟加载在对象初始化比较复杂的情况下,可以降低对象的产生和销毁带来的复杂性。这是非常有意义的,例如 JDBC 连接数据库,都会要求设置一个 MaxConnections 最大连接数量,该数量就是内存中最大实例化的数量。 + +## 总结 + +工厂方法模式使用的频率非常高,工厂方法模式通过定义抽象工厂类和抽象产品类,将对象的创建委托给子类来实现。它提供了一种灵活、可扩展的对象创建方式,符合开闭原则,并且降低了代码的耦合度。 + +通过合理地使用工厂方法模式,我们可以提高代码的灵活性、可扩展性和可维护性,从而构建更优秀的软件系统。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\346\250\241\346\235\277\346\226\271\346\263\225\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\346\250\241\346\235\277\346\226\271\346\263\225\346\250\241\345\274\217.md" new file mode 100644 index 0000000..67ee70f --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\346\250\241\346\235\277\346\226\271\346\263\225\346\250\241\345\274\217.md" @@ -0,0 +1,225 @@ +模板方法模式(Template Method Pattern),又叫模板模式(Template Pattern),是一种行为设计模式,它定义了一个操作中的算法框架,将某些步骤的具体实现留给子类。通过模板方法模式,我们可以在不改变算法结构的情况下,允许子类重新定义某些步骤,从而实现代码复用和扩展。 + +在软件开发中,我们经常会遇到需要定义一组相似操作的场景。这些操作可能在整体上有着相同的结构,但在细节上有所差异。如果每次都重复编写这些操作的通用结构,会导致代码的冗余性,同时也增加了后期维护的难度。为了解决这个问题,模板方法模式应运而生。 + +## 使用场景 + +模板方法模式适用于以下场景: + +- 当存在一组相似的操作,它们具有相同的算法结构,但实现细节各不相同时。 +- 当希望在不改变算法的整体结构的情况下,允许子类自由扩展或修改某些步骤时。 +- 当希望将算法的实现细节封装起来,只暴露出高层接口供调用者使用时。 + +JUC 下的 AQS 就使用到了模板方法模式,其中 `acquire()` 是模板方法。`tryAcquire()` 方法的具体实现去交给子类完成。 + +```java + /** + * Acquires in exclusive mode, ignoring interrupts. Implemented + * by invoking at least once {@link #tryAcquire}, + * returning on success. Otherwise the thread is queued, possibly + * repeatedly blocking and unblocking, invoking {@link + * #tryAcquire} until success. This method can be used + * to implement method {@link Lock#lock}. + * + * @param arg the acquire argument. This value is conveyed to + * {@link #tryAcquire} but is otherwise uninterpreted and + * can represent anything you like. + */ + public final void acquire(int arg) { + if (!tryAcquire(arg) && + acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) + selfInterrupt(); + } + + protected boolean tryAcquire(int arg) { + throw new UnsupportedOperationException(); + } +``` + +## 实现方式 + +**结构说明** + +模板方法模式由抽象类和具体子类组成。抽象类定义了算法的框架,其中包含了一个或多个抽象方法,用于由具体子类实现。具体子类继承抽象类,并根据需要重写其中的抽象方法,从而实现具体的细节。 + +在模板方法模式中,通常涉及以下几个角色: + +- 抽象类(Abstract Class):抽象类定义了算法的框架,包括一个或多个抽象方法和具体方法。其中的抽象方法由子类实现,具体方法可以被子类直接继承或重写。 +- 具体子类(Concrete Subclass):具体子类继承抽象类,并根据需要实现其中的抽象方法。具体子类提供了算法的具体实现细节。 + +**示例代码** + +以下是一个简单的代码示例: + +```java +// 抽象类,定义模板方法和抽象步骤方法 +public abstract class AbstractClass { + // 模板方法,定义算法的整体结构 + public final void templateMethod() { + step1(); + step2(); + step3(); + } + // 模板公共方法 + protected final void step1(){ + System.out.println("ConcreteClass: Step 1"); + } + // 抽象步骤方法,由子类实现具体的步骤逻辑 + protected abstract void step2(); + // 抽象步骤方法,由子类实现具体的步骤逻辑 + protected abstract void step3(); +} + +// 具体子类,实现抽象步骤方法 +public class ConcreteClass extends AbstractClass { + + protected void step2() { + System.out.println("ConcreteClass: Step 2"); + } + + protected void step3() { + System.out.println("ConcreteClass: Step 3"); + } +} + +// 客户端代码 +public class Client { + public static void main(String[] args) { + AbstractClass abstractClass = new ConcreteClass(); + abstractClass.templateMethod(); + } +} +``` + +在上述代码中,我们首先定义了一个抽象类 `AbstractClass`,其中包含了模板方法和抽象方法。然后,我们创建了具体子类 `ConcreteClass`,根据需要实现了抽象方法。 + +在客户端代码 `Client` 中,我们创建了具体子类的对象,并调用了模板方法 `templateMethod()`,从而执行了定义好的算法。 + +运行该代码将输出以下结果: + +```java +ConcreteClass: Step 1 +ConcreteClass: Step 2 +ConcreteClass: Step 3 +``` + +注意: + +- 一般模板方法都加上 final 关键字, 防止子类重写模板方法。 +- 抽象模板中的基本方法尽量设计为 protected 类型,符合迪米特法则,不需要暴露的属性或方法尽量不要设置为 protected 类型。实现类若非必要,尽量不要扩大父类中的访问权限。 + +### 钩子方法 + +钩子方法(Hook Method)是模板方法模式中的一种特殊方法,用于在抽象类中提供一个默认的实现,但允许具体子类选择性地进行重写或扩展。钩子方法允许子类在不改变算法骨架的情况下,对算法的某些步骤进行定制。 + +以下是一个包含钩子方法的 Java 示例代码: + +```java +// 抽象类,定义模板方法和钩子方法 +public abstract class AbstractClass { + // 模板方法,定义算法的整体结构 + public final void templateMethod() { + step1(); + step2(); + // 钩子方法的调用 + if (hookMethod()) { + step3(); + } + } + + protected abstract void step1(); + + protected abstract void step2(); + + // 钩子方法,默认返回true,子类可以选择性地重写 + protected boolean hookMethod() { + return true; + } + + protected abstract void step3(); +} + +// 具体子类1 +public class ConcreteClass1 extends AbstractClass { + protected void step1() { + System.out.println("ConcreteClass1: Step 1"); + } + + protected void step2() { + System.out.println("ConcreteClass1: Step 2"); + } + + protected void step3() { + System.out.println("ConcreteClass1: Step 3"); + } +} + +// 具体子类2 +public class ConcreteClass2 extends AbstractClass { + protected void step1() { + System.out.println("ConcreteClass2: Step 1"); + } + + protected void step2() { + System.out.println("ConcreteClass2: Step 2"); + } + + protected boolean hookMethod() { + return false; // 重写钩子方法,返回false + } + + protected void step3() { + System.out.println("ConcreteClass2: Step 3"); + } +} + +// 客户端代码 +public class Client { + public static void main(String[] args) { + AbstractClass class1 = new ConcreteClass1(); + class1.templateMethod(); + + System.out.println("------------------"); + + AbstractClass class2 = new ConcreteClass2(); + class2.templateMethod(); + } +} +``` + +在上述代码中,我们定义了一个抽象类 `AbstractClass`,其中包含模板方法 `templateMethod() ` 和钩子方法 `hookMethod()`。在模板方法中,我们先执行了`step1() `和 `step2()` 两个基本操作方法,然后通过调用钩子方法决定是否执行 `step3()`。 + +具体子类 `ConcreteClass1` 和 `ConcreteClass2` 继承了抽象类,并实现了基本操作方法 `step1()`、`step2()` 和钩子方法 `hookMethod()`、`step3()`。 + +在客户端代码 `Client` 中,我们分别创建了具体子类的对象,并调用其模板方法,从而执行了定义好的算法。 + +运行该示例代码将输出以下结果: + +```java +ConcreteClass1: Step 1 +ConcreteClass1: Step 2 +ConcreteClass1: Step 3 +------------------ +ConcreteClass2: Step 1 +ConcreteClass2: Step 2 +``` + +通过重写钩子方法,具体子类可以选择性地对算法进行定制化。这就展示了钩子方法在模板方法模式中的应用。 + +## 优缺点 + +**优点** + +- 代码复用:模板方法模式通过将算法的通用结构定义在抽象类中,可以使子类直接继承这些通用部分,从而达到代码复用的目的。 +- 扩展性:模板方法模式允许子类根据需要重写父类的某些步骤,从而实现对算法的自由扩展和修改,同时保持整体结构的稳定性。 +- 封装性:模板方法模式将算法的实现细节封装在抽象类中,对调用者屏蔽了具体的实现细节,只暴露出高层接口。 + +**缺点** + +- 模板方法模式将算法的执行流程固定在抽象类中,可能会导致代码的可读性降低,增加理解和维护的难度。 +- 模板方法中的步骤越多, 其维护工作就可能会越困难。 +- 通过子类抑制默认步骤实现可能会导致违反里氏替换原则。 + +## 总结 + +模板方法是一种简单但非常实用的设计模式,它通过定义一个算法的框架,并将具体实现延迟到子类中,实现了代码复用和扩展的目的。在具体实现步骤相对固定、但又存在差异性的情况下,模板方法模式能够很好地解决代码重复和维护难度的问题。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\347\255\226\347\225\245\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\347\255\226\347\225\245\346\250\241\345\274\217.md" new file mode 100644 index 0000000..67caaea --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\347\255\226\347\225\245\346\250\241\345\274\217.md" @@ -0,0 +1,243 @@ +在软件开发中,经常会遇到需要根据不同的条件来实现不同行为的场景。这种场景下,策略模式(Strategy Pattern)就是一种非常有用的设计模式。 + +策略模式属于行为型模式,允许我们定义一系列算法,并将其封装在独立的策略类中,使得它们可以互相替换。通过使用策略模式,我们能够灵活地选择和切换不同的算法,而无需修改原有的代码,替代⼤量 if else 的逻辑。 + +## 使用场景 + +策略模式通常在以下情况下被使用: + +- 当存在多种实现方式,且需要在运行时动态选择具体实现时,策略模式非常有用。例如,一个购物应用可能需要根据用户的会员等级来计算折扣,不同等级对应不同的计算方式,这时就可以使用策略模式来实现。 +- 当存在一组类似的行为,只是实现细节略有不同,但又不希望通过继承来添加新的子类时,策略模式也很适用。它将这组行为封装在独立的策略类中,并通过委托的方式在上下文对象中使用。 + +例如: + + - **支付方式选择**:一个电子商务平台可以根据用户的选择来使用不同的支付策略,例如信用卡支付、支付宝支付、微信支付等。 + - **排序算法选择**:一个排序工具可以根据用户的需求选择不同的排序算法,例如快速排序、归并排序等。 + - **数据验证**:一个表单验证工具可以根据不同的验证规则采用不同的验证策略,例如长度验证、格式验证等。 + + 这些只是策略模式的一些例子,实际应用场景非常丰富。通过使用策略模式,我们可以将算法或行为与具体的业务逻辑解耦,使得系统更加灵活和可扩展。 + +## 策略模式实现 + +在策略模式中,有三个核心角色:上下文(Context)、策略接口(Strategy)和具体策略类(Concrete Strategy)。 + +- **上下文(Context)**:封装了具体策略的执行逻辑,提供给客户端使用的接口。上下文通常包含一个指向策略接口的引用,用于调用具体策略的方法。 +- **策略接口(Strategy)**:定义了一组算法或行为的公共接口,所有具体策略都必须实现该接口。 +- **具体策略类(Concrete Strategy)**:实现了策略接口,提供了具体的算法或行为。 + +下面我们来实现一下策略模式: + +**步骤 1** + +创建策略接口。 + +```java +//策略接口 +public interface PaymentStrategy { + void pay(double amount); +} +``` + +**步骤2** + +创建策略接口实现类。 + +```java +//具体策略类 +public class CreditCardPayment implements PaymentStrategy { + public void pay(double amount) { + System.out.println("使用信用卡支付:" + amount); + // 具体的支付逻辑 + } +} +``` + +```java +public class WeChatPay implements PaymentStrategy { + public void pay(double amount) { + System.out.println("使用微信支付:" + amount); + // 具体的支付逻辑 + } +} +``` + +注意:在实际项目中,我们一般通过工厂方法模式来实现策略类的声明。 + +实现关系如下: + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScNWmOxJppIVezhQlgqFicW6F5RdLCb3liaTwOQtb0aibCY2ZDQzviacfDBSvuvg8SZibXMKjwTg2EShqgw/640) + +**步骤 3** + +创建 Context 类。 + +```java +// 上下文类 +public class PaymentContext { + private PaymentStrategy paymentStrategy; + + public PaymentContext(PaymentStrategy paymentStrategy) { + this.paymentStrategy = paymentStrategy; + } + + public void pay(double amount) { + paymentStrategy.pay(amount); + } +} +``` + +调用一下: + +```java +// 使用示例 +public class Main { + public static void main(String[] args) { + PaymentStrategy strategy = new CreditCardPayment(); + PaymentContext context = new PaymentContext(strategy); + context.pay(100.0); + + strategy = new WeChatPay(); + context = new PaymentContext(strategy); + context.pay(200.0); + } +} +``` + +输出: + +``` +使用信用卡支付:100.0 +使用微信支付:200.0 +``` + +在上面的代码中,我们定义了一个 `PaymentStrategy` 接口作为策略接口,两个具体的策略类 `CreditCardPayment` 和 `WeChatPay` 实现了该接口。然后,我们创建了一个 `PaymentContext` 上下文对象,并根据需要传入不同的策略实例进行支付操作。 + +## 策略模式的优缺点 + +策略模式的优点包括: + +- **松耦合**:策略模式将不同的策略封装在独立的类中,与上下文对象解耦,增加了代码的灵活性和可维护性。 +- **易于扩展**:可以通过添加新的策略类来扩展系统的功能,无需修改现有代码。 +- **符合开闭原则**:对于新的策略,无需修改上下文对象,只需要实现新的策略接口即可。 + +策略模式的缺点包括: + +- **类数量增多**:每个具体策略都需要一个独立的类,如果策略较多,将导致类的数量增加。 +- **上层必须知道所有策略类**:上层模块必须知道有哪些策略,并选择合适的策略进行使用,这与迪米特法则是相违背的,我只是想使用了一个策略,我凭什么就要了解这个策略呢?那要你的封装类还有什么 意义?这是原装策略模式的一个缺点。 + +**注意事项:**如果一个系统的策略多于四个,就需要考虑使用混合模式,解决策略类膨胀的问题,否则日后的系统维护就会成为一个烫手山芋。 + +## 策略模式优化 + +### 使用Map取消 Context 类 + +我们可以将策略实现类放进 Map 中,根据 key 去选择具体的策略,就不必事先定义 Context 类。 + +```java +public static void main(String[] args) { + Map map=new HashMap<>(); + map.put("CREDIT_CARD", new CreditCardPayment()); + map.put("WECHAT_PAY",new WeChatPay()); + + map.get("CREDIT_CARD").pay(100.0); + map.get("WECHAT_PAY").pay(200.0); + } +``` + +### 策略枚举解决策略类膨胀 + +策略枚举可以解决策略类过多的问题。 + +我们对原装的策略模式进行改造,把原有定义在抽象策略中的方法移植到枚举中,让枚举成员成为一个具体策略。 + +```java +@Slf4j +public enum PaymentStrategyEnum { + CREDIT_CARD { + @Override + public void pay(double amount) { + log.info("使用信用卡支付:" + amount); + // 具体的支付逻辑 + } + }, + WECHAT_PAY { + @Override + public void pay(double amount) { + log.info("使用微信支付:" + amount); + // 具体的支付逻辑 + } + + }; + + public abstract void pay(double amount); +} +``` + + +在上面的代码中,我们定义了一个枚举类型 `PaymentStrategy`,其中包含两个枚举常量 `CREDIT_CARD` 和 `WECHAT_PAY`。每个枚举常量都重写了 `pay()` 方法,用于具体的支付逻辑。 + + +```java +// 使用示例 +public static void main(String[] args) { + Map map=new HashMap<>(); + map.put("CREDIT_CARD", PaymentStrategyEnum.CREDIT_CARD); + map.put("WECHAT_PAY", PaymentStrategyEnum.WECHAT_PAY); + + map.get("CREDIT_CARD").pay(100.0); + map.get("WECHAT_PAY").pay(200.0); + } +``` + + +注意:策略枚举是一个非常优秀和方便的模式,但是它受枚举类型的限制,每个枚举项都是 public、final、static 的,扩展性受到了一定的约束,因此在系统开发中,策略枚举一般担当不经常发生变化的角色。 + +### SpringBoot中的策略模式 + +SpringBoot中使用策略模式更加方便: + +```java +public interface Test { + void print(String name); +} +``` + +```java +@Service("testA") +@Slf4j +public class TestA implements Test{ + @Override + public void print(String name) { + log.info("实现类A"+name); + } +} + +``` + +```java +@Service("testB") +@Slf4j +public class TestB implements Test{ + @Override + public void print(String name) { + log.info("实现类B"+name); + } +} + +``` + +使用的时候 `@Autowired` 或者 `@Resource` 即可,SpringBoot会帮我们把实现类自动注入注入Map。 + +```java +@Resource +private Map map; +``` + +```java +Test test = map.get("你想拿出的具体策略类"); +test.print("hello world"); +``` + + ## 总结 + +策略模式是一种强大而灵活的设计模式,它可以帮助我们处理不同的算法或行为,并使系统更具可维护性和扩展性。通过封装具体的策略类和使用上下文对象,我们可以轻松地选择和切换不同的策略,而无需修改现有的代码。 \ No newline at end of file diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\243\205\351\245\260\345\231\250\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\243\205\351\245\260\345\231\250\346\250\241\345\274\217.md" new file mode 100644 index 0000000..a2f5e4c --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\243\205\351\245\260\345\231\250\346\250\241\345\274\217.md" @@ -0,0 +1,111 @@ +装饰器模式(Decorator Pattern)是一种结构型设计模式,它允许向现有对象添加新功能而不改变其结构。装饰器模式通过创建包装对象(装饰器)来动态地扩展对象的行为,是继承的替代方案之一。 + +在装饰器模式中,有一个抽象组件(Component)定义核心功能,具体组件(Concrete Component)实现这个核心功能,装饰器(Decorator)实现了抽象组件接口并持有一个指向抽象组件的引用。装饰器可以在调用抽象组件的方法之前或之后加入自己的逻辑,从而实现功能的动态扩展。 + +这种模式常被用于避免过度使用子类的情况,可以灵活地添加功能而不会导致类爆炸。装饰器模式符合开闭原则,即对扩展开放,对修改关闭。 + +## 组成部分 + +装饰器模式主要涉及以下几个角色: + +- Component(抽象组件):定义一个对象接口,可以给这些对象动态地添加职责。抽象组件通常是一个接口或抽象类,声明了具体组件和装饰器共同拥有的方法。 +- Concrete Component(具体组件):实现抽象组件接口,是被装饰的具体对象。具体组件是装饰的对象真正的实例,其功能是被装饰器动态增加功能的基础。 +- Decorator(装饰器抽象类):持有一个抽象组件的引用,并实现了抽象组件的接口。装饰器的存在对具体组件的功能进行了扩展或修饰。 +- Concrete Decorator(具体装饰器):继承自装饰器抽象类,具体装饰器向对象添加新的职责或行为。可以根据需要扩展具体装饰器类以添加不同的功能。 + +在装饰器模式中,抽象组件定义了核心功能,具体组件实现了这些功能,而装饰器通过包装具体组件并在其基础上添加额外功能来实现动态扩展。这种结构使得客户端代码可以不受影响地使用装饰后的对象,同时灵活地添加不同的装饰器以满足不同的需求。 + +## 使用场景 + +装饰器模式通常适用于以下场景: + +- **需要动态地给对象添加额外功能**:装饰器模式允许在运行时动态地给对象添加新的功能或行为,而不需要修改原有类的结构,这些功能可以再动态地撤销。 +- **避免使用子类进行扩展**:当通过继承会导致类爆炸或无法实现灵活组合时,装饰器模式是一个很好的替代方案。 +- **保持类的简单性**:通过将装饰器和具体组件分离,可以保持每个类的职责单一,并使整体结构更清晰。 +- **多层次的功能嵌套**:可以通过多个装饰器的组合实现多层次的功能嵌套,每个装饰器负责一部分功能,形成复杂的功能组合。 + +总之,装饰器模式适用于需要灵活地为对象添加功能、避免过多子类、保持简单性且能够动态地添加、移除功能的情况。通过装饰器模式,可以实现对对象功能的动态扩展,同时保持代码的灵活性和可维护性。 + +## 具体实现 + +以下是一个代码示例,演示了如何使用装饰器模式为咖啡添加配料,并计算总价。这个示例包括抽象组件接口(Coffee)、具体组件类(Espresso)、装饰器抽象类(CondimentDecorator)以及具体装饰器类(Milk),并展示了如何动态地组合装饰器实现功能扩展。 + +```java +// 抽象组件接口 +public interface Coffee { + String getDescription(); + double cost(); +} + +// 具体组件类 +public class Espresso implements Coffee { + public String getDescription() { + return "Espresso"; + } + + public double cost() { + return 1.99; + } +} + +// 装饰器抽象类 +public abstract class CondimentDecorator implements Coffee { + protected Coffee coffee; + + public CondimentDecorator(Coffee coffee) { + this.coffee = coffee; + } +} + +// 具体装饰器类:牛奶 +public class Milk extends CondimentDecorator { + public Milk(Coffee coffee) { + super(coffee); + } + + public String getDescription() { + return coffee.getDescription() + ", Milk"; + } + + public double cost() { + return coffee.cost() + 0.5; + } +} + +public class DecoratorPatternExample { + public static void main(String[] args) { + // 订购一杯Espresso + Coffee espresso = new Espresso(); + System.out.println("Order: " + espresso.getDescription() + ", Cost: $" + espresso.cost()); + + // 加牛奶 + Coffee espressoWithMilk = new Milk(espresso); + System.out.println("Order: " + espressoWithMilk.getDescription() + ", Cost: $" + espressoWithMilk.cost()); + } +} +``` + +在这个示例中,Espresso表示一种具体的咖啡,Milk是一个具体的装饰器类用于添加牛奶配料。在main方法中演示了如何通过装饰器模式为咖啡添加配料并计算价格。 + +以上代码会输出如下结果: + +```java +Order: Espresso, Cost: $1.99 +Order: Espresso, Milk, Cost: $2.49 +``` + +> Tips:若只有一个装饰类,则可以没有抽象装饰角色,直接实现具体的装饰角色即可。 + +装饰器模式的优点包括: + +- 灵活性:装饰器模式允许动态地为对象添加新的功能,而无需改变其原有的结构。可以根据需求组合多个装饰器,实现各种功能的组合,使得系统更加灵活。 +- 避免子类膨胀:相比使用继承来扩展对象功能,装饰器模式避免了子类膨胀的问题,使得类的继承体系更加简洁。 +- 单一责任原则:每个装饰器类只负责一个特定的功能,遵循了单一责任原则,降低了类的复杂度和耦合度。 + +装饰器模式的缺点包括: + +- 过多的对象:如果过度使用装饰器模式,可能会导致系统中出现大量小对象,增加了系统的复杂性。 +- 顺序影响:由于装饰器模式是通过嵌套组合实现的,装饰器的顺序可能会影响最终的行为,需要谨慎设计装饰器的顺序。 +- 初学者理解困难:对于初学者来说,理解装饰器模式可能会有一定的难度,特别是在多层装饰器嵌套的情况下。 + +总体来说,装饰器模式是一种非常有用的设计模式,能够帮助我们动态地扩展对象的功能,同时避免了继承带来的一些问题。在适当的场景下,合理地应用装饰器模式可以提高系统的灵活性和可扩展性。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\247\202\345\257\237\350\200\205\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\247\202\345\257\237\350\200\205\346\250\241\345\274\217.md" new file mode 100644 index 0000000..985847b --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\247\202\345\257\237\350\200\205\346\250\241\345\274\217.md" @@ -0,0 +1,417 @@ +观察者模式(Observer Pattern)是一种常见的行为型设计模式,用于在对象之间建立一种一对多的依赖关系。当一个对象的状态发生变化时,所有依赖它的对象都将得到通知并自动更新。 + +## 使用场景 + +观察者模式在许多应用中都有广泛的应用,特别是当存在对象之间的一对多关系,并且需要实时通知和更新时,观察者模式非常适用。下面列举几个典型的使用场景: + +- **消息发布/订阅系统**:观察者模式可以用于构建消息发布/订阅系统,其中消息发布者充当主题(被观察者),而订阅者则充当观察者。当发布者发布新消息时,所有订阅者都会收到通知并执行相应操作。 +- **用户界面组件**:在图形用户界面 (GUI) 开发中,观察者模式常被用于处理用户界面组件之间的交互。当一个组件的状态发生变化时,其他依赖该组件的组件将自动更新以反映新的状态。 +- **股票市场监控**:在金融领域,观察者模式可用于实现股票市场监控系统。各个投资者可以作为观察者订阅感兴趣的股票,在股票价格变动时即时收到通知。 +- **事件驱动系统**:观察者模式也常用于事件驱动系统中,如图形用户界面框架、游戏引擎等。当特定事件发生时,触发相应的回调函数并通知所有注册的观察者。 + +以上仅是观察者模式的一些典型使用场景,实际上,只要存在对象之间的依赖关系,并且需要实现解耦和灵活性,观察者模式都可以考虑作为一种设计方案。 + +## 实现方式 + +**观察者模式包含以下几个核心角色:** + +- 主题(Subject):也称为被观察者或可观察者,它是具有状态的对象,并维护着一个观察者列表。主题提供了添加、删除和通知观察者的方法。 +- 观察者(Observer):观察者是接收主题通知的对象。观察者需要实现一个更新方法,当收到主题的通知时,调用该方法进行更新操作。 +- 具体主题(Concrete Subject):具体主题是主题的具体实现类。它维护着观察者列表,并在状态发生改变时通知观察者。 +- 具体观察者(Concrete Observer):具体观察者是观察者的具体实现类。它实现了更新方法,定义了在收到主题通知时需要执行的具体操作。 + +下面是观察者模式的经典实现方式: + +1. **定义观察者接口**:创建一个名为 `Observer` 的接口,包含一个用于接收通知的方法,例如 `update()`。 + +```java +public interface Observer { + void update(); +} +``` + +2. **定义主题接口**:创建一个名为 `Subject` 的接口,包含用于管理观察者的方法,如 `registerObserver()`、`removeObserver()` 和 `notifyObservers()`。 + +```java +public interface Subject { + void registerObserver(Observer observer); + void removeObserver(Observer observer); + void notifyObservers(); +} +``` + +3. **实现具体主题**:创建一个具体类实现 `Subject` 接口,实现注册、移除和通知观察者的方法。在状态变化时调用 `notifyObservers()` 方法通知所有观察者。 + +```java +import java.util.ArrayList; +import java.util.List; + +public class ConcreteSubject implements Subject { + private final List observers = new ArrayList<>(); + private int state; + + public void setState(int state) { + this.state = state; + notifyObservers(); + } + + @Override + public void registerObserver(Observer observer) { + observers.add(observer); + } + + @Override + public void removeObserver(Observer observer) { + observers.remove(observer); + } + + @Override + public void notifyObservers() { + for (Observer observer : observers) { + observer.update(); + } + } +} +``` + +4. **实现具体观察者**:创建一个具体类实现 `Observer` 接口,实现接收通知并进行相应处理的方法。 + +```java +public class ConcreteObserver implements Observer { + private String name; + private Subject subject; + + public ConcreteObserver(String name, Subject subject) { + this.name = name; + this.subject = subject; + } + + @Override + public void update() { + System.out.println(name+" received notification"); + } +} +``` + +5. **使用观察者模式**:在实际代码中,我们可以创建具体的主题和观察者对象,并进行注册和触发状态变化。 + +```java +public class Main { + public static void main(String[] args) { + ConcreteSubject subject = new ConcreteSubject(); + + ConcreteObserver observer1 = new ConcreteObserver("Observer 1", subject); + ConcreteObserver observer2 = new ConcreteObserver("Observer 2", subject); + + subject.registerObserver(observer1); + subject.registerObserver(observer2); + + subject.setState(10); + // Output: + // Observer 1 received notification + // Observer 2 received notification + + subject.removeObserver(observer1); + + subject.setState(20); + // Output: + // Observer 2 received notification + } +} +``` + +### Java对观察者模式的支持 + +观察者模式在Java语言中的地位非常重要。在JDK的 java.util 包中,提供 `Observable` 类以及 `Observer` 接口,它们构成了Java语言对观察者模式的支持。 + +使用 `Observable` 类以及 `Observer` 接口,优化之后的代码为: + +```java +// 具体观察者 +public class ConcreteObserver implements Observer { + private String name; + + public ConcreteObserver(String name) { + // 设置每一个观察者的名字 + this.name = name; + } + + /** + * 当变化之后,就会自动触发该方法 + */ + @Override + public void update(Observable o, Object arg) { + if (arg instanceof Integer) { + System.out.println(this.name + " 观察到 state 更改为:" + arg); + } + } +} +``` + +```java +// 被观察者,继承 Observable 表示可以被观察 +public class ConcreteSubject extends Observable { + private int state; + + public ConcreteSubject(int state) { + this.setState(state); + } + + public int getState() { + return state; + } + + public void setState(int state) { + // 设置变化点 + super.setChanged(); + // 状态变化,通知观察者 + super.notifyObservers(state); + this.state = state; + } + + @Override + public String toString() { + return "state:" + this.state; + } +} +``` + +```java +public class TestObserve { + public static void main(String[] args) { + // 创建被观察者 + ConcreteSubject subject = new ConcreteSubject(0); + // 创建观察者 + ConcreteObserver ConcreteObserverA = new ConcreteObserver("观察者 A"); + ConcreteObserver ConcreteObserverB = new ConcreteObserver("观察者 B"); + ConcreteObserver ConcreteObserverC = new ConcreteObserver("观察者 C"); + // 添加可观察对象 + subject.addObserver(ConcreteObserverA); + subject.addObserver(ConcreteObserverB); + subject.addObserver(ConcreteObserverC); + + System.out.println(subject); + // Output: + // state:0 + subject.setState(1); + // Output: + // 观察者 C 观察到 state 更改为:1 + // 观察者 B 观察到 state 更改为:1 + // 观察者 A 观察到 state 更改为:1 + System.out.println(subject); + // Output: + // state:1 + + } +} +``` + +### Guava对观察者模式的支持 + +Guava 中使用 Event Bus 来实现对观察者模式的支持。 + +com.google.common.eventbus.EventBus 提供了以下主要方法: + +- register(Object listener):将一个对象注册为事件的监听器。 +- unregister(Object listener):从事件总线中注销一个监听器。 +- post(Object event):发布一个事件到事件总线,以便通知所有注册的监听器。 +- getSubscribers(Class eventClass):返回订阅指定事件类型的所有监听器的集合。 + +这些方法提供了事件的注册、注销、发布和获取监听器等功能,使得开发者可以方便地使用 EventBus 进行事件驱动编程。 + +```java +@Getter +@AllArgsConstructor +public class MyEvent { + private String message; +} +``` + +```java +@Slf4j +public class EventSubscriber { + @Subscribe + public void handleEvent(MyEvent event) { + String message = event.getMessage(); + // Handle the event logic + log.info("Received event: " + message); + } +} +``` + +```java +@Test +public void test() { + EventBus eventBus = new EventBus(); + EventSubscriber subscriber = new EventSubscriber(); + eventBus.register(subscriber); + + // Publish an event + eventBus.post(new MyEvent("Hello, World!")); + // Output: + // Received event: Hello, World! + } +``` + +### Spring对观察者模式的支持 + +Spring 中可以使用 Spring Event 来实现观察者模式。 + +在Spring Event中,有一些核心的概念和组件,包括ApplicationEvent、ApplicationListener、ApplicationContext和ApplicationEventMulticaster。 + +- ApplicationEvent(应用事件): + - ApplicationEvent是Spring Event框架中的基础类,它是所有事件类的父类。 + - 通过继承ApplicationEvent,并定义自己的事件类,可以创建特定类型的事件对象。 + - 事件对象通常包含与事件相关的信息,例如状态变化、操作完成等。 +- ApplicationListener(应用监听器): + - ApplicationListener是Spring Event框架中的接口,用于监听并处理特定类型的事件。 + - 通过实现ApplicationListener接口,并指定感兴趣的事件类型,可以创建具体的监听器。 + - 监听器可以定义在任何Spring Bean中,当所监听的事件被发布时,监听器会自动接收到该事件,并执行相应的处理逻辑。 +- ApplicationContext(应用上下文): + - ApplicationContext是Spring框架的核心容器,它负责管理Bean的生命周期和依赖关系。 + - 在Spring Event中,ApplicationContext是事件的发布者和订阅者的容器。 + - 通过获取ApplicationContext实例,可以获取ApplicationEventPublisher来发布事件,也可以注册ApplicationListener来监听事件。 +- ApplicationEventMulticaster(事件广播器): + - ApplicationEventMulticaster是Spring Event框架中的组件,用于将事件广播给各个监听器。 + - 它负责管理事件和监听器之间的关系,并将事件传递给对应的监听器进行处理。 + - Spring框架提供了几种实现ApplicationEventMulticaster的类,如SimpleApplicationEventMulticaster和AsyncApplicationEventMulticaster,用于支持不同的事件分发策略。 + +通过使用这些关键概念和组件,可以在 Spring 应用程序中实现事件驱动的编程模型。事件发布者(ApplicationEventPublisher)可以发布特定类型的事件,而订阅者(ApplicationListener)可以监听和处理已发布的事件。ApplicationContext作为容器,负责管理事件和监听器,并使用ApplicationEventMulticaster来实现事件的广播和分发。 + +下面是使用 Spring Event 实现观察者模式的例子: + +```java +/** + *

+ * 基础事件发布类 + *

+ * + */ + +public abstract class BaseEvent extends ApplicationEvent { + + /** + * 该类型事件携带的信息 + */ + private T eventData; + + /** + * + * @param source 最初触发该事件的对象 + * @param eventData 该类型事件携带的信息 + */ + public BaseEvent(Object source, T eventData) { + super(source); + this.eventData = eventData; + } + + public T getEventData() { + return eventData; + } +} +``` + +这里定义了一个基础事件发布抽象类,所有的事件发布类都可以继承此类。 +```java +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class User { + private Integer userId; + private String userName; +} +``` + +```java +public class UserEvent extends BaseEvent{ + private static final long serialVersionUID = 8145130999696021526L; + + public UserEvent(Object source, User user) { + super(source,user); + } + +} +``` + +```java +@Slf4j +@Service +public class UserListener { + /* + * @Async加了就是异步监听,没加就是同步(启动类要开启@EnableAsync注解) + * 可以使用@Order定义监听者顺序,默认是按代码书写顺序 + * 如果返回类型不为void,则会被当成一个新的事件,再次发布 + * @EventListener注解在EventListenerMethodProcessor类被扫描 + * 可以使用SpEL表达式来设置监听器生效的条件 + * 监听器可以看做普通方法,如果监听器抛出异常,在publishEvent里处理即可 + */ + + //@Async + @Order(1) + @EventListener(condition = "#userEvent.getEventData().getUserName().equals('小明')") + public String lister1(UserEvent userEvent){ + User user =userEvent.getEventData(); + log.info(user.toString()); + return "小米"; + } + + @Async + @Order(2) + @EventListener + public void lister3(UserEvent userEvent){ + log.info("监听者2"); + } + @Async + @Order(3) + @EventListener + public void lister2(String name){ + log.info("我叫:"+name); + } + +} +``` + +```java +@Slf4j +@SpringBootTest +@RunWith(SpringRunner.class) +public class ObserveTest { + @Resource + private ApplicationEventPublisher applicationEventPublisher; + @Test + public void test() { + applicationEventPublisher.publishEvent(new UserEvent(this, new User(1, "小明"))); + // Output: + // User(userId=1, userName=小明) + // 我叫:小米 + // 监听者2 + } + +} +``` + +IDEA 中可以直接跳转到对应的监听器。 + +![](https://mmbiz.qpic.cn/mmbiz_png/jC8rtGdWScPXxrhqNqNL4uj4icEEzAN0k6eKPmje8CVjjhbIiccdhCWmicdynd9p4N45IyYgg5hW7kMEtgmQTFwpw/640) + +相比于 Guava Event Bus,Spring Event 在实现观察者模式时具有以下优点: + +- 集成性:Spring Event 是 Spring 框架的一部分,可以与其他 Spring 组件(如 Spring Boot、Spring MVC 等)无缝集成。这使得在一个应用程序中使用 Spring Event 变得更加方便和统一。 +- 注解驱动:Spring Event 支持使用注解来声明事件监听器和发布事件。通过使用 `@EventListener` 注解,开发人员可以轻松定义事件监听器方法,并且不需要显式注册和注销监听器。 + +## 优缺点 + +观察者模式有以下几个优点: + +- **解耦性**:观察者模式能够将主题和观察者之间的耦合度降到最低。主题与观察者之间都是松散耦合的关系,它们之间可以独立地进行扩展和修改,而不会相互影响。 +- **灵活性**:通过使用观察者模式,可以动态地添加、删除和通知观察者,使系统更加灵活。无需修改主题或观察者的代码,就可以实现新的观察者加入和旧观察者离开的功能。 +- **一对多关系**:观察者模式支持一对多的依赖关系,一个主题可以有多个观察者。这样可以方便地实现消息的传递和广播,当主题状态更新时,所有观察者都能得到通知。 + +虽然观察者模式具有许多优点,但也存在一些缺点: + +- **可能引起性能问题**:如果观察者较多或通知过于频繁,可能会导致性能问题。每个观察者都需要接收通知并执行相应操作,当观察者较多时,可能会增加处理时间和系统负载。 +- **可能引起循环依赖**:由于观察者之间可以相互注册,如果设计不当,可能会导致循环依赖的问题。这样会导致触发通知的死循环,造成系统崩溃或异常。 +- **顺序不确定性**:在观察者模式中,观察者的执行顺序是不确定的。如果观察者之间有依赖关系,可能会产生意外的结果。 + +综上所述,观察者模式在许多场景下都非常有用,但在使用时需要注意性能问题、循环依赖和执行顺序等方面的考虑。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\264\243\344\273\273\351\223\276\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\264\243\344\273\273\351\223\276\346\250\241\345\274\217.md" new file mode 100644 index 0000000..157e4ee --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\350\264\243\344\273\273\351\223\276\346\250\241\345\274\217.md" @@ -0,0 +1,355 @@ +责任链模式(Chain of Responsibility Pattern)是一种行为型设计模式,它允许多个对象按照顺序处理请求,并且每个对象可以选择自己是否处理该请求或将其传递给下一个对象。这种模式将请求的发送者和接收者解耦,同时提供了更大的灵活性和可扩展性。 + +## 简介 + +责任链模式通过将多个处理请求的对象组成一条链,使请求在链上传递,直到有一个对象处理它为止。每个处理对象都负责判断自己能否处理该请求,如果可以则进行处理,否则将请求传递给下一个处理对象。这样,请求发送者无需知道具体的处理对象,只需将请求发送到责任链上即可。 + +责任链模式包含以下角色: + +- **抽象处理者(Handler)**:定义一个处理请求的接口,并持有下一个处理者的引用。 +- **具体处理者(Concrete Handler)**:实现抽象处理者的接口,在处理请求前判断自己是否能够处理该请求,如果可以则进行处理,否则将请求传递给下一个处理者。 + +通过责任链模式,我们可以动态地组合处理对象,灵活地配置处理流程,这种解耦使得系统更加灵活和可扩展。 + +## 使用场景 + +责任链模式常用于以下场景: + +- **动态组合处理流程**:通过灵活配置责任链,可以动态地组合处理对象,实现不同的处理流程。每个处理者只需关注自己负责处理的请求,使得系统更加灵活和可扩展。 +- **避免请求的发送者和接收者之间的直接耦合**:通过将请求传递给责任链,请求发送者无需知道具体的处理对象,减少了对象之间的依赖关系。 +- **处理请求的顺序可变**:责任链模式允许在运行时根据需要改变处理请求的顺序,灵活调整处理流程。 + +常见的实际应用场景包括: + +- **日志记录器链**:一个日志记录系统可以根据日志级别将日志消息传递给不同的日志记录器,如控制台记录器、文件记录器、数据库记录器等。 +- **审批流程**:一个多级审批系统可以根据审批者的权限和级别将审批请求传递给下一个级别的审批者,直到获得最终的审批结果。 +- **异常处理**:一个异常处理系统可以根据异常类型将异常进行分类处理,如日志记录、邮件通知、异常展示等。 + +责任链模式在这些场景中可以减少代码的耦合性,提高代码的可维护性和可扩展性。 + +## 优缺点 + +**优点:** + +- **解耦发送者和接收者**:责任链模式将请求的发送者和接收者解耦,发送者无需知道具体的处理对象,只需将请求发送到责任链上即可。 +- **灵活动态的处理流程**:通过配置责任链,可以灵活地组合处理对象,实现不同的处理流程,并且可以在运行时动态地改变处理的顺序。 +- **增强代码的可扩展性**:由于责任链模式遵循开闭原则,新的处理者可以随时被加入到责任链中,不需要修改已有代码,提供了良好的扩展性。 +- **增强代码的可维护性**:每个处理者只需关注自己负责处理的请求,职责单一,使得代码更加清晰、可读性更高。 + +**缺点:** + +- **请求的处理不保证被处理**:由于责任链中的每个处理者都可以选择是否处理请求,如果没有正确配置责任链或者某个处理者没有正确处理请求,可能会导致请求无法被处理。 +- **性能问题**:当责任链过长或者请求在责任链中被频繁传递时,可能会对性能产生影响。因此,在设计责任链时需要注意链的长度和处理的复杂度。 +- **调试不方便**:当责任链特别是链条比较长, 环节比较多的时候,由于采用了类似递归的方式,调试的时候逻辑可能比较复杂。 + +在实际应用中,我们需要根据具体情况评估责任链模式的优缺点,并合理地选择使用或者改进这个模式。 + +## 责任链模式实现 + +要实现责任链模式,我们按照以下步骤进行: + +1. 定义处理者接口(Handler),声明处理方法,并添加设置下一个处理者的方法。 +2. 实现具体处理者类(ConcreteHandler),实现处理方法,并在需要时选择是否调用下一个处理者。 +3. 在客户端代码中创建责任链,并将请求发送到责任链上的第一个处理者。 + +以下是一个简单的示例,演示如何使用责任链模式处理请假申请: + +**步骤一:定义处理者接口(Handler)** + +```java +/** + *

+ * 责任链模式——抽象类处理器 + *

+ */ + +public abstract class AbstractHandler { + + /** + * 责任链中的下一个元素 + */ + protected AbstractHandler nextHandler; + + public AbstractHandler setNextChain(AbstractHandler nextHandler) { + this.nextHandler = nextHandler; + return nextHandler; + } + + /** + * 责任链处理逻辑 + */ + public void linkChain(LeaveRequest request) { + handler(request); + //这里还可以加入其他方法 + if (Objects.nonNull(nextHandler)) { + nextHandler.linkChain(request); + } + } + + /** + * 抽象方法 + */ + protected abstract void handler(LeaveRequest request); +} +``` + +请求对象为: + +```java +@Getter +public class LeaveRequest { + private final String employee; + private final int days; + + public LeaveRequest(String employee, int days) { + this.employee = employee; + this.days = days; + } + +} +``` + +**步骤二:实现具体处理者类(ConcreteHandler)** + +```java +@Slf4j +public class Handler1 extends AbstractHandler { + + @Override + public void handler(LeaveRequest request) { + if (request.getDays() <= 3) { + log.info("ConcreteHandlerA 处理了 " + request.getEmployee() + " 的请假申请,天数为:" + request.getDays()); + } + } +} + +@Slf4j +public class Handler2 extends AbstractHandler { + @Override + public void handler(LeaveRequest request) { + if (request.getDays() > 3 && request.getDays() <= 7) { + log.info("ConcreteHandlerB 处理了 " + request.getEmployee() + " 的请假申请,天数为:" + request.getDays()); + } + } +} + +@Slf4j +public class Handler3 extends AbstractHandler { + @Override + protected void handler(LeaveRequest request) { + if (request.getDays() > 7) { + log.info("ConcreteHandlerC 处理了 " + request.getEmployee() + " 的请假申请,天数为:" + request.getDays()); + } + } +} +``` + +**步骤三:在客户端代码中创建责任链,并将请求发送到责任链上的第一个处理者** + +```java +public class ChainPatternDemo { + private static AbstractHandler getChainOfHandler() { + AbstractHandler handler1 = new Handler1(); + AbstractHandler handler2 = new Handler2(); + AbstractHandler handler3 = new Handler3(); + //可以自定义链路顺序 + handler1.setNextChain(handler2).setNextChain(handler3); + return handler1; + } + + public static void main(String[] args) { + AbstractHandler chain = getChainOfHandler(); + LeaveRequest request1 = new LeaveRequest("张三", 2); + chain.linkChain(request1); + + LeaveRequest request2 = new LeaveRequest("李四", 5); + chain.linkChain(request2); + + LeaveRequest request3 = new LeaveRequest("王五", 10); + chain.linkChain(request3); + } +} +``` + +在上述示例中,我们定义了三个具体处理者类:`Handler1`、`Handler2`和`Handler3`,它们分别处理请假申请。客户端代码创建了责任链,并将请求发送给第一个处理者`Handler1`。每个具体处理者判断自己是否能够处理该请求,如果可以则进行处理,否则传递给下一个处理者。 + +运行以上代码,输出结果为: + +```java +ConcreteHandlerA 处理了 张三 的请假申请,天数为:2 +ConcreteHandlerB 处理了 李四 的请假申请,天数为:5 +ConcreteHandlerC 处理了 王五 的请假申请,天数为:10 +``` + +这只是一个简单示例,实际使用时可以根据业务需求进行适当的扩展和修改。 + +在使用责任链模式时,需要注意以下几点: + +- **确定责任链中的处理顺序**:要确保责任链中处理者的顺序是正确的,以便能够按照预期处理请求。处理者的顺序可以在创建责任链时进行设置。 +- **避免出现循环引用**:如果责任链中的处理者之间出现了循环引用,可能会导致请求无法被正确处理或进入死循环。因此,在设置下一个处理者时要注意避免出现循环引用的情况。 +- **处理者的数量控制**:在设计责任链时要注意控制处理者的数量,避免责任链过长导致性能下降。可以根据实际需求合理划分责任链,将相关的处理逻辑放在同一个处理者中,可以在 Handler 中设置一个最大节点数量,在 `setNextChain()` 方法中判断是否已经是超过其阈值,超过则不允许该链建立,避免无意识地破坏系统性能。 + +## 通过建造者模式优化 + +我们可以通过建造者模式来创建责任链中的处理者对象。这种优化可以使责任链的创建和配置更加灵活和可拓展,符合开闭原则。 + +优化后的示例代码: + +```java +/** + *

+ * 责任链模式——抽象类处理器 + *

+ */ + +public abstract class AbstractHandler { + + /** + * 责任链中的下一个元素 + */ + protected AbstractHandler nextHandler; + + private void setNextChain(AbstractHandler nextHandler) { + this.nextHandler = nextHandler; + } + + /** + * 责任链处理逻辑 + */ + public void linkChain(LeaveRequest request) { + handler(request); + //这里还可以加入其他方法 + if (Objects.nonNull(nextHandler)) { + nextHandler.linkChain(request); + } + } + + /** + * 抽象方法 + */ + protected abstract void handler(LeaveRequest request); + + public static class Builder { + private AbstractHandler head; + private AbstractHandler tail; + + public Builder addHandler(AbstractHandler handler) { + if (this.head == null) { + this.head = this.tail = handler; + return this; + } + this.tail.setNextChain(handler); + this.tail = handler; + return this; + } + + public AbstractHandler build() { + return this.head; + } + } +} +``` + +```java +public class ChainPatternDemo { + private static AbstractHandler getChainOfHandler() { + return new AbstractHandler.Builder() + .addHandler(new Handler1()) + .addHandler(new Handler2()) + .addHandler(new Handler3()) + .build(); + } + + public static void main(String[] args) { + AbstractHandler chain = getChainOfHandler(); + LeaveRequest request1 = new LeaveRequest("张三", 2); + chain.linkChain(request1); + + LeaveRequest request2 = new LeaveRequest("李四", 5); + chain.linkChain(request2); + + LeaveRequest request3 = new LeaveRequest("王五", 10); + chain.linkChain(request3); + } +} +``` + +在客户端代码中,我们使用建造者模式创建了一个包含多个处理者的责任链,并发送了一个请假申请。责任链会按照添加处理者的顺序依次处理请假申请,直到找到能够处理该请求的处理者为止。 + +通过调用 `addHandler` 方法,我们可以逐步构建责任链,将处理者添加到责任链的末尾,由于 `setNextChain()` 不对外调用,作用域可以更改为 `private`,最后,通过调用 `build` 方法,我们可以获取责任链的起始处理者。 + +## Spring中使用责任链模式 + +Spring中我们可以使用 `@Component`,`@Order` 注解,来让容器帮我们自动构建责任链,从而简化代码。 + +```java +public abstract class Handler { + abstract void handler(LeaveRequest request); +} +``` + +```java +@Order(value = 1) +@Component +@Slf4j +public class HandlerA extends Handler{ + @Override + public void handler(LeaveRequest request) { + if (request.getDays() <= 3) { + log.info("ConcreteHandlerA 处理了 " + request.getEmployee() + " 的请假申请,天数为:" + request.getDays()); + } + } +} + +@Order(value = 2) +@Component +@Slf4j +public class HandlerB extends Handler { + @Override + public void handler(LeaveRequest request) { + if (request.getDays() > 3 && request.getDays() <= 7) { + log.info("ConcreteHandlerB 处理了 " + request.getEmployee() + " 的请假申请,天数为:" + request.getDays()); + } + } +} + +@Order(value = 3) +@Component +@Slf4j +public class HandlerC extends Handler{ + @Override + public void handler(LeaveRequest request) { + if (request.getDays() > 7) { + log.info("ConcreteHandlerC 处理了 " + request.getEmployee() + " 的请假申请,天数为:" + request.getDays()); + } + } +} +``` + +测试: + +```java +@Test +public void test() { + for (Handler handler : handlerChain) { + LeaveRequest request = new LeaveRequest("王五", 10); + handler.handler(request); + } + } +``` + +输出: + +```java +ConcreteHandlerC 处理了 王五 的请假申请,天数为:10 +``` + +这种写法有其利弊,优点是可以避免繁琐的责任链构建过程,简化了代码结构;缺点是具体处理者类之间的执行顺序不够直观,具体使用时需要权衡考虑。 + +------ + +责任链模式是一种强大而灵活的设计模式,它可以帮助我们构建具有可扩展性和低耦合度的处理流程。通过将请求发送方和接收方解耦,责任链模式允许我们动态地改变或扩展请求的处理顺序,从而实现更高度的灵活性和可维护性。 + +责任链模式的优点在于其低耦合性、灵活性和可扩展性,使得我们能够更加轻松地管理和组织复杂的处理流程。然而,也要注意其缺点,即请求未必被处理和对处理顺序敏感的特点。 + +最重要的是,在实际应用中根据具体需求合理运用责任链模式,结合其他设计模式,以便在代码结构和可维护性上取得更好的效果。 \ No newline at end of file diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\351\200\202\351\205\215\345\231\250\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\351\200\202\351\205\215\345\231\250\346\250\241\345\274\217.md" new file mode 100644 index 0000000..18906d2 --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\351\200\202\351\205\215\345\231\250\346\250\241\345\274\217.md" @@ -0,0 +1,142 @@ +适配器模式(Adapter Pattern)属于结构型模式,用于将一个类的接口转换成客户端所期望的另一个接口。它允许不兼容的类之间进行合作,使得原本因接口不匹配而无法工作的类能够协同工作。 + +## 使用场景 + +适配器模式在以下情况下特别有用: + +- 当你想使用一个已经存在的类,但其接口与你的需求不匹配时。 +- 当你想创建一个可复用的类,该类与其他不相关的类或不可预见的类进行交互。 +- 当我们有动机地修改一个正常运行的系统的接口,这时应该考虑使用适配器模式。 + +## 实现方式 + +适配器模式的实现通常涉及三个角色:目标接口、适配器和被适配者。 + +- 目标接口:定义了客户端需要使用的方法,是客户端期望的接口。 +- 适配器:实现了目标接口,并包含一个对被适配者的引用。通过对被适配者的调用来完成客户端请求。 +- 被适配者:已经存在的类或接口,与目标接口不兼容。 + +在 Java 中,一个常见的使用适配器模式的例子是`InputStreamReader`类。该类是Java I/O库中用于将字节流(`InputStream`)适配成字符流(`Reader`)的适配器。 + +```java +FileInputStream fis = new FileInputStream("hello world"); +InputStreamReader adapter = new InputStreamReader(fis); +BufferedReader bfr = new BufferedReader(adapter); +``` + +在这个示例中,客户需要使用`BufferedReader`来读取文件字符流。然而,现有的接口只能提供字节流,例如`FileInputStream`。为了满足客户的需求,我们需要对现有的接口进行适配。 + +`InputStreamReader`充当了适配器的角色。它持有一个`FileInputStream`对象,并通过适配将其转换为所需的字符流接口。可以将`InputStreamReader`视为适配器模式的具体实现之一。 + +通过使用适配器模式,我们成功地将字节流接口适配成了字符流接口,使得`BufferedReader`能够以字符方式读取文件内容,从而满足了客户的需求。 + +适配器模式有两种比较常见的实现方式: + +- 类适配器模式(使用继承) +- 对象适配器模式(使用组合) + +### 类适配器实现 + +**类适配器通过继承来实现适配器功能** + +```java +// 目标接口 +public interface Target { + void request(); +} + +// 被适配者 +public class Adaptee { + public void specificRequest() { + System.out.println("Adaptee: specificRequest"); + } +} + +// 适配器 +public class Adapter extends Adaptee implements Target { + /** + * 采用继承的方式实现转换功能 + */ + @Override + public void request() { + super.specificRequest(); + } +} + +// 客户端代码 +public class Client { + public static void main(String[] args) { + Adaptee adaptee = new Adaptee(); + Target target = new Adapter(adaptee); + target.request(); // 通过适配器调用被适配者方法 + } +} +``` + +### 对象适配器实现 + +**对象适配器通过组合来实现适配器功能** + +以下是一个简单的示例代码: + +```java +// 目标接口 +public interface Target { + void request(); +} + +// 被适配者 +public class Adaptee { + public void specificRequest() { + System.out.println("Adaptee: specificRequest"); + } +} + +// 适配器 +public class Adapter implements Target { + private Adaptee adaptee; + + public Adapter(Adaptee adaptee) { + this.adaptee = adaptee; + } + + @Override + public void request() { + adaptee.specificRequest(); + } +} + +// 客户端代码 +public class Client { + public static void main(String[] args) { + Adaptee adaptee = new Adaptee(); + Target target = new Adapter(adaptee); + target.request(); // 通过适配器调用被适配者方法 + } +} +``` + +对象适配器和类适配器的区别是:类适配器是类间继承,对象适配器是对象的合成关系,也可以说是类的关联关系,这是两者的根本区别。 + +一般而言,由于对象适配器是通过类间的关联关系进行耦合的,因此在设计时就可以做到比较灵活,可以适配不同的被适配类,并且允许动态替换被适配对象。另外,对象适配器不受被适配类的限制。 + +类适配器通过继承现有接口类并实现目标接口,这样的话会使得现有接口类完全对适配器暴露,使得适配器具有现有接口类的全部功能,破坏了封装性,会引入一些设计上的限制。此外从逻辑上来说,这也是不符合常理的,适配器要做的是扩展现有接口类的功能而不是替代,类适配器只有在特定条件下会被使用。 + +对象适配器持有现有接口类一个实例,并扩展其功能,实现目标接口。这是推荐的方式,优先采用组合而不是继承,会使得代码更利于维护。 + +## 优缺点 + +优点: + +- 透明性:适配器模式可以使客户端对目标类和适配者类的使用变得透明。客户端只需要与目标接口进行交互,无需了解适配者类的内部实现细节。 +- 重用性:通过适配器模式,可以复用已经存在的可复用类。适配器将这些类适配到目标接口中,使得它们可以在新的环境下被重用。 +- 灵活性:适配器模式可以动态地适配不同的适配者类,从而满足不同的客户端需求。适配器模式允许在运行时更改适配器,以适应不同的情况和要求。 + +缺点: + +- 过多的适配器类:如果系统中存在大量的适配器类,会让系统非常零乱,不易整体进行把握,可能会导致代码结构的复杂性增加。 +- 可能引入额外的复杂性:适配器模式可能会导致系统中增加额外的类和对象,从而增加系统的复杂性。 + +## 总结 + +适配器模式通过将不兼容的接口转换为可协同工作的形式,实现了不同类之间的互操作。它可以提高代码的复用性和灵活性。但在使用过程中需要注意选择合适的适配器类型,并确保适配器能够正确地转换接口。 diff --git "a/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\351\227\250\351\235\242\346\250\241\345\274\217.md" "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\351\227\250\351\235\242\346\250\241\345\274\217.md" new file mode 100644 index 0000000..8a812b6 --- /dev/null +++ "b/docs/md/\350\256\276\350\256\241\346\250\241\345\274\217/\344\270\200\346\226\207\346\220\236\346\207\202\350\256\276\350\256\241\346\250\241\345\274\217\342\200\224\351\227\250\351\235\242\346\250\241\345\274\217.md" @@ -0,0 +1,302 @@ +软件开发过程中,我们经常会遇到复杂系统,其中包含多个子系统和接口。在这种情况下,为了简化客户端的调用过程,提高代码的可维护性和可读性,我们可以使用门面模式。 + +门面模式(Facade Pattern)也叫做外观模式,是一种结构型设计模式。它提供一个统一的接口,封装了一个或多个子系统的复杂功能,并向客户端提供一个简单的调用方式。通过引入门面,客户端无需直接与子系统交互,而只需要通过门面来与子系统进行通信。 + +门面模式中包含以下角色: + +- 门面(Facade):门面角色是门面模式的核心,它封装了系统内部复杂子系统的接口,为客户端提供一个简单的高层接口。门面角色知道哪些子系统负责处理请求,并将请求转发给相应的子系统进行处理。 +- 子系统(Subsystem):子系统角色是实际执行系统功能的组件。每个子系统都有自己的职责和行为,通过门面角色对外提供服务。 +- 客户端(Client):客户端角色通过调用门面角色提供的高层接口来使用系统功能,而无需直接与子系统交互。 + +在门面模式中,门面角色充当了客户端和子系统之间的中介者,隐藏了子系统的复杂性,简化了客户端的调用过程。客户端只需要与门面角色进行交互,而不需要了解和处理子系统的具体细节。 + +注意:门面对象只是提供一个访问子系统的一个路径而已,它不应该也不能参与具体的业务逻辑,否则就会产生一个倒依赖的问题:子系统必须依赖门面才能被访问,这是设计上一个严重错误,不仅违反了单一职责原则,同时也破坏了系统的封装性。 + +## 使用场景 + +门面模式适用于以下情况: + +- 当一个系统有很多复杂的子系统时,可以使用门面模式将其封装起来,隐藏内部复杂性,简化客户端的调用。 +- 当需要将客户端与复杂的子系统解耦,降低系统之间的依赖时,可以使用门面模式。 + +以下是一个简单的示例,展示了门面模式在电子商务系统中的应用。 + +假设我们的电子商务系统包含了订单管理、库存管理和支付管理等子系统。为了简化客户端的调用过程,我们可以使用门面模式来封装这些子系统,并提供一个统一的接口。 + +```java +// 订单管理子系统 +class OrderService { + public void createOrder() { + // 创建订单的具体实现 + } +} + +// 库存管理子系统 +class InventoryService { + public void checkStock() { + // 检查库存的具体实现 + } +} + +// 支付管理子系统 +class PaymentService { + public void makePayment() { + // 支付的具体实现 + } +} + +// 电子商务门面类 +class ECommerceFacade { + private OrderService orderService; + private InventoryService inventoryService; + private PaymentService paymentService; + + public ECommerceFacade() { + orderService = new OrderService(); + inventoryService = new InventoryService(); + paymentService = new PaymentService(); + } + + // 提供给客户端的接口 + public void placeOrder() { + orderService.createOrder(); + inventoryService.checkStock(); + paymentService.makePayment(); + } +} +``` + +在上述示例中,我们创建了一个电子商务门面类(ECommerceFacade),它封装了订单管理、库存管理和支付管理等子系统,并提供了一个简单的接口(placeOrder)供客户端调用。这样,客户端只需要通过门面类来完成下单操作,而无需直接与子系统交互。 + +## 门面模式实现 + +下面是门面模式的基本结构: + +```java +// 子系统A +public class SubSystemA { + public void operationA() { + System.out.println("子系统A的操作"); + } +} + +// 子系统B +public class SubSystemB { + public void operationB() { + System.out.println("子系统B的操作"); + } +} + +// 子系统C +public class SubSystemC { + public void operationC() { + System.out.println("子系统C的操作"); + } +} + +// 门面类 +public class Facade { + private SubSystemA subSystemA; + private SubSystemB subSystemB; + private SubSystemC subSystemC; + + public Facade() { + subSystemA = new SubSystemA(); + subSystemB = new SubSystemB(); + subSystemC = new SubSystemC(); + } + + // 提供简单的接口给客户端调用,隐藏了子系统的复杂性 + public void operation() { + subSystemA.operationA(); + subSystemB.operationB(); + subSystemC.operationC(); + } +} +``` + +在上述代码中,我们有三个子系统(SubSystemA、SubSystemB、SubSystemC),它们分别实现了具体的功能。然后,我们创建了一个门面类(Facade)来封装这些子系统,并提供了一个简单的接口供客户端调用。 + +## 优缺点 + +**优点** + +- 简化客户端的调用过程,隐藏了子系统的复杂性,提供了一个统一的接口,客户端无需了解子系统的具体实现。 +- 减少系统的相互依赖,解耦了客户端与子系统之间的依赖关系。 +- 提高了代码的可维护性和可读性。 + +**缺点** + +- 门面模式可能会导致门面类变得庞大,承担过多的责任。 +- 如果需要修改子系统的功能,可能需要修改门面类。 + +## 门面模式优化 + +在实际应用中,我们可以对门面模式进行一些优化和扩展。以下是几个常见的优化实现方式: + +### 子系统解耦 + +门面类可以通过委托来调用子系统的功能,而不是直接依赖于具体的子系统。这样可以使得子系统能够独立演化,不受门面类的影响。 + +```java +// 门面类 +class Facade { + private SubSystemInterface subSystemA; + private SubSystemInterface subSystemB; + + public Facade() { + subSystemA = new ConcreteSubSystemA(); + subSystemB = new ConcreteSubSystemB(); + } + + // 提供给客户端的接口 + public void operation() { + subSystemA.operation(); + subSystemB.operation(); + } +} + +// 子系统接口 +interface SubSystemInterface { + void operation(); +} + +// 具体的子系统A +class ConcreteSubSystemA implements SubSystemInterface { + public void operation() { + // 实现具体的功能 + } +} + +// 具体的子系统B +class ConcreteSubSystemB implements SubSystemInterface { + public void operation() { + // 实现具体的功能 + } +} +``` + +### 多个门面类 + +当门面已经庞大到不能忍受的程度,承担过多的责任时,可以考虑使用多个门面类,每个门面类负责与特定的子系统交互,原则上建议按照功能拆分,比如一个数据库操作的门面可以拆分为查询门面、删除门面、更新门面等。 + +```java +// 子系统A的门面类 +class SubSystemAFacade { + private SubSystemA subSystemA; + + public SubSystemAFacade() { + subSystemA = new SubSystemA(); + } + + // 提供给客户端的接口 + public void operation() { + subSystemA.operationA(); + } +} + +// 子系统B的门面类 +class SubSystemBFacade { + private SubSystemB subSystemB; + + public SubSystemBFacade() { + subSystemB = new SubSystemB(); + } + + // 提供给客户端的接口 + public void operation() { + subSystemB.operationB(); + } +} +``` + +通过上述优化实现方式,我们能够灵活地应对不同的需求和场景,提高了系统的可扩展性和维护性。 + +### 门面嵌套 + +假设我们有一个文件处理系统,其中包括三个子系统:文件读取(FileReader)、文件写入(FileWriter)和文件压缩(FileCompressor)。 + +现在有两个模块来访问该子系统:通用模块(GeneralModule)可以完整地访问所有业务逻辑,而受限模块(RestrictedModule)只能访问文件读取操作。 + +在这种情况下,我们可以在门面外再嵌套门面来解决接口权限问题,以供不同的模块访问。 + +```java +// 子系统:文件读取 +class FileReader { + public void read(String filePath) { + System.out.println("读取文件:" + filePath); + // 具体的读取逻辑... + } +} + +// 子系统:文件写入 +class FileWriter { + public void write(String filePath, String content) { + System.out.println("写入文件:" + filePath); + // 具体的写入逻辑... + } +} + +// 子系统:文件压缩 +class FileCompressor { + public void compress(String filePath, String destinationPath) { + System.out.println("压缩文件:" + filePath + " -> " + destinationPath); + // 具体的压缩逻辑... + } +} + +// 通用模块门面 +class GeneralFacade { + private FileReader fileReader; + private FileWriter fileWriter; + private FileCompressor fileCompressor; + + public GeneralFacade() { + this.fileReader = new FileReader(); + this.fileWriter = new FileWriter(); + this.fileCompressor = new FileCompressor(); + } + + public void processFile(String filePath, String content, String destinationPath) { + fileReader.read(filePath); + fileWriter.write(filePath, content); + fileCompressor.compress(filePath, destinationPath); + } + + public void read(String filePath) { + fileReader.read(filePath); + } + +} + +// 受限模块门面 +class RestrictedFacade { + private GeneralFacade generalFacade = new GeneralFacade(); + + public void readRestrictedFile(String filePath) { + generalFacade.read(filePath); + } +} + +// 客户端代码 +public class Client { + public static void main(String[] args) { + GeneralFacade generalFacade = new GeneralFacade(); + generalFacade.processFile("file.txt", "Hello World!", "compressed.zip"); + + RestrictedFacade restrictedFacade = new RestrictedFacade(); + restrictedFacade.readRestrictedFile("file.txt"); + } +} +``` + +在上述示例中,我们使用了两个不同的门面:GeneralFacade和RestrictedFacade。GeneralFacade提供了完整的访问子系统的方法(processFile),而RestrictedFacade仅提供了受限的文件读取方法(readRestrictedFile)。 + +通过不同的门面对象,通用模块可以访问所有子系统功能,而受限模块只能访问特定的子系统功能。 + +## 总结 + +通过使用门面模式,我们可以简化复杂系统的调用过程,提高代码的可维护性和可读性。门面模式将子系统进行封装,并提供一个简单的接口给客户端,隐藏了子系统的复杂性,同时解耦了客户端与子系统之间的依赖关系。 + + +