news 2026/11/4 3:45:25

分库分表与多数据源切换:统一路由、事务边界与落地验收

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
分库分表与多数据源切换:统一路由、事务边界与落地验收

简介:SSMDemo示例工程围绕分库分表与多数据源切换,面向具备Java、MyBatis基础并希望理解Sharding-JDBC落地的后端开发者。它将分片规则配置、数据源切换、事务策略与业务代码组织在同一套工程中,便于对照学习数据路由、跨库跨表查询、全局唯一ID及数据迁移等典型问题。压缩包共73个文件,约66KB,以Java源码、class字节码、XML配置和properties配置为主,另含JSP页面、SQL脚本及Maven与Eclipse工程文件,分别承载业务实现、分片与数据源配置、建表初始化及项目构建信息。已有2070人浏览学习。读者可获得一套可导入开发工具的Sharding-JDBC练习骨架,借助配置文件和示例SQL梳理分库分表改造思路,参考多数据源切换的代码组织方式,并理解分片策略与事务处理在工程中的配置位置,适合技术预研与方案验证。

1. 订单表涨到 8000 万行那天,分库分表和多数据源切换必须一起设计

某电商项目在第七个月遇到了一个很实际的问题:订单主表 8000 万行,加一个字段要锁表二十多分钟,凌晨的慢查询告警开始连成片。团队的第一反应是加索引,第二反应是加从库,但当单表写入稳定压在每秒三千以上、单库磁盘逼近水位线时,单机优化只是在拖时间。分库分表和多数据源切换这两个词,就是这时候被同时摆到桌面上的,注意是同时而不是先后。很多方案做不下去,恰恰因为只设计了分片、没设计切换:订单落到 4 个库之后,报表要跨库聚合、历史数据要单独归档、读写要分离,业务代码里到处 if-else 挑数据源,最后没人敢动。这套方案要做的,是把分片规则、数据源路由、事务边界收敛进一层统一的访问层,让业务代码只认识「查订单」,不认识「数据在哪」。它适合两类人:正在做毕设或课设、需要一套完整分库分表设计的学生,以及要给存量单体库做水平拆分改造的从业者。

2. 需求边界与选型:先确认该不该分,再决定怎么切

2.1 用三张表算清数据量与增长曲线

判断要不要分库分表,网上流传的「单表 500 万行」更像一种经验说法,不是标准答案。真正决定性能拐点的是索引树高度、缓冲池命中率和 DDL 维护窗口这三个量。我一般会让团队先填一张容量表,把未来 12 个月的增量算出来,而不是只看当下。

表名当前行数日均增量12 个月预估单行字节结论
订单主表8200 万45 万2.4 亿620必须水平拆分
订单明细2.1 亿130 万6.8 亿310与主表同键分片
支付流水7600 万40 万2.2 亿480随订单同库
商品表320 万2000390 万1500只做读写分离
字典配置表8000101.2 万400不分,广播表

这张表的作用不只是判断规模,更重要的是划出「谁跟谁必须在一起」。订单主表和订单明细必须同键同库,否则每次查详情都要跨库 JOIN;商品表虽然单行大,但行数增长慢,先做读写分离就够了,硬拆反而增加复杂度。分片方案的范围一旦划大,后面的路由、事务、聚合查询全都要跟着放大,这其实是在给自己找麻烦。

还有一个容易被忽略的维度是访问模式。订单表是典型的「写多、按用户查、按时间做后台统计」,这种表适合哈希分片;而账单类业务常常是「按月份查」,范围分片更自然。同一个系统里不同的表走不同的分片策略,是完全合理的,别为了好维护强行统一。

2.2 三条落地路线的取舍

企业里做分库分表,落地路线基本就三条:应用内增强 JDBC 层、独立部署代理层、业务代码手写路由。这三条没有绝对优劣,只有阶段匹配。

维度增强 JDBC 层代理层业务手写
接入成本低,改配置和依赖中,改连接地址与运维部署高,DAO 全改
SQL 兼容性受限于中间件解析能力较好,但对特殊语法仍有限制完全自主
多语言支持仅本语言栈任意语言任意语言
故障点跟随应用,无额外节点多一层网络跳转与单点风险无
排错难度中,日志在应用内高,出问题难定位到具体 SQL低但工作量大
适合阶段单体应用首次拆分多语言、多团队共用一套库分片逻辑极简单且固定

