news 2026/10/6 10:13:28

Hibernate乐观锁配置全解析:从@Version到生产环境排障

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Hibernate乐观锁配置全解析:从@Version到生产环境排障

写这篇之前,我先说个背景。Hibernate这个系列前面聊了不少基础功夫,这次讲乐观锁配置。很多兄弟一听到“乐观锁”,第一反应就是“加个 @Version 不就行了”,真到线上出问题,版本号不更新、批量更新绕过检查、异常类型 catch 不到,一个比一个扎心。这篇文章不是简单贴注解,而是把乐观锁从原理到配置、从客户端锁模式到生产环境排障整个链路都过一遍,无论你是直接用 Hibernate 原生 API,还是 Spring Data JPA 封装的仓库接口,只要底层是 Hibernate,这篇内容都适用。

1. 乐观锁的定位与使用场景

1.1 并发控制的两条路线

做后端开发的朋友都清楚,多用户同时改同一行数据是个绕不开的问题。两个请求同时读到库存量 100,一个要改成 90,另一个要改成 80,如果都不做控制,最终结果取决于谁后写,另一个人的操作就白干了,这就是典型的“丢失更新”。

常见的处理思路无非两条。一条是悲观锁,直接对数据库里那行记录加锁,SELECT ... FOR UPDATE,别人想读都读不了,直到你提交事务释放锁。另一条就是乐观锁,它不锁数据库资源,而是借助一个版本号或者时间戳,在提交更新的时候检查一下“我手上的数据还是不是最新的”。这两种方案对应了完全不同的并发场景:悲观锁适合写多读少、冲突严重的业务,乐观锁适合读多写少、冲突不频繁的业务。

乐观锁本质上是 CAS 思想在数据库层面的落地——Compare And Swap,先比较后交换。流程很朴素:更新之前检查版本号,版本一致才更新并递增版本号,版本不一致就直接拒绝操作。这里用一个生活化的例子:你去银行柜台取款,大堂经理会先看存折上最后打印的那行余额,对照系统里的最新余额,一致才给你办业务,办完顺手把新的余额和流水刷在存折上。如果中间有人已经取过一笔,系统余额已经变了,你再拿着旧存折去办,经理就发现对不上,让你先补登再办。这个“存折余额”就是实体的版本号。

1.2 为什么我推荐你在 Hibernate 里优先用乐观锁

现在有个热搜词叫“hibernate还有人用吗”,每次刷到都觉得很有意思。Spring Data JPA 确实把开发体验做得很舒服,但是 JPA 规范下面跑的还是 Hibernate 那一套,尤其到了并发控制这种底层问题上,你绕不开它。老项目在维护、新项目在选型,只要还在 Java 生态里做关系型数据库持久层,Hibernate 的这套机制就依然值得吃透。

我在实际项目里优先推荐乐观锁,理由很简单:悲观锁持有数据库锁的时间太长,高并发下会把数据库连接池和行锁队列全部拖垮。一个事务里如果还有远程调用、消息发送甚至报表计算,整个过程都握着那行锁,别的请求全堵在锁等待上,吞吐量直接往下掉。乐观锁的好处是读操作完全无锁,只在提交更新那一刻做一次版本比对和递增,绝大多数场景下开销可以忽略不计。

当然乐观锁也不是银弹,它有个天然短板——冲突多了之后,大量请求会做无用功:读到旧版本,提交时报错,然后重新加载数据再重试。如果业务本身是秒杀抢购、热点账户扣减这种写非常密集的场景,乐观锁注定会演变成重试风暴,这时候老老实实回去用悲观锁或者上分布式锁,别硬扛。乐观锁最适合的场景是:一条数据经常被读,偶尔被改,改的时候又不想让并发写互相覆盖。订单状态流转、商品基础资料维护、配置管理这类业务,用它都特别合适。

2. 乐观锁配置的三种方式拆解

2.1 注解方式:@Version 一把梭

先讲最主流的方式,也就是 JPA 标准注解。给实体增加一个版本字段,然后在这个字段上加上@Version,Hibernate 就会自动接管它的读取和递增逻辑。