我通常倾向「增强 JDBC 层」起步。原因是分片改造最大的成本不是性能,而是存量 SQL 的兼容验证,增强 JDBC 层把改写留在应用进程内,出问题时能直接打断点看到改写后的真实 SQL,排查成本最低。代理层更适合公司已经有多个语言栈、多个团队连同一套库的场景,代价是多了一个必须做高可用的中间节点。

业务手写路由看起来最可控,实际上最难维护。它把分片规则散落到几十个 DAO 方法里,一旦分片键要调整,就是全量回归。除非分片逻辑简单到「按租户 ID 一分为二」,否则不建议走这条路。

提示:选型阶段一定要做一次存量 SQL 盘点,把所有带 JOIN、子查询、ORDER BY不带分片键、COUNT(*)全表的语句列出来,数量往往决定最终路线,而不是技术偏好。

2.3 分片键怎么定:一次定错,后面全是返工

分片键的选择是整套方案里最不可逆的一步。改分片键等于全量数据迁移,几乎没人愿意做第二次。选的时候至少要过四道关:基数够不够大、分布够不够均匀、是否出现在绝大多数查询条件里、是否会在业务演进中改变。

订单场景里,用户 ID 基数大、分布均匀、且 90% 以上的查询都带它,是天然的分片键。但这里有个细节:如果只用用户 ID 取模,同一个用户的所有订单会挤在同一张表里,遇到大卖家就会出现数据倾斜。常见做法是引入「基因法」,把订单号的低位基因嵌进分片键计算,或者采用「用户 ID 取模定库、订单号取模定表」的两级策略,让单用户订单在库内再散开。

另外要提前想清楚「不带分片键的查询」怎么办。订单列表按状态查、后台按时间导出,这些语句在分片后都会变成全路由。方案层面有两条路:一是建异构索引表,把状态、时间维度单独同步一份到独立的查询库;二是后台类查询直接走离线数仓,不走在线库。这两条都要在第 2 章就定下来,等到上线后再补,成本会翻几倍。

3. 总体架构设计:分片规则、路由层与数据源注册的职责划分

3.1 逻辑表到物理表的映射与配置结构

整套架构我一般分成四层:业务 DAO 层、路由与改写层、数据源选择层、物理库。业务只写逻辑表名t_order,路由层负责把它翻译成ds2.t_order_05这样的物理定位,数据源选择层负责把连接切到ds2。

配置上要提前把命名约定固定死:库统一ds0..dsN,表统一t_order_00..t_order_MM,序号补零对齐长度。补零这个细节很多人省掉,结果排序、脚本遍历、监控面板全要特殊处理,属于典型的「省一分钟、还一年债」。

# 数据源与分片规则配置,库数 4、每库表数 8 是压测后的结论,不是拍脑袋 spring: datasource: datasource: ds0: url: jdbc:mysql://db-order-0:3306/order_db?useSSL=false username: app_rw password: ${DB_PWD_0} hikari: maximum-pool-size: 30 # 单库连接上限,按 QPS / 单连接吞吐倒推 minimum-idle: 10 ds1: url: jdbc:mysql://db-order-1:3306/order_db?useSSL=false username: app_rw password: ${DB_PWD_1} ds2: url: jdbc:mysql://db-order-2:3306/order_db?useSSL=false username: app_rw password: ${DB_PWD_2} ds3: url: jdbc:mysql://db-order-3:3306/order_db?useSSL=false username: app_rw password: ${DB_PWD_3} sharding: tables: t_order: actual-data-nodes: ds$->{0..3}.t_order_$->{0..7} # 4 库 x 8 表 = 32 个物理分片 database-strategy: sharding-column: user_id algorithm: orderDbMod table-strategy: sharding-column: order_no algorithm: orderTableMod key-generator: column: order_id type: SNOWFLAKE

配置里最关键的两个参数是actual-data-nodes和sharding-column。前者决定中间件认不认得出所有物理分片,写错了会在启动时直接报「找不到路由节点」;后者一旦与实际 SQL 的查询条件对不上,就会退化成全路由。maximum-pool-size需要单独强调:分片之后库里连接数是「应用实例数 × 库数 × 单库连接数」,很容易把数据库的最大连接数打满,这一项通常要按实际 QPS 除以单连接可承载吞吐来倒推,而不是沿用单库时代的配置。

3.2 分片算法:取模、范围与基因法的适用边界

分片算法本身不复杂,难的是选对。取模适合均匀写入、点查为主的场景;范围分片适合按时间归档、有冷热分离需求的场景;一致性哈希适合节点会动态扩缩的场景,但会带来运维复杂度。

订单表我一般用两级取模:用户 ID 定库保证同用户数据聚在一起,订单号定表让单个大用户的数据在库内再散开。下面是库路由的实现,表路由同理。

/** * 库级分片:user_id 取模定库 * 关键点:负数用户 ID 要归一化,否则取模结果可能为负,直接抛路由异常 */ public class OrderDbShardingAlgorithm implements PreciseShardingAlgorithm<Long> { private static final int DB_COUNT = 4; @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) { long userId = shardingValue.getValue(); // 归一化,避免负数分片键导致下标越界 int index = (int) Math.floorMod(userId, DB_COUNT); String target = "ds" + index; if (!availableTargetNames.contains(target)) { // 配置与算法不一致时快速失败,别让它静默走到默认库 throw new IllegalStateException("无可用数据源: " + target); } return target; } }

这段代码有三个设计点值得说明。floorMod而不是%,是因为用户 ID 如果来自外部导入,出现负数时%会返回负值,路由下标直接越界,这类问题上线前几乎测不出来。availableTargetNames判空是用快速失败替代静默降级,分片中间件在找不到目标时会走默认数据源,结果就是数据写错库,事后靠对账才能发现。最后,算法类必须做成无状态的,不能在里面缓存用户到库的映射,否则节点扩容时各个实例的缓存不一致,路由立刻乱掉。

范围分片则要注意边界。比如按create_time按月切分,如果查询条件是「最近 30 天」,中间件会把 30 天覆盖的所有分片都路由一遍,再在内存里归并。分片数一多,这种查询会拖垮应用内存。所以范围分片必须配合「强制带时间范围」的 DAO 规范,超范围查询直接拒绝。

3.3 全局主键:雪花、号段与它们的坑

分表之后自增主键必然冲突,必须换成全局唯一 ID。可选方案主要是雪花算法和号段模式。

雪花算法性能好、趋势递增、不依赖外部服务,但强依赖机器时钟。回拨发生时如果没有处理逻辑,会直接产生重复 ID。工程上常用的解法是:检测到时钟回拨在可接受范围内(比如 5 毫秒)就自旋等待,超过阈值直接抛异常让上层重试,宁可失败也不能生成重复键。号段模式则不依赖时钟,从数据库批量取号缓存在本地,缺点是服务重启会浪费一段 ID,且需要额外的号段表。订单这种对唯一性零容忍的表,我一般选号段模式,宁可浪费号段也不冒时钟风险。

注意:分片键和主键最好保持独立。曾经见过设计把订单号当主键又当分片键,结果订单号里嵌了日期,所有订单都堆到最新月份的分片上,等于只用了 1/32 的容量。

3.4 跨库分页与聚合查询的处理

跨库分页是分片方案里最容易被低估的一块。LIMIT 10000, 20在单库是偏移扫描,在分片下会变成「32 个分片各取 10020 条,归并后丢弃前 10000 条」,内存和网络开销随页码线性增长。

工程上的处理方式有三种。第一种是游标分页,用上一页最后一条记录的排序键做WHERE条件,彻底消灭偏移量,这是电商列表页的标准做法。第二种是二次查询法,先在各分片取LIMIT offset/count, count拿到最小排序值,再回查补齐,适合必须支持跳页的后台场景。第三种是把深分页需求直接引导到离线数仓,在线接口只开放前 N 页。

聚合查询同理。COUNT(*)、SUM这类可以下推到各分片后归并,DISTINCT和GROUP BY非分片键则要慎用,通常的做法是建立独立的聚合结果表,由异步任务定时汇总,在线接口只读汇总表。

4. 多数据源切换落地:注解、切面与事务边界的协同

4.1 路由上下文:ThreadLocal 与动态数据源

分片中间件解决的是「一张逻辑表怎么落到物理表」,多数据源切换解决的是「这次请求该连哪个库」。两者是不同层次的问题,很多方案只做了前者,遇到历史库、报表库、只读库就抓瞎。

动态数据源的核心是一个可切换的DataSource实现,它本身不建连接,只根据当前上下文决定委派给哪个真实数据源。

/** * 数据源上下文:用栈而不是单值,支持嵌套切换后自动恢复 */ public final class DataSourceContextHolder { private static final ThreadLocal<Deque<String>> CONTEXT = ThreadLocal.withInitial(ArrayDeque::new); public static void push(String dsKey) { CONTEXT.get().push(dsKey); } public static void poll() { Deque<String> deque = CONTEXT.get(); if (!deque.isEmpty()) { deque.poll(); } // 栈空了就移除,避免线程池复用线程时残留上下文 if (deque.isEmpty()) { CONTEXT.remove(); } } public static String peek() { return CONTEXT.get().peek(); } }

用Deque而不是单个String,是为了支持嵌套调用:外层方法切到报表库,内层方法切到归档库,内层结束后要能自动回到报表库,而不是一路回到默认库。poll里那句CONTEXT.remove()是线程池环境下的必要条件,Tomcat 的线程会被复用,不清理就会把上一次请求的数据源带到下一次请求里,这类故障表现为「偶发查不到数据」,排查起来极其难受。

AbstractRoutingDataSource的实现只需覆写determineCurrentLookupKey,返回上下文栈顶的值,为空时返回默认库。这里不要做任何业务判断,只做取值,判断逻辑全部放在切面里。

4.2 注解与切面的优先级设计

业务代码不该关心数据源,标准做法是自定义注解加 AOP。注解放在方法上,切面在方法执行前压栈、执行后弹栈。

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value() default "master"; // 默认主库 boolean forceMaster() default false; // 强制走主库,用于写后立即读 }

切面实现时有两个细节必须处理。第一是优先级:数据源切面的@Order必须高于事务切面。事务开启时就已经从数据源拿了连接,如果数据源切换发生在事务开启之后,切了也没用,连接早就绑死在旧库上了。这个顺序问题几乎每个团队都会遇到一次。第二是方法级注解覆盖类级注解,通过AnnotationUtils.findAnnotation从具体方法往上找,找不到再取类上的默认值。

切面里还要处理异步场景。@Async或手动提交到线程池的任务,ThreadLocal 不会自动传递,子线程拿到的是空上下文,直接落到默认库。我一般封装一个ContextPropagatingTaskDecorator,在任务提交时把父线程的上下文快照带上,在子线程执行前恢复、执行后清理。

4.3 事务内切换失效的根因

这是被问得最多的一个问题:方法上加了@DS("slave"),为什么还是查到了主库?根因在于 Spring 的事务管理器在事务开启时通过DataSource取了一次连接,并把连接绑定到当前线程。之后所有 SQL 都复用这个连接,AbstractRoutingDataSource的determineCurrentLookupKey再也不会被调用。

解决思路不是绕开它,而是把切换点前移到事务之外。具体做法有两种。第一种是在事务边界之外做切换,即让调用方先确定数据源,再进入事务方法,这要求切面顺序正确且切点不在事务内部。第二种是显式地声明多个事务管理器,每个数据源一个,用@Transactional(transactionManager = "slaveTxManager")绑定,代价是配置量翻倍。

对于跨库写操作,别指望单机事务能兜住。订单落 A 库、流水落 B 库,这种场景要么走 TCC 或本地消息表做最终一致,要么在业务上改造,让强关联的数据落在同一个分片键下。后者是更便宜的选择,也是分片键设计阶段就该考虑的事情。

// 正确顺序:切面 order 小于事务切面 order,先切库再开事务 @DS("report") @Transactional(rollbackFor = Exception.class) public void rebuildReport(Long userId) { // 这里的连接已经是 report 库 reportMapper.deleteByUser(userId); reportMapper.insertBatch(loadFromOrderDb(userId)); }

这段代码能生效的前提是数据源切面的@Order(1)、事务切面的@Order(2)。顺序写反了不会报错,只会静默地连错库,属于必须写进团队规范的隐性约定。

4.4 读写分离与强制走主库

多数据源最常见的落地形态是主从分离。写走主库、读走从库,看上去简单,实际有两个必踩的坑:主从延迟导致的写后读不一致,以及从库故障时的降级。

写后读不一致的处理方式是提供forceMaster开关。用户下单成功后立刻跳转到订单详情页,这个请求必须强制走主库,否则从库延迟 200 毫秒就会让用户看到「订单不存在」。开关的粒度要精细到方法级,不能做成全局配置,否则等于没做读写分离。

从库故障降级则要区分场景。列表页读从库失败可以降级到主库,但要加限流,防止主库被打穿;报表类查询失败就直接返回错误,不要降级,因为这类查询本身就很重,把主库拖垮的代价远大于报表不可用。

5. 避坑与排查:上线后才暴露的五类故障

5.1 路由与查询类故障

现象一:新上线的按状态查订单接口,响应时间从 80 毫秒涨到 4 秒,数据库 CPU 打满。

原因是查询条件里没有分片键,中间件只能把 SQL 广播到全部 32 个分片,再在应用内存里归并结果。数据量小时看不出来,数据涨起来就是灾难。

解决方式分两层。短期在 DAO 层加拦截,检测到WHERE条件不含分片键就直接抛异常,让问题在测试环境暴露;长期建一张按status + create_time的异构索引表,由异步任务从主分片同步,列表查询走索引表,用主键回表补齐详情。

现象二:跨库 JOIN 在测试环境正常,生产环境偶发返回空列表。

原因是测试环境的分片数据恰好集中在同一个库,JOIN 就被改写成单库执行;生产环境数据分散,JOIN 无法下推,中间件要么报错,要么返回不完整结果。

解决方式是在方案阶段就禁用跨分片 JOIN,需要关联的字段冗余到主表,或者拆成两次查询在应用层做内存拼装。前者写放大,后者查放大,按读多写少还是写多读少来选。

现象三:分片扩容从 4 库扩到 8 库后,大约一半的查询查不到数据。

取模分片在扩容时,user_id % 4和user_id % 8的结果不同,存量数据还按旧规则分布,新规则自然找不到。

解决方式是扩容必须配数据迁移,用双写加全量迁移加校验三阶段完成,并在迁移期让读请求按「新规则查不到则回退旧规则」做兜底。如果预判到要扩容,一开始就用「先按 1024 个逻辑分片分好,再映射到物理库」的方式,扩容时只改映射表,不动数据。

5.2 事务、连接与主键类故障

现象四:批量导入任务跑十分钟后报「连接池获取连接超时」,但数据库连接数并没有满。

原因是任务在事务里循环处理,每处理一批就通过@DS切一次数据源,切到新数据源时向连接池申请连接,而旧连接因为事务还没结束无法归还,连接被一层层占住直到耗尽。

解决方式是把切换粒度提到批次外层,一个批次只切一次;或者干脆按分片键预先分组,每组开一个独立事务顺序执行。事务方法里绝对不要出现动态数据源切换,这是硬性约束。

现象五:订单号偶发重复,重启服务后概率下降。

这是雪花算法的时钟回拨,或者号段模式在多实例下号段缓存重叠。前者要靠启动时校验机器标识唯一、回拨检测两段逻辑兜住;后者要在号段表上加行锁并保证每次取号是原子的UPDATE ... SET max_id = max_id + step,取完再读。

排查这类问题别靠猜,把生成的 ID 全部落日志,带上时间戳和机器标识,出现重复时直接比对,比翻代码快得多。

提示:这五类问题里,有三类在功能测试阶段完全测不出来,必须靠「不带分片键的查询拦截」「连接池活跃数监控」「ID 唯一性对账」这三个常态化检查项来兜。

6. 验收与答辩:用一张用例表证明这套分片方案站得住

分片方案的验收,最忌讳只演示「能查能写」。能查能写只能说明路由没崩,说明不了方案成立。我一般用下面这张表逐项过,每项都要有可复现的输入和明确的通过标准。

验收项验证方法通过标准
分片均匀性统计 32 个物理分片的行数分布最大分片不超过均值 1.3 倍
点查路由用带分片键的 ID 查询,看执行日志只命中 1 个分片,无广播
无分片键查询构造只有状态条件的查询被拦截器拒绝,或走索引表
跨库分页翻到第 500 页取 20 条响应时间不超过首页的 2 倍
事务一致性同分片内两表写入后人为抛异常两表均回滚,无脏数据
数据源切换事务方法内外各切一次事务内不切换,切换点可追踪
主键唯一并发压测 10 分钟收集全部 ID重复数为 0
主从延迟写入后立即强制主库读取能读到最新值
扩容演练模拟 4 库扩 8 库并迁移迁移后校验行数与校验和一致

答辩或评审时,讲述顺序建议反过来:先讲你遇到的问题(8000 万行、DDL 锁表、慢查询),再讲为什么选 4 库 8 表这个具体数字(压测结论、连接数倒推、增长曲线推演),最后讲你怎么证明它有效(上面那张表加压测曲线)。评审最想听的不是「用了什么中间件」,而是「为什么是这个数」和「出错时你怎么知道」。

有个技巧值得单独说:准备一份「不带分片键的 SQL 清单」,把系统里所有会退化成全路由的语句列出来,标注处理方式(加条件、走索引表、走数仓)。这份清单在评审现场的说服力,远大于任何架构图,因为它证明你真的跑过存量代码,而不是画完图就交差。

从那以后我每接一个分片改造,都强制先把这张验收表和 SQL 清单跑一遍,再谈上线时间。翻车最惨的一次就是跳过了分片均匀性检查,上线第三天发现某个大卖家的订单把一张分片表撑到了别的表的八倍,紧急做数据重分布,熬了两个通宵。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 3:45:24

AI工具助力学术写作:从文献管理到数据分析

1. 学术写作的痛点与AI工具崛起读研那会儿&#xff0c;最怕导师凌晨三点发微信问论文进度。现在带研究生&#xff0c;发现学生们依然在重复我们当年的痛苦——文献综述写到头秃、数据分析卡在模型验证、格式调整耗掉整个周末。直到去年帮学生调试代码时&#xff0c;偶然发现他们…

作者头像 李华
网站建设 2026/11/4 3:44:56

Java与TypeScript核心特性对比分析

1. 从Java到TypeScript&#xff1a;语言特性深度对比作为一名从Java转型到TypeScript的开发者&#xff0c;我深刻体会到两种语言在设计哲学和实现细节上的显著差异。TypeScript虽然借鉴了Java的部分语法元素&#xff0c;但其底层机制与Java有着本质的不同。让我们从最核心的语言…

作者头像 李华
网站建设 2026/11/4 3:43:04

AI测试框架实战指南:从基础到生产级应用

1. 为什么需要系统化的AI测试框架学习路径第一次接触AI测试框架时&#xff0c;我像大多数人一样陷入了"工具迷宫"——TensorFlow的tf.test、PyTorch的torch.testing、专门的AI测试库如DeepChecks&#xff0c;还有各种模型验证工具让人眼花缭乱。更头疼的是&#xff0…

作者头像 李华
网站建设 2026/11/4 3:42:50

Compositor节点式修图工作流:开源免费、零学习成本上手

1. 这不是另一个“PS平替”口号&#xff0c;而是一套能真正落地的修图工作流Compositor 这个名字最近在设计类社区和开源工具圈里出现频率明显变高&#xff0c;但很多人点开官网第一眼看到界面&#xff0c;反而更犹豫了——它不像Photopea那样直接模仿PS布局&#xff0c;也不像…

作者头像 李华
网站建设 2026/11/4 3:42:29

建模、控制与优化:智能系统工程实践全解析

这几年我在工程计算和智能系统方向上投入了不少精力&#xff0c;一个很直观的感受是&#xff1a;人工智能早已不是计算机科学那帮人的专利&#xff0c;而是大规模渗透到了建模、控制与优化这些传统工科领域。CMES&#xff08;Computer Modeling in Engineering & Sciences&…

作者头像 李华