@Entity @Table(name = "t_stock") public class Stock { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "product_name", nullable = false) private String productName; @Column(name = "quantity", nullable = false) private Integer quantity; @Version @Column(name = "version", nullable = false) private Integer version; // getter 和 setter 省略 }

关键点来了:这个version字段你不用也不能手动去赋值,Hibernate 会在实体首次持久化时把初始值写到数据库(通常是 0 或 1),之后每次更新都会自动执行“版本比对 + 版本递增”。具体到 SQL 层面,更新语句会变成这样:

update t_stock set product_name = ?, quantity = ?, version = ? where id = ? and version = ?

where子句里带上旧版本号,set子句里把版本号加一。如果执行后受影响行数是 0,说明数据在你读取之后被别的会话改过了,Hibernate 会直接抛异常把这个操作拦下来,而不是默默覆盖。

用@Version有几个硬性要求要记牢:版本字段必须是实体的基本类型属性,不能是集合类型;数字类型或者时间戳类型都可以用,但后面会细说类型选择的坑;字段不能标注updatable = false,否则 Hibernate 会认为这个列不允许更新,版本机制直接失效。

2.2 XML 映射文件方式:老项目的标配

虽然注解已经是主流,但我敢打赌,现在还有不少跑了好多年的老系统用着 XML 映射文件,尤其是那些从 Hibernate 3.x 时代一路升级上来的项目。XML 方式的配置同样不复杂,在<class>节点内部声明一个<version>子节点即可。

<class name="com.demo.entity.Stock" table="t_stock"> <id name="id" column="id" type="long"> <generator class="identity"/> </id> <property name="productName" column="product_name" type="string"/> <property name="quantity" column="quantity" type="integer"/> <version name="version" column="version" type="integer"/> </class>

这里有个容易搞混的点:<version>不是<property>,即使它们都映射同一个列,也不能用<property>来声明版本字段。用<property>声明的话,Hibernate 只当它是普通业务字段,不会做版本递增和并发检查。XML 方式下type属性同样可以写integer、long、timestamp等,机制和注解完全一致。

如果你还在维护老项目,建议顺手检查一下映射文件里有没有把版本字段错写成<property>,这个错误通常不会导致启动失败,但会让乐观锁在不知不觉中失效。排查方法也简单,打开 SQL 日志看更新语句,where子句里没有version条件基本就是配置错了。

2.3 版本字段类型选择与隐藏的坑

版本字段选什么类型,看起来是个小问题,实际踩过坑的人才知道它能有多疼。JPA 规范允许的数字类型有short、int、long以及对应的包装类,时间类型支持java.sql.Timestamp,Hibernate 5.2 之后还支持java.time.Instant。

我的建议非常直接:能用Integer或Long就别用时间戳。时间戳做版本有个老生常谈的问题——精度。早期 MySQL 的TIMESTAMP类型默认只有秒级精度,两个事务在同一秒内先后提交,拿到的版本值完全一样,版本比对就形同虚设。MySQL 5.6.4 之后虽然支持了DATETIME(6)微秒精度,但前提是你建表时写对了,而且数据库驱动、方言配置也得跟上。为了省这点麻烦,不如直接上数字类型,自增语义清晰,排查问题也直观。

还有个隐藏坑是初始化值。如果表里已经存在存量数据,新增version列时一定要给默认值,比如ALTER TABLE t_stock ADD COLUMN version INT NOT NULL DEFAULT 0;。如果列允许为空,老记录的version全是NULL,Hibernate 在做版本比对时就会出各种莫名其妙的问题。我就见过有同事在测试环境表结构没处理好,更新接口报错,排查半天最后发现是version列存在NULL值。

3. 客户端锁模式与冲突处理配置

3.1 标准注解之外的 Hibernate 原生乐观锁配置

@Version是 JPA 规范的能力,但 Hibernate 自己还提供了一个更细粒度的原生注解@OptimisticLocking,它允许你控制“到底比对哪些字段”。这个注解在标准 JPA 下不生效,必须用 Hibernate 原生注解,直接放在实体类上。

@Entity @DynamicUpdate @org.hibernate.annotations.OptimisticLocking(type = OptimisticLockType.DIRTY) public class Stock { // 字段省略 }

OptimisticLockType有四个取值,含义差别很大:

类型作用适用场景
VERSION只比对版本字段,默认行为大部分业务场景
ALL比对实体的所有字段表里没有版本列,但需要防覆盖
DIRTY只比对本次修改过的字段实体字段多,希望减少冲突概率
NONE不做任何乐观锁检查明确知道不需要并发保护

ALL和DIRTY这两个模式有一个前提,就是实体对应的表里没有version列。Hibernate 会把where条件扩展到所有字段,比如一个Stock改了quantity,更新 SQL 就会变成where id=? and product_name=? and quantity=?。第一次见这个 SQL 的时候你可能觉得别扭,但它确实能防住“读到的数据被整体改过”的场景。

这里必须提醒一下:DIRTY模式有个隐含风险。如果两个并发请求改的是同一个实体的不同字段,DIRTY模式不会判定为冲突,因为更新的字段集合不同。这在某些业务里可能是好事,能减少无谓重试,但在另一些业务里就会出问题——两个请求改不同字段,都认为自己是成功的,最终语义上还是交错覆盖了。用之前一定要想清楚业务到底需不需要字段级隔离。

3.2 通过 LockMode 手动控制乐观锁行为

除了加注解被动防御,Hibernate 还允许你在代码里手动指定锁模式。常见的是LockMode(Hibernate 原生 API)和LockModeType(JPA API)中的两个乐观锁枚举值:OPTIMISTIC和OPTIMISTIC_FORCE_INCREMENT。

OPTIMISTIC是普通乐观锁,含义是“在事务提交前检查实体版本”。它适合那种“读取数据、长事务处理、最后提交时确认没人改过”的场景。代码这样写:

@Transactional public void updateStock(Long id, Integer newQuantity) { Stock stock = entityManager.find(Stock.class, id); // 事务操作中途,显式声明这个实体需要乐观锁校验 entityManager.lock(stock, LockModeType.OPTIMISTIC); // 后续业务处理 stock.setQuantity(newQuantity); }

OPTIMISTIC_FORCE_INCREMENT则更激进,它在锁定成功后立即递增版本号,不管当前实体有没有被修改,它的用途之一是“防止父实体被子实体更新连带着丢更新”。举个例子:订单主表和订单明细表,你只改了明细,想让订单主表的版本号也跟着变,这样别的会话在改主表时能感知到“订单被碰过了”,这时候就可以在锁定订单时用OPTIMISTIC_FORCE_INCREMENT。

@Transactional public void addOrderItem(Long orderId, Item item) { Order order = entityManager.find(Order.class, orderId); // 强制订单版本递增,确保并发修改能被感知 entityManager.lock(order, LockModeType.OPTIMISTIC_FORCE_INCREMENT); order.getItems().add(item); }

3.3 冲突发生后怎么处理:重试与提示

乐观锁配置好了,异常处理更不能省。JPA 标准下抛的是javax.persistence.OptimisticLockException,Hibernate 原生 API 下抛的往往是org.hibernate.StaleObjectStateException,而后者有时候会被包装成前者。写catch的时候,建议两个都抓住,或者直接捕获基类,避免漏掉原生 API 场景导致异常裸奔。

try { tx.commit(); } catch (StaleObjectStateException e) { tx.rollback(); // 记录日志:用户ID、实体ID、版本号、冲突原因 throw new BizException("数据已被其他用户修改,请刷新后再操作"); }

捕获异常之后,业务上通常有两条路。一是直接给用户一个友好的提示,让他刷新页面重新操作,适合后台管理这类低频编辑场景。二是自动重试,适合接口调用方不方便手动处理的场景。重试逻辑要控制次数和频率,我的经验是最大次数不超过 3 次,中间加一点随机退避,避免多个失败请求重试时再次扎堆撞车。

for (int i = 0; i < 3; i++) { try { return doUpdate(id, newQuantity); } catch (OptimisticLockException e) { // 重新读取最新数据,再次尝试 } } throw new BizException("系统繁忙,请稍后重试");

重试之前必须先重新查询实体,拿到最新版本号,不能拿着旧对象再 update 一次,否则必然再次失败,进入死循环。

4. 实战验证:把乐观锁完整跑起来

4.1 准备表结构和工程依赖

理论说了这么多,还是得跑起来看效果。我用一个库存表做演示,推荐你也照着在本地环境试一次,感受会完全不一样。

CREATE TABLE t_stock ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_name VARCHAR(50) NOT NULL, quantity INT NOT NULL, version INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

如果项目用的是 Maven,依赖可以这样配。为了方便演示,我直接用 Hibernate 原生 API,不走 Spring Boot 的封装,这样你能看清底层到底发生了什么。

<dependency> <groupId>org.hibernate.orm</groupId> <artifactId>hibernate-core</artifactId> <version>6.5.2.Final</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>

4.2 模拟两个并发会话的更新流程

核心实验思路很简单:开两个Session,都读取同一个Stock,然后第一个事务先提交,第二个事务再提交,观察第二个事务会不会被拦截。写这段测试代码时,最好在两个事务提交之间加个Thread.sleep,确保第一个事务真正落库了再操作第二个。

// 会话1:读取并更新 Session session1 = sessionFactory.openSession(); Transaction tx1 = session1.beginTransaction(); Stock stock1 = session1.get(Stock.class, 1L); System.out.println("会话1读到的版本: " + stock1.getVersion()); stock1.setQuantity(90); tx1.commit(); session1.close(); // 会话2:在会话1提交后再提交,模拟并发冲突 Session session2 = sessionFactory.openSession(); Transaction tx2 = session2.beginTransaction(); Stock stock2 = session2.get(Stock.class, 1L); System.out.println("会话2读到的版本: " + stock2.getVersion()); stock2.setQuantity(80); try { tx2.commit(); System.out.println("会话2提交成功"); } catch (StaleObjectStateException e) { System.out.println("会话2提交失败: " + e.getMessage()); tx2.rollback(); } finally { session2.close(); }

跑这段代码,你会看到控制台输出类似下面这样的结果:

会话1读到的版本: 0 会话2读到的版本: 0 会话1提交成功 会话2提交失败: Row was updated or deleted by another transaction

这个实验结果已经说明问题:会话 2 虽然读到的版本也是 0,但它提交时 Hibernate 发现数据库里的版本已经变成了 1,于是直接拒绝更新。你要是没用乐观锁,会话 2 的quantity=80会直接把会话 1 的quantity=90覆盖掉,数据最终停留在 80,丢了 10 个库存的变更。

4.3 从 SQL 日志看 Hibernate 怎么帮你比对版本

光看结果还不够,把hibernate.show_sql打开,你会看到 Hibernate 实际执行的 SQL 长什么样。配置很简单,在hibernate.cfg.xml里加一行:

<property name="hibernate.show_sql">true</property> <property name="hibernate.format_sql">true</property>

会话 1 提交时,Hibernate 生成的更新语句大概是这样的:

update t_stock set product_name = '蓝牙耳机', quantity = 90, version = 1 where id = 1 and version = 0

这条语句执行成功,因为数据库里的version确实是 0。会话 2 提交时生成的更新语句几乎一模一样,唯一的差别是quantity和version的值不同:

update t_stock set product_name = '蓝牙耳机', quantity = 80, version = 1 where id = 1 and version = 0

但这次执行后受影响的行数是 0,因为此刻数据库里的version已经是 1,where version = 0匹配不到任何记录。Hibernate 执行更新后会用受影响行数做校验,发现 0 行,就知道自己手里的版本是过期数据,马上抛异常终止事务。这就是乐观锁在底层最核心的机制——不是 Hibernate 主动查了一次版本号,而是通过更新语句受影响的记录数来判断的。

这里顺带讲一个很多人会忽略的细节:事务提交前 Hibernate 默认会自动flush,所以你在代码里没有手动调用任何方法,提交时也能看到这条 SQL。如果你用的是 JPA 的EntityManager,机制完全一样,只是在异常类型上会表现为OptimisticLockException。

5. 常见问题排查与避坑实录

5.1 版本字段不更新,多半是配置没生效

线上遇到“我明明加了 @Version,为什么更新语句里没有版本条件”,十有八九是下面这几个原因。

第一,字段上写了@Column(updatable = false)或者insertable = false。这个配置会告诉 Hibernate“这列不由持久层管理”,于是版本递增逻辑被静默跳过。第二,自定义拦截器或事件监听器在onFlushDirty里修改了版本字段,覆盖了 Hibernate 的默认行为。第三,用了 JPA 的entityManager.merge()合并一个脱管实体,而脱管实体的版本字段本身就是null,也可能导致检查失效。

排查思路很简单:打开 SQL 日志,重点看更新语句里有没有where ... version = ?以及set ... version = ?。没有的话,实体上所有跟 version 相关的注解逐个检查一遍,特别注意手滑把版本字段写成了普通@Basic属性还忘了加@Version。

5.2 批量更新绕过了乐观锁怎么办

这是个大坑。HQL 和 JPQL 的update语句默认走的是 Bulk Update 路径,直接生成一条数据库级更新语句,完全不会触发实体层面的版本检查和版本递增。换句话说,你如果用Query的方式批量改了数据,乐观锁在中间形同虚设。

// 危险写法:绕过版本检查 int rows = session.createQuery("update Stock s set s.quantity = :q where s.id = :id") .setParameter("q", 100) .setParameter("id", 1L) .executeUpdate();

Hibernate 面向这种需求提供了一个冷门但是好用的扩展语法:在update后面加上versioned关键字,就会强制批量更新参与版本管理和检查。

int rows = session.createQuery("update versioned Stock s set s.quantity = :q where s.id = :id") .setParameter("q", 100) .setParameter("id", 1L) .executeUpdate();

注意这是 Hibernate 原生扩展语法,JPQL 规范不认。而且它的语义是“把版本号也自动递增”,并不能帮你拦截并发冲突——它只是让批量更新不丢失版本递增动作。如果你的批量更新需要完整的并发检查,还是得老老实实把数据查出来逐条更新,或者走数据库层面的事务补偿。

5.3 级联操作与乐观锁的相互作用

实体间有@OneToMany、@ManyToOne等关联关系时,级联操作和版本检查之间经常出现误解。很多人以为级联更新子实体会自动检查父实体版本,其实不一定,要看你的锁模式和关联配置。

比较有代表性的是删除场景:对一个带@Version字段的实体执行删除,Hibernate 生成的删除语句也会带上版本条件。比如删除Stock时,SQL 会是delete from t_stock where id=? and version=?。这样做的目的是防止“删一条已经被别人改过的记录”造成误删。第一次见到这个行为可能会觉得奇怪,但这确实是有意设计的,不要试图通过自定义@SQLDelete把这个条件去掉,除非你非常清楚自己在做什么。

级联保存父实体和子实体时,如果父实体版本递增了,子实体的版本字段并不会跟着变化。这是正常的,别去纠结。真正要注意的是,父实体如果用OPTIMISTIC_FORCE_INCREMENT强制递增版本,无论子实体改没改,父实体都会发生变化,这会引发额外的更新语句和锁竞争,不适用的场景别乱用。

5.4 数据库默认值、触发器与 version 字段的冲突

最后说一个我最想吐槽的问题。有次排查一个线上更新频繁报错的功能,最后发现是运维同学在数据库里加了个触发器:每次更新t_stock都顺手把version字段加 1。看起来“很贴心”,实际上直接摧毁了 Hibernate 的版本机制。

为什么?因为 Hibernate 更新流程是先执行update ... set version = 1 where version = 0,然后用自己的版本比对和受影响行数逻辑判断结果。数据库触发器在 Hibernate 的 SQL 执行完之后又把version改成了 2,Hibernate 认为版本是自己递增到 1 的,但数据库幂等状态已经被破坏,下一轮更新永远会基于错误的版本号进行比对,冲突和异常就会反复出现。

正确的做法是:版本字段完全交给 Hibernate 管理,数据库层面不做任何触发器、默认值逻辑(DEFAULT 0这种静态默认值没问题,但动态计算就免了)。如果你接手了一个已经加了触发器的存量系统,先把这个触发器搬走,再对齐数据和程序里的版本号,把每个实体的 version 修正到与数据库一致,然后才能继续使用乐观锁。

另外补充一个救命小技巧:存量表初始化版本号时,一定要保证全表所有记录的 version 非空且从同一个起点开始。我之前见过业务表几百万条数据,新增 version 列时没注意历史数据,默认值NULL和0混着来,后来高并发更新时频繁出现StaleObjectStateException。这类问题排查起来非常痛苦,因为错误只在特定记录上触发,日志里看不出规律。最简单粗暴也是最好用的办法,就是一条 SQL 把历史数据的版本全部刷成 0,让所有实体站在同一起跑线上。

乐观锁的配置说白了不难,难的是理解它背后的机制和边界。@Version加注解只是第一步,版本字段类型选型、客户端锁模式选择、批量更新绕过问题、数据库触发器冲突,这些才是生产环境真正的考验。我自己做过几个订单类系统,最深刻的体会就是:乐观锁一定要配合明确的冲突处理策略一起上线,不管是重试还是提示,总得有个兜底方案,不然高并发一来,异常日志铺天盖地,业务方会一脸懵。最后再分享一个习惯:每次配置完乐观锁,我都先开 SQL 日志跑一遍并发用例,亲眼看到where ... version = ?出现再收工,这个习惯帮我挡掉了至少七八次低级配置错误。

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

数据结构实验资源包的正确打开方式:从复制到理解

简介&#xff1a;这是一份数据结构实验课完整资料&#xff0c;基于C语言实现&#xff0c;包含全部题目、完整源码与实验报告。面向计算机专业学生、考研复习者以及需要课程设计参考的开发者&#xff0c;可用于理解链表、数组、二叉树、图等核心数据结构的实际应用与算法设计。压…

作者头像 李华
网站建设 2026/10/6 10:13:03

力扣1417重新格式化字符串:从双指针陷阱到计数分类解法

我刷力扣有个习惯&#xff1a;碰到题目先不看题解&#xff0c;自己硬啃&#xff0c;实在卡住再翻讨论区。这种方式经常让我在 Easy 题上翻车&#xff0c;1417. Reformat The String&#xff08;重新格式化字符串&#xff09;就是最典型的一例。这道题的标签是 Easy&#xff0c;…

作者头像 李华
网站建设 2026/10/6 10:12:00

Keras 3 多后端架构解析:解耦原理与迁移实战

1. 这不是一场普通的技术发布会&#xff0c;而是一次框架演进的现场直播 “Keras 社区会议即将开始”——这行字出现在 Keras 官方 GitHub Discussions、Twitter 和邮件列表时&#xff0c;我正调试一个用了三年的老项目。它没有炫目的倒计时动效&#xff0c;没有明星工程师站台…

作者头像 李华
网站建设 2026/10/6 10:10:40

大模型上下文管理模式(context-mode)实战:从原理到代码实现

context-mode 这个词&#xff0c;乍一看像是某个编辑器插件里的开关选项。我第一次见到它&#xff0c;是在一个对话式 AI 项目的技术方案评审里&#xff0c;当时没太当回事&#xff0c;后来被上下文溢出、角色混乱、答非所问这些问题反复摩擦&#xff0c;才真正理解这个词背后的…

作者头像 李华
网站建设 2026/10/6 10:09:10

音视频扩散模型的自适应奖励路由机制

1. 这不是又一个“加个奖励函数”的老套路“音视频扩散模型的自适应奖励路由”——光看标题&#xff0c;很多人第一反应是&#xff1a;“哦&#xff0c;又是在扩散模型后面接个Reward Model做RLHF&#xff1f;”然后顺手点开下一条。我去年也这么想&#xff0c;直到在复现一篇顶…

作者头像 李华
网站建设 2026/10/6 10:08:27

AI Native团队落地指南:Agent、Harness与Plan Mode实战

1. 为什么“AI Native 团队”不是加个 Copilot 那么简单这两年我参与过几个团队从传统研发模式往 AI Native 方向转的过程&#xff0c;也踩了不少坑。最直观的感受是&#xff1a;绝大多数团队对“AI Native”的理解还停留在“给 IDE 装个补全插件”或者“让 AI 帮忙写写单测”这…

作者头像 李